about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.codeclimate.yml1
-rw-r--r--.env.production.sample3
-rw-r--r--.eslintrc.yml24
-rw-r--r--.gitmodules0
-rw-r--r--.travis.yml3
-rw-r--r--CODE_OF_CONDUCT.md2
-rw-r--r--CONTRIBUTING.md35
-rw-r--r--Dockerfile3
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock12
-rw-r--r--README.md87
-rw-r--r--Vagrantfile2
-rw-r--r--app/controllers/about_controller.rb5
-rw-r--r--app/controllers/accounts_controller.rb3
-rw-r--r--app/controllers/activitypub/follows_controller.rb22
-rw-r--r--app/controllers/admin/base_controller.rb7
-rw-r--r--app/controllers/api/base_controller.rb2
-rw-r--r--app/controllers/api/v1/mutes_controller.rb31
-rw-r--r--app/controllers/api/v1/notifications_controller.rb9
-rw-r--r--app/controllers/api/v1/search_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines/direct_controller.rb60
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb1
-rw-r--r--app/controllers/application_controller.rb90
-rw-r--r--app/controllers/auth/confirmations_controller.rb8
-rw-r--r--app/controllers/auth/passwords_controller.rb5
-rw-r--r--app/controllers/auth/registrations_controller.rb5
-rw-r--r--app/controllers/auth/sessions_controller.rb5
-rw-r--r--app/controllers/authorize_follows_controller.rb5
-rw-r--r--app/controllers/follower_accounts_controller.rb4
-rw-r--r--app/controllers/following_accounts_controller.rb4
-rw-r--r--app/controllers/home_controller.rb5
-rw-r--r--app/controllers/invites_controller.rb5
-rw-r--r--app/controllers/oauth/authorizations_controller.rb5
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb5
-rw-r--r--app/controllers/remote_follow_controller.rb5
-rw-r--r--app/controllers/settings/applications_controller.rb4
-rw-r--r--app/controllers/settings/base_controller.rb12
-rw-r--r--app/controllers/settings/deletes_controller.rb6
-rw-r--r--app/controllers/settings/exports_controller.rb6
-rw-r--r--app/controllers/settings/flavours_controller.rb32
-rw-r--r--app/controllers/settings/follower_domains_controller.rb6
-rw-r--r--app/controllers/settings/imports_controller.rb5
-rw-r--r--app/controllers/settings/keyword_mutes_controller.rb61
-rw-r--r--app/controllers/settings/notifications_controller.rb6
-rw-r--r--app/controllers/settings/preferences_controller.rb8
-rw-r--r--app/controllers/settings/profiles_controller.rb5
-rw-r--r--app/controllers/settings/sessions_controller.rb1
-rw-r--r--app/controllers/settings/two_factor_authentication/confirmations_controller.rb6
-rw-r--r--app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb6
-rw-r--r--app/controllers/settings/two_factor_authentications_controller.rb5
-rw-r--r--app/controllers/shares_controller.rb8
-rw-r--r--app/controllers/statuses_controller.rb2
-rw-r--r--app/controllers/stream_entries_controller.rb3
-rw-r--r--app/controllers/tags_controller.rb1
-rw-r--r--app/helpers/jsonld_helper.rb4
-rw-r--r--app/helpers/mailer_helper.rb4
-rw-r--r--app/helpers/settings/keyword_mutes_helper.rb2
-rw-r--r--app/javascript/core/admin.js (renamed from app/javascript/packs/admin.js)2
-rw-r--r--app/javascript/core/common.js8
-rw-r--r--app/javascript/core/embed.js23
-rw-r--r--app/javascript/core/mailer.js1
-rw-r--r--app/javascript/core/public.js25
-rw-r--r--app/javascript/core/settings.js39
-rw-r--r--app/javascript/core/theme.yml19
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js661
-rw-r--r--app/javascript/flavours/glitch/actions/alerts.js24
-rw-r--r--app/javascript/flavours/glitch/actions/blocks.js82
-rw-r--r--app/javascript/flavours/glitch/actions/bundles.js25
-rw-r--r--app/javascript/flavours/glitch/actions/cards.js52
-rw-r--r--app/javascript/flavours/glitch/actions/columns.js40
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js396
-rw-r--r--app/javascript/flavours/glitch/actions/domain_blocks.js117
-rw-r--r--app/javascript/flavours/glitch/actions/emojis.js14
-rw-r--r--app/javascript/flavours/glitch/actions/favourites.js87
-rw-r--r--app/javascript/flavours/glitch/actions/height_cache.js17
-rw-r--r--app/javascript/flavours/glitch/actions/interactions.js313
-rw-r--r--app/javascript/flavours/glitch/actions/lists.js313
-rw-r--r--app/javascript/flavours/glitch/actions/local_settings.js24
-rw-r--r--app/javascript/flavours/glitch/actions/modal.js16
-rw-r--r--app/javascript/flavours/glitch/actions/mutes.js103
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js265
-rw-r--r--app/javascript/flavours/glitch/actions/onboarding.js14
-rw-r--r--app/javascript/flavours/glitch/actions/pin_statuses.js40
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/index.js23
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/registerer.js149
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/setter.js34
-rw-r--r--app/javascript/flavours/glitch/actions/reports.js80
-rw-r--r--app/javascript/flavours/glitch/actions/search.js73
-rw-r--r--app/javascript/flavours/glitch/actions/settings.js31
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js217
-rw-r--r--app/javascript/flavours/glitch/actions/store.js17
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js55
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js210
-rw-r--r--app/javascript/flavours/glitch/components/account.js141
-rw-r--r--app/javascript/flavours/glitch/components/attachment_list.js33
-rw-r--r--app/javascript/flavours/glitch/components/avatar.js77
-rw-r--r--app/javascript/flavours/glitch/components/avatar_overlay.js37
-rw-r--r--app/javascript/flavours/glitch/components/button.js64
-rw-r--r--app/javascript/flavours/glitch/components/collapsable.js22
-rw-r--r--app/javascript/flavours/glitch/components/column.js54
-rw-r--r--app/javascript/flavours/glitch/components/column_back_button.js29
-rw-r--r--app/javascript/flavours/glitch/components/column_back_button_slim.js31
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js213
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js30
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js215
-rw-r--r--app/javascript/flavours/glitch/components/extended_video_player.js54
-rw-r--r--app/javascript/flavours/glitch/components/icon.js26
-rw-r--r--app/javascript/flavours/glitch/components/icon_button.js137
-rw-r--r--app/javascript/flavours/glitch/components/intersection_observer_article.js130
-rw-r--r--app/javascript/flavours/glitch/components/link.js97
-rw-r--r--app/javascript/flavours/glitch/components/load_more.js26
-rw-r--r--app/javascript/flavours/glitch/components/loading_indicator.js11
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js305
-rw-r--r--app/javascript/flavours/glitch/components/missing_indicator.js12
-rw-r--r--app/javascript/flavours/glitch/components/notification_purge_buttons.js58
-rw-r--r--app/javascript/flavours/glitch/components/permalink.js40
-rw-r--r--app/javascript/flavours/glitch/components/relative_timestamp.js147
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js198
-rw-r--r--app/javascript/flavours/glitch/components/setting_text.js34
-rw-r--r--app/javascript/flavours/glitch/components/status.js441
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js185
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js245
-rw-r--r--app/javascript/flavours/glitch/components/status_header.js120
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js72
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js83
-rw-r--r--app/javascript/flavours/glitch/components/status_visibility_icon.js48
-rw-r--r--app/javascript/flavours/glitch/components/text_icon_button.js29
-rw-r--r--app/javascript/flavours/glitch/containers/account_container.js72
-rw-r--r--app/javascript/flavours/glitch/containers/card_container.js18
-rw-r--r--app/javascript/flavours/glitch/containers/compose_container.js38
-rw-r--r--app/javascript/flavours/glitch/containers/dropdown_menu_container.js16
-rw-r--r--app/javascript/flavours/glitch/containers/intersection_observer_article_container.js17
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js70
-rw-r--r--app/javascript/flavours/glitch/containers/media_gallery_container.js34
-rw-r--r--app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js49
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js159
-rw-r--r--app/javascript/flavours/glitch/containers/timeline_container.js48
-rw-r--r--app/javascript/flavours/glitch/containers/video_container.js26
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js144
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js107
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/components/media_item.js39
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js111
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js95
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js104
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js77
-rw-r--r--app/javascript/flavours/glitch/features/blocks/index.js70
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js35
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/index.js107
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js439
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js138
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js129
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/index.js225
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/index.js344
-rw-r--r--app/javascript/flavours/glitch/features/composer/publisher/index.js122
-rw-r--r--app/javascript/flavours/glitch/features/composer/reply/index.js85
-rw-r--r--app/javascript/flavours/glitch/features/composer/spoiler/index.js92
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/icons/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/index.js305
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js43
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js112
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/index.js53
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/item/index.js177
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js52
-rw-r--r--app/javascript/flavours/glitch/features/composer/warning/index.js54
-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/index.js107
-rw-r--r--app/javascript/flavours/glitch/features/drawer/account/index.js71
-rw-r--r--app/javascript/flavours/glitch/features/drawer/header/index.js118
-rw-r--r--app/javascript/flavours/glitch/features/drawer/index.js137
-rw-r--r--app/javascript/flavours/glitch/features/drawer/results/index.js116
-rw-r--r--app/javascript/flavours/glitch/features/drawer/search/index.js152
-rw-r--r--app/javascript/flavours/glitch/features/drawer/search/popout/index.js104
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js456
-rw-r--r--app/javascript/flavours/glitch/features/favourited_statuses/index.js98
-rw-r--r--app/javascript/flavours/glitch/features/favourites/index.js60
-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.js71
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.js93
-rw-r--r--app/javascript/flavours/glitch/features/following/index.js93
-rw-r--r--app/javascript/flavours/glitch/features/generic_not_found/index.js11
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js166
-rw-r--r--app/javascript/flavours/glitch/features/getting_started_misc/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.js118
-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.js90
-rw-r--r--app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js102
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/account.js77
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/search.js75
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/index.js80
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.js170
-rw-r--r--app/javascript/flavours/glitch/features/lists/components/new_list_form.js78
-rw-r--r--app/javascript/flavours/glitch/features/lists/index.js76
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/index.js65
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/navigation/index.js71
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js66
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js209
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/item/index.js87
-rw-r--r--app/javascript/flavours/glitch/features/mutes/index.js70
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js17
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js85
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js98
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js93
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/overlay.js57
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js34
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js39
-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.js193
-rw-r--r--app/javascript/flavours/glitch/features/pinned_statuses/index.js59
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/index.js107
-rw-r--r--app/javascript/flavours/glitch/features/reblogs/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/report/components/status_check_box.js37
-rw-r--r--app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js19
-rw-r--r--app/javascript/flavours/glitch/features/standalone/compose/index.js20
-rw-r--r--app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js70
-rw-r--r--app/javascript/flavours/glitch/features/standalone/public_timeline/index.js76
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js154
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.js125
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js130
-rw-r--r--app/javascript/flavours/glitch/features/status/containers/card_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js394
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/actions_modal.js125
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.js84
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle.js102
-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.js35
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_link.js39
-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.js179
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js53
-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.js84
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/favourite_modal.js84
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/image_loader.js152
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js126
-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.js135
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/mute_modal.js105
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js301
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js105
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/tabs_bar.js84
-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.js34
-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.js8
-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.js18
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js77
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js458
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js318
-rw-r--r--app/javascript/flavours/glitch/images/glitch-preview.jpgbin0 -> 197277 bytes
-rw-r--r--app/javascript/flavours/glitch/images/mbstobon-ui-0.pngbin0 -> 39646 bytes
-rw-r--r--app/javascript/flavours/glitch/images/mbstobon-ui-1.pngbin0 -> 43609 bytes
-rw-r--r--app/javascript/flavours/glitch/images/mbstobon-ui-2.pngbin0 -> 40376 bytes
-rw-r--r--app/javascript/flavours/glitch/images/mbstobon-ui-3.pngbin0 -> 32449 bytes
-rw-r--r--app/javascript/flavours/glitch/images/wave-drawer-glitched.pngbin0 -> 3544 bytes
-rw-r--r--app/javascript/flavours/glitch/images/wave-drawer.pngbin0 -> 3269 bytes
-rw-r--r--app/javascript/flavours/glitch/locales/ar.js7
-rw-r--r--app/javascript/flavours/glitch/locales/bg.js7
-rw-r--r--app/javascript/flavours/glitch/locales/ca.js7
-rw-r--r--app/javascript/flavours/glitch/locales/de.js7
-rw-r--r--app/javascript/flavours/glitch/locales/en.js66
-rw-r--r--app/javascript/flavours/glitch/locales/eo.js7
-rw-r--r--app/javascript/flavours/glitch/locales/es.js7
-rw-r--r--app/javascript/flavours/glitch/locales/fa.js7
-rw-r--r--app/javascript/flavours/glitch/locales/fi.js7
-rw-r--r--app/javascript/flavours/glitch/locales/fr.js7
-rw-r--r--app/javascript/flavours/glitch/locales/he.js7
-rw-r--r--app/javascript/flavours/glitch/locales/hr.js7
-rw-r--r--app/javascript/flavours/glitch/locales/hu.js7
-rw-r--r--app/javascript/flavours/glitch/locales/id.js7
-rw-r--r--app/javascript/flavours/glitch/locales/io.js7
-rw-r--r--app/javascript/flavours/glitch/locales/it.js7
-rw-r--r--app/javascript/flavours/glitch/locales/ja.js67
-rw-r--r--app/javascript/flavours/glitch/locales/ko.js7
-rw-r--r--app/javascript/flavours/glitch/locales/nl.js7
-rw-r--r--app/javascript/flavours/glitch/locales/no.js7
-rw-r--r--app/javascript/flavours/glitch/locales/oc.js7
-rw-r--r--app/javascript/flavours/glitch/locales/pl.js66
-rw-r--r--app/javascript/flavours/glitch/locales/pt-BR.js7
-rw-r--r--app/javascript/flavours/glitch/locales/pt.js7
-rw-r--r--app/javascript/flavours/glitch/locales/ru.js7
-rw-r--r--app/javascript/flavours/glitch/locales/sv.js7
-rw-r--r--app/javascript/flavours/glitch/locales/th.js7
-rw-r--r--app/javascript/flavours/glitch/locales/tr.js7
-rw-r--r--app/javascript/flavours/glitch/locales/uk.js7
-rw-r--r--app/javascript/flavours/glitch/locales/zh-CN.js7
-rw-r--r--app/javascript/flavours/glitch/locales/zh-HK.js7
-rw-r--r--app/javascript/flavours/glitch/locales/zh-TW.js7
-rw-r--r--app/javascript/flavours/glitch/middleware/errors.js31
-rw-r--r--app/javascript/flavours/glitch/middleware/loading_bar.js25
-rw-r--r--app/javascript/flavours/glitch/middleware/sounds.js46
-rw-r--r--app/javascript/flavours/glitch/names.yml15
-rw-r--r--app/javascript/flavours/glitch/packs/about.js22
-rw-r--r--app/javascript/flavours/glitch/packs/common.js4
-rw-r--r--app/javascript/flavours/glitch/packs/home.js7
-rw-r--r--app/javascript/flavours/glitch/packs/public.js75
-rw-r--r--app/javascript/flavours/glitch/packs/share.js22
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts.js141
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts_counters.js144
-rw-r--r--app/javascript/flavours/glitch/reducers/alerts.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/cards.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js360
-rw-r--r--app/javascript/flavours/glitch/reducers/contexts.js61
-rw-r--r--app/javascript/flavours/glitch/reducers/custom_emojis.js16
-rw-r--r--app/javascript/flavours/glitch/reducers/height_cache.js23
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js58
-rw-r--r--app/javascript/flavours/glitch/reducers/list_editor.js89
-rw-r--r--app/javascript/flavours/glitch/reducers/lists.js37
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js45
-rw-r--r--app/javascript/flavours/glitch/reducers/media_attachments.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/meta.js16
-rw-r--r--app/javascript/flavours/glitch/reducers/modal.js17
-rw-r--r--app/javascript/flavours/glitch/reducers/mutes.js29
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js191
-rw-r--r--app/javascript/flavours/glitch/reducers/push_notifications.js51
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js46
-rw-r--r--app/javascript/flavours/glitch/reducers/reports.js60
-rw-r--r--app/javascript/flavours/glitch/reducers/search.js42
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js127
-rw-r--r--app/javascript/flavours/glitch/reducers/status_lists.js87
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js148
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js149
-rw-r--r--app/javascript/flavours/glitch/reducers/user_lists.js80
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js87
-rw-r--r--app/javascript/flavours/glitch/service_worker/entry.js10
-rw-r--r--app/javascript/flavours/glitch/service_worker/web_push_notifications.js159
-rw-r--r--app/javascript/flavours/glitch/store/configureStore.js15
-rw-r--r--app/javascript/flavours/glitch/styles/_mixins.scss52
-rw-r--r--app/javascript/flavours/glitch/styles/about.scss827
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss566
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss502
-rw-r--r--app/javascript/flavours/glitch/styles/basics.scss122
-rw-r--r--app/javascript/flavours/glitch/styles/compact_header.scss34
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss463
-rw-r--r--app/javascript/flavours/glitch/styles/components/boost.scss28
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss503
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss445
-rw-r--r--app/javascript/flavours/glitch/styles/components/doodle.scss86
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss333
-rw-r--r--app/javascript/flavours/glitch/styles/components/emoji.scss105
-rw-r--r--app/javascript/flavours/glitch/styles/components/emoji_picker.scss204
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss1173
-rw-r--r--app/javascript/flavours/glitch/styles/components/lists.scss103
-rw-r--r--app/javascript/flavours/glitch/styles/components/local_settings.scss81
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss485
-rw-r--r--app/javascript/flavours/glitch/styles/components/metadata.scss52
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss668
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss105
-rw-r--r--app/javascript/flavours/glitch/styles/components/sensitive.scss24
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss698
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss116
-rw-r--r--app/javascript/flavours/glitch/styles/footer.scss30
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss570
-rw-r--r--app/javascript/flavours/glitch/styles/index.scss22
-rw-r--r--app/javascript/flavours/glitch/styles/landing_strip.scss111
-rw-r--r--app/javascript/flavours/glitch/styles/lists.scss19
-rw-r--r--app/javascript/flavours/glitch/styles/metadata.scss43
-rw-r--r--app/javascript/flavours/glitch/styles/modal.scss20
-rw-r--r--app/javascript/flavours/glitch/styles/reset.scss91
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss266
-rw-r--r--app/javascript/flavours/glitch/styles/stream_entries.scss356
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss82
-rw-r--r--app/javascript/flavours/glitch/styles/variables.scss38
-rw-r--r--app/javascript/flavours/glitch/theme.yml46
-rw-r--r--app/javascript/flavours/glitch/util/api.js34
-rw-r--r--app/javascript/flavours/glitch/util/async-components.js135
-rw-r--r--app/javascript/flavours/glitch/util/base_polyfills.js18
-rw-r--r--app/javascript/flavours/glitch/util/bio_metadata.js331
-rw-r--r--app/javascript/flavours/glitch/util/counter.js9
-rw-r--r--app/javascript/flavours/glitch/util/dom_helpers.js14
-rw-r--r--app/javascript/flavours/glitch/util/emoji/emoji_compressed.js93
-rw-r--r--app/javascript/flavours/glitch/util/emoji/emoji_map.json1
-rw-r--r--app/javascript/flavours/glitch/util/emoji/emoji_mart_data_light.js41
-rw-r--r--app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js157
-rw-r--r--app/javascript/flavours/glitch/util/emoji/emoji_picker.js7
-rw-r--r--app/javascript/flavours/glitch/util/emoji/emoji_unicode_mapping_light.js35
-rw-r--r--app/javascript/flavours/glitch/util/emoji/emoji_utils.js258
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js96
-rw-r--r--app/javascript/flavours/glitch/util/emoji/unicode_to_filename.js26
-rw-r--r--app/javascript/flavours/glitch/util/emoji/unicode_to_unified_name.js17
-rw-r--r--app/javascript/flavours/glitch/util/extra_polyfills.js5
-rw-r--r--app/javascript/flavours/glitch/util/fullscreen.js46
-rw-r--r--app/javascript/flavours/glitch/util/get_rect_from_entry.js21
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js23
-rw-r--r--app/javascript/flavours/glitch/util/intersection_observer_wrapper.js57
-rw-r--r--app/javascript/flavours/glitch/util/is_mobile.js34
-rw-r--r--app/javascript/flavours/glitch/util/js_helpers.js5
-rw-r--r--app/javascript/flavours/glitch/util/link_header.js33
-rw-r--r--app/javascript/flavours/glitch/util/load_polyfills.js39
-rw-r--r--app/javascript/flavours/glitch/util/main.js39
-rw-r--r--app/javascript/flavours/glitch/util/optional_motion.js5
-rw-r--r--app/javascript/flavours/glitch/util/performance.js31
-rw-r--r--app/javascript/flavours/glitch/util/react_helpers.js21
-rw-r--r--app/javascript/flavours/glitch/util/react_router_helpers.js64
-rw-r--r--app/javascript/flavours/glitch/util/ready.js7
-rw-r--r--app/javascript/flavours/glitch/util/reduced_motion.js44
-rw-r--r--app/javascript/flavours/glitch/util/redux_helpers.js8
-rw-r--r--app/javascript/flavours/glitch/util/rtl.js31
-rw-r--r--app/javascript/flavours/glitch/util/schedule_idle_task.js29
-rw-r--r--app/javascript/flavours/glitch/util/scroll.js30
-rw-r--r--app/javascript/flavours/glitch/util/settings.js46
-rw-r--r--app/javascript/flavours/glitch/util/stream.js73
-rw-r--r--app/javascript/flavours/glitch/util/url_regex.js196
-rw-r--r--app/javascript/flavours/glitch/util/uuid.js3
-rw-r--r--app/javascript/flavours/vanilla/names.yml16
-rw-r--r--app/javascript/flavours/vanilla/theme.yml44
-rw-r--r--app/javascript/images/icon_cached.svg2
-rw-r--r--app/javascript/images/icon_email.svg4
-rw-r--r--app/javascript/images/icon_grade.svg4
-rw-r--r--app/javascript/images/icon_lock_open.svg4
-rw-r--r--app/javascript/images/icon_person_add.svg4
-rw-r--r--app/javascript/images/icon_reply.svg4
-rw-r--r--app/javascript/images/logo_transparent.svg1
-rw-r--r--app/javascript/images/screenshot.jpgbin0 -> 239221 bytes
-rw-r--r--app/javascript/locales/index.js9
-rw-r--r--app/javascript/locales/locale-data/README.md (renamed from app/javascript/mastodon/locales/locale-data/README.md)0
-rw-r--r--app/javascript/locales/locale-data/oc.js (renamed from app/javascript/mastodon/locales/locale-data/oc.js)0
-rw-r--r--app/javascript/mastodon/actions/push_notifications/registerer.js12
-rw-r--r--app/javascript/mastodon/actions/settings.js4
-rw-r--r--app/javascript/mastodon/api.js12
-rw-r--r--app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap16
-rw-r--r--app/javascript/mastodon/components/account.js2
-rw-r--r--app/javascript/mastodon/components/attachment_list.js4
-rw-r--r--app/javascript/mastodon/components/collapsable.js4
-rw-r--r--app/javascript/mastodon/components/column_header.js19
-rw-r--r--app/javascript/mastodon/components/display_name.js2
-rw-r--r--app/javascript/mastodon/components/icon_button.js4
-rw-r--r--app/javascript/mastodon/components/status.js20
-rw-r--r--app/javascript/mastodon/features/account/components/header.js6
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js4
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/moved_note.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js4
-rw-r--r--app/javascript/mastodon/features/compose/index.js6
-rw-r--r--app/javascript/mastodon/features/compose/util/url_regex.js192
-rw-r--r--app/javascript/mastodon/features/emoji/__tests__/emoji-test.js4
-rw-r--r--app/javascript/mastodon/features/list_editor/index.js4
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/column_header.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/embed_modal.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js12
-rw-r--r--app/javascript/mastodon/features/ui/components/upload_area.js4
-rw-r--r--app/javascript/mastodon/features/ui/containers/columns_area_container.js1
-rw-r--r--app/javascript/mastodon/locales/ar.json6
-rw-r--r--app/javascript/mastodon/locales/ca.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json15
-rw-r--r--app/javascript/mastodon/locales/en.json7
-rw-r--r--app/javascript/mastodon/locales/fa.json74
-rw-r--r--app/javascript/mastodon/locales/gl.json8
-rw-r--r--app/javascript/mastodon/locales/index.js10
-rw-r--r--app/javascript/mastodon/locales/ja.json7
-rw-r--r--app/javascript/mastodon/locales/ko.json14
-rw-r--r--app/javascript/mastodon/locales/nl.json8
-rw-r--r--app/javascript/mastodon/locales/pl.json9
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json2
-rw-r--r--app/javascript/mastodon/locales/pt.json12
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json18
-rw-r--r--app/javascript/packs/about.js2
-rw-r--r--app/javascript/packs/common.js7
-rw-r--r--app/javascript/packs/public.js72
-rw-r--r--app/javascript/packs/share.js2
-rw-r--r--app/javascript/skins/vanilla/win95/common.scss1
-rw-r--r--app/javascript/skins/vanilla/win95/names.yml4
-rw-r--r--app/javascript/styles/fonts/montserrat.scss8
-rw-r--r--app/javascript/styles/fonts/roboto-mono.scss8
-rw-r--r--app/javascript/styles/fonts/roboto.scss32
-rw-r--r--app/javascript/styles/mailer.scss470
-rw-r--r--app/javascript/styles/mastodon/components.scss27
-rw-r--r--app/javascript/styles/mastodon/modal.scss4
-rw-r--r--app/javascript/styles/win95.scss61
-rw-r--r--app/lib/activitypub/activity/accept.rb17
-rw-r--r--app/lib/activitypub/activity/announce.rb6
-rw-r--r--app/lib/activitypub/activity/create.rb24
-rw-r--r--app/lib/activitypub/tag_manager.rb10
-rw-r--r--app/lib/feed_manager.rb23
-rw-r--r--app/lib/frontmatter_handler.rb244
-rw-r--r--app/lib/ostatus/activity/creation.rb3
-rw-r--r--app/lib/themes.rb71
-rw-r--r--app/lib/user_settings_decorator.rb16
-rw-r--r--app/mailers/admin_mailer.rb4
-rw-r--r--app/mailers/application_mailer.rb2
-rw-r--r--app/mailers/notification_mailer.rb4
-rw-r--r--app/mailers/user_mailer.rb2
-rw-r--r--app/models/account.rb20
-rw-r--r--app/models/follow_request.rb4
-rw-r--r--app/models/glitch.rb7
-rw-r--r--app/models/glitch/keyword_mute.rb100
-rw-r--r--app/models/media_attachment.rb29
-rw-r--r--app/models/mute.rb2
-rw-r--r--app/models/status.rb32
-rw-r--r--app/models/stream_entry.rb2
-rw-r--r--app/models/user.rb8
-rw-r--r--app/policies/status_policy.rb6
-rw-r--r--app/presenters/instance_presenter.rb9
-rw-r--r--app/serializers/activitypub/follow_serializer.rb9
-rw-r--r--app/serializers/initial_state_serializer.rb10
-rw-r--r--app/serializers/manifest_serializer.rb7
-rw-r--r--app/serializers/rest/instance_serializer.rb6
-rw-r--r--app/serializers/rest/mute_serializer.rb15
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb2
-rw-r--r--app/services/activitypub/process_account_service.rb18
-rw-r--r--app/services/batched_remove_status_service.rb11
-rw-r--r--app/services/fan_out_on_write_service.rb13
-rw-r--r--app/services/mute_service.rb1
-rw-r--r--app/services/post_status_service.rb9
-rw-r--r--app/services/reblog_service.rb7
-rw-r--r--app/services/remove_status_service.rb8
-rw-r--r--app/validators/status_length_validator.rb2
-rw-r--r--app/views/about/more.html.haml7
-rw-r--r--app/views/about/show.html.haml14
-rw-r--r--app/views/accounts/_header.html.haml13
-rw-r--r--app/views/admin/reports/show.html.haml3
-rw-r--r--app/views/admin/statuses/index.html.haml3
-rw-r--r--app/views/auth/registrations/_sessions.html.haml2
-rw-r--r--app/views/home/index.html.haml6
-rw-r--r--app/views/layouts/_theme.html.haml13
-rw-r--r--app/views/layouts/admin.html.haml3
-rwxr-xr-xapp/views/layouts/application.html.haml14
-rw-r--r--app/views/layouts/auth.html.haml3
-rw-r--r--app/views/layouts/embedded.html.haml8
-rw-r--r--app/views/layouts/error.html.haml4
-rw-r--r--app/views/layouts/mailer.html.haml55
-rw-r--r--app/views/layouts/modal.html.haml3
-rw-r--r--app/views/layouts/plain_mailer.html.haml1
-rw-r--r--app/views/layouts/public.html.haml3
-rw-r--r--app/views/notification_mailer/_status.html.haml30
-rw-r--r--app/views/notification_mailer/digest.html.haml44
-rw-r--r--app/views/notification_mailer/favourite.html.haml45
-rw-r--r--app/views/notification_mailer/follow.html.haml43
-rw-r--r--app/views/notification_mailer/follow_request.html.haml43
-rw-r--r--app/views/notification_mailer/mention.html.haml45
-rw-r--r--app/views/notification_mailer/reblog.html.haml45
-rw-r--r--app/views/settings/flavours/show.html.haml20
-rw-r--r--app/views/settings/keyword_mutes/_fields.html.haml11
-rw-r--r--app/views/settings/keyword_mutes/_keyword_mute.html.haml10
-rw-r--r--app/views/settings/keyword_mutes/edit.html.haml6
-rw-r--r--app/views/settings/keyword_mutes/index.html.haml18
-rw-r--r--app/views/settings/keyword_mutes/new.html.haml6
-rw-r--r--app/views/settings/preferences/show.html.haml4
-rw-r--r--app/views/settings/profiles/show.html.haml2
-rw-r--r--app/views/shares/show.html.haml1
-rw-r--r--app/views/stream_entries/_content_spoiler.html.haml2
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml4
-rw-r--r--app/views/stream_entries/_media.html.haml2
-rw-r--r--app/views/stream_entries/_simple_status.html.haml15
-rw-r--r--app/views/stream_entries/show.html.haml2
-rw-r--r--app/views/tags/show.html.haml1
-rw-r--r--app/views/user_mailer/confirmation_instructions.ar.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.ca.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.en.html.erb15
-rw-r--r--app/views/user_mailer/confirmation_instructions.es.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.fa.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.fi.html.erb5
-rw-r--r--app/views/user_mailer/confirmation_instructions.fr.html.erb14
-rw-r--r--app/views/user_mailer/confirmation_instructions.he.html.erb14
-rw-r--r--app/views/user_mailer/confirmation_instructions.html.haml76
-rw-r--r--app/views/user_mailer/confirmation_instructions.id.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.it.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.ja.html.erb11
-rw-r--r--app/views/user_mailer/confirmation_instructions.ko.html.erb13
-rw-r--r--app/views/user_mailer/confirmation_instructions.nl.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.no.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.oc.html.erb14
-rw-r--r--app/views/user_mailer/confirmation_instructions.pl.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.pt-BR.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.ru.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb15
-rw-r--r--app/views/user_mailer/confirmation_instructions.sr.html.erb15
-rw-r--r--app/views/user_mailer/confirmation_instructions.sv.html.erb15
-rw-r--r--app/views/user_mailer/confirmation_instructions.tr.html.erb15
-rw-r--r--app/views/user_mailer/confirmation_instructions.zh-cn.html.erb13
-rw-r--r--app/views/user_mailer/email_changed.en.html.erb15
-rw-r--r--app/views/user_mailer/email_changed.html.haml58
-rw-r--r--app/views/user_mailer/email_changed.ja.html.erb13
-rw-r--r--app/views/user_mailer/email_changed.oc.html.erb15
-rw-r--r--app/views/user_mailer/email_changed.pl.html.erb15
-rw-r--r--app/views/user_mailer/email_changed.zh-cn.text.erb11
-rw-r--r--app/views/user_mailer/password_change.ar.html.erb3
-rw-r--r--app/views/user_mailer/password_change.ca.html.erb3
-rw-r--r--app/views/user_mailer/password_change.en.html.erb3
-rw-r--r--app/views/user_mailer/password_change.es.html.erb3
-rw-r--r--app/views/user_mailer/password_change.fa.html.erb3
-rw-r--r--app/views/user_mailer/password_change.fi.html.erb3
-rw-r--r--app/views/user_mailer/password_change.fr.html.erb3
-rw-r--r--app/views/user_mailer/password_change.he.html.erb4
-rw-r--r--app/views/user_mailer/password_change.html.haml40
-rw-r--r--app/views/user_mailer/password_change.id.html.erb3
-rw-r--r--app/views/user_mailer/password_change.it.html.erb3
-rw-r--r--app/views/user_mailer/password_change.ja.html.erb3
-rw-r--r--app/views/user_mailer/password_change.nl.html.erb3
-rw-r--r--app/views/user_mailer/password_change.no.html.erb3
-rw-r--r--app/views/user_mailer/password_change.oc.html.erb3
-rw-r--r--app/views/user_mailer/password_change.pl.html.erb3
-rw-r--r--app/views/user_mailer/password_change.pt-BR.html.erb3
-rw-r--r--app/views/user_mailer/password_change.ru.html.erb3
-rw-r--r--app/views/user_mailer/password_change.sr-Latn.html.erb3
-rw-r--r--app/views/user_mailer/password_change.sr.html.erb3
-rw-r--r--app/views/user_mailer/password_change.sv.html.erb3
-rw-r--r--app/views/user_mailer/password_change.th.html.erb3
-rw-r--r--app/views/user_mailer/password_change.tr.html.erb8
-rw-r--r--app/views/user_mailer/password_change.zh-cn.html.erb3
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.en.html.erb15
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.html.haml60
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.ja.html.erb13
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.oc.html.erb15
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.pl.html.erb15
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.zh-cn.text.erb10
-rw-r--r--app/views/user_mailer/reset_password_instructions.ar.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.ca.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.en.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.es.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.fa.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.fi.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.fr.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.he.html.erb10
-rw-r--r--app/views/user_mailer/reset_password_instructions.html.haml60
-rw-r--r--app/views/user_mailer/reset_password_instructions.id.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.it.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.ja.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.nl.html.erb9
-rw-r--r--app/views/user_mailer/reset_password_instructions.no.html.erb9
-rw-r--r--app/views/user_mailer/reset_password_instructions.oc.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.pl.html.erb9
-rw-r--r--app/views/user_mailer/reset_password_instructions.pt-BR.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.ru.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.sr.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.sv.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.th.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.tr.html.erb14
-rw-r--r--app/views/user_mailer/reset_password_instructions.zh-cn.html.erb8
-rw-r--r--app/workers/digest_mailer_worker.rb6
-rw-r--r--app/workers/scheduler/email_scheduler.rb24
-rw-r--r--config/application.rb6
-rw-r--r--config/environments/production.rb13
-rw-r--r--config/i18n-tasks.yml1
-rw-r--r--config/initializers/assets.rb2
-rw-r--r--config/initializers/locale.rb6
-rw-r--r--config/initializers/premailer_rails.rb6
-rw-r--r--config/initializers/rack_attack.rb2
-rw-r--r--config/initializers/suppress_csrf_warnings.rb3
-rw-r--r--config/locales/activerecord.gl.yml13
-rw-r--r--config/locales/activerecord.pt.yml13
-rw-r--r--config/locales/ar.yml137
-rw-r--r--config/locales/ca.yml8
-rw-r--r--config/locales/devise.ar.yml10
-rw-r--r--config/locales/devise.en.yml17
-rw-r--r--config/locales/devise.gl.yml61
-rw-r--r--config/locales/devise.zh-CN.yml29
-rw-r--r--config/locales/doorkeeper.gl.yml86
-rw-r--r--config/locales/doorkeeper.nl.yml8
-rw-r--r--config/locales/doorkeeper.pt.yml42
-rw-r--r--config/locales/en.yml33
-rw-r--r--config/locales/es.yml2
-rw-r--r--config/locales/fa.yml117
-rw-r--r--config/locales/fr.yml2
-rw-r--r--config/locales/gl.yml710
-rw-r--r--config/locales/ja.yml13
-rw-r--r--config/locales/ko.yml66
-rw-r--r--config/locales/nl.yml4
-rw-r--r--config/locales/pl.yml18
-rw-r--r--config/locales/pt-BR.yml35
-rw-r--r--config/locales/pt.yml435
-rw-r--r--config/locales/ru.yml147
-rw-r--r--config/locales/simple_form.ar.yml13
-rw-r--r--config/locales/simple_form.en.yml7
-rw-r--r--config/locales/simple_form.fa.yml8
-rw-r--r--config/locales/simple_form.gl.yml18
-rw-r--r--config/locales/simple_form.ja.yml1
-rw-r--r--config/locales/simple_form.pl.yml5
-rw-r--r--config/locales/simple_form.pt.yml6
-rw-r--r--config/locales/simple_form.zh-CN.yml2
-rw-r--r--config/locales/zh-CN.yml24
-rw-r--r--config/navigation.rb7
-rw-r--r--config/routes.rb22
-rw-r--r--config/settings.yml8
-rw-r--r--config/sidekiq.yml3
-rw-r--r--config/themes.yml2
-rw-r--r--config/webpack/configuration.js57
-rw-r--r--config/webpack/generateLocalePacks.js94
-rw-r--r--config/webpack/loaders/babel.js3
-rw-r--r--config/webpack/loaders/sass.js2
-rw-r--r--config/webpack/shared.js84
-rw-r--r--config/webpacker.yml12
-rw-r--r--db/migrate/20170716191202_add_hide_notifications_to_mute.rb14
-rw-r--r--db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb8
-rw-r--r--db/migrate/20171009222537_create_keyword_mutes.rb12
-rw-r--r--db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb7
-rw-r--r--db/migrate/20171210213213_add_local_only_flag_to_statuses.rb5
-rw-r--r--db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb10
-rw-r--r--db/schema.rb17
-rw-r--r--docker_entrypoint.sh2
-rw-r--r--jest.config.js1
-rw-r--r--lib/mastodon/premailer_webpack_strategy.rb15
-rw-r--r--lib/mastodon/version.rb2
-rw-r--r--lib/paperclip/audio_transcoder.rb21
-rw-r--r--lib/tasks/glitchsoc.rake8
-rw-r--r--lib/tasks/mastodon.rake7
-rw-r--r--package.json11
-rw-r--r--[l---------]public/500.html1
-rw-r--r--public/background-cybre.pngbin0 -> 237414 bytes
-rw-r--r--public/clock.js54
-rw-r--r--public/logo-cybre-glitch.gifbin0 -> 837759 bytes
-rw-r--r--public/riot-glitch.pngbin0 -> 24926 bytes
-rw-r--r--spec/controllers/activitypub/follows_controller_spec.rb43
-rw-r--r--spec/controllers/api/v1/accounts/credentials_controller_spec.rb4
-rw-r--r--spec/controllers/api/v1/mutes_controller_spec.rb20
-rw-r--r--spec/controllers/application_controller_spec.rb20
-rw-r--r--spec/controllers/settings/flavours_controller_spec.rb38
-rw-r--r--spec/controllers/settings/keyword_mutes_controller_spec.rb5
-rw-r--r--spec/fabricators/glitch_keyword_mute_fabricator.rb2
-rw-r--r--spec/helpers/settings/keyword_mutes_helper_spec.rb15
-rw-r--r--spec/lib/activitypub/activity/accept_spec.rb53
-rw-r--r--spec/lib/activitypub/activity/create_spec.rb34
-rw-r--r--spec/lib/feed_manager_spec.rb68
-rw-r--r--spec/mailers/previews/notification_mailer_preview.rb7
-rw-r--r--spec/mailers/previews/user_mailer_preview.rb32
-rw-r--r--spec/models/account_spec.rb8
-rw-r--r--spec/models/concerns/account_interactions_spec.rb48
-rw-r--r--spec/models/follow_request_spec.rb15
-rw-r--r--spec/models/glitch/keyword_mute_spec.rb157
-rw-r--r--spec/models/status_spec.rb133
-rw-r--r--spec/policies/status_policy_spec.rb6
-rw-r--r--spec/rails_helper.rb2
-rw-r--r--spec/services/activitypub/fetch_remote_status_service_spec.rb10
-rw-r--r--spec/services/notify_service_spec.rb20
-rw-r--r--spec/services/precompute_feed_service_spec.rb2
-rw-r--r--spec/validators/status_length_validator_spec.rb23
-rw-r--r--spec/views/about/show.html.haml_spec.rb6
-rw-r--r--stack-fix.c32
-rw-r--r--streaming/index.js7
-rw-r--r--yarn.lock501
743 files changed, 40861 insertions, 2071 deletions
diff --git a/.codeclimate.yml b/.codeclimate.yml
index 47e3e6ab9..21e6b33bf 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -27,6 +27,7 @@ plugins:
     enabled: true
   eslint:
     enabled: true
+    channel: eslint-4
   rubocop:
     enabled: true
   scss-lint:
diff --git a/.env.production.sample b/.env.production.sample
index 3f0edd72f..cf1654e99 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -135,3 +135,6 @@ STREAMING_CLUSTER_NUM=1
 # If you use Docker, you may want to assign UID/GID manually.
 # UID=1000
 # GID=1000
+
+# Maximum allowed character count
+# MAX_TOOT_CHARS=500
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 7c6da9d57..b71e564e2 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -17,11 +17,9 @@ plugins:
 parserOptions:
   sourceType: module
   ecmaFeatures:
-    arrowFunctions: true
+    experimentalObjectRestSpread: true
     jsx: true
-    destructuring: true
-    modules: true
-    spread: true
+  ecmaVersion: 2018
 
 settings:
   import/extensions:
@@ -29,6 +27,11 @@ settings:
   import/ignore:
   - node_modules
   - \\.(css|scss|json)$
+  import/resolver:
+    node:
+      moduleDirectory:
+        - node_modules
+        - app/javascript
 
 rules:
   brace-style: warn
@@ -109,6 +112,7 @@ rules:
   react/self-closing-comp: error
 
   jsx-a11y/accessible-emoji: warn
+  jsx-a11y/alt-text: warn
   jsx-a11y/anchor-has-content: warn
   jsx-a11y/aria-activedescendant-has-tabindex: warn
   jsx-a11y/aria-props: warn
@@ -119,16 +123,22 @@ rules:
   jsx-a11y/href-no-hash: warn
   jsx-a11y/html-has-lang: warn
   jsx-a11y/iframe-has-title: warn
-  jsx-a11y/img-has-alt: warn
   jsx-a11y/img-redundant-alt: warn
+  jsx-a11y/interactive-supports-focus: warn
   jsx-a11y/label-has-for: off
   jsx-a11y/mouse-events-have-key-events: warn
   jsx-a11y/no-access-key: warn
   jsx-a11y/no-distracting-elements: warn
+  jsx-a11y/no-noninteractive-element-interactions:
+  - warn
+  - handlers:
+    - onClick
   jsx-a11y/no-onchange: warn
   jsx-a11y/no-redundant-roles: warn
-  jsx-a11y/onclick-has-focus: warn
-  jsx-a11y/onclick-has-role: warn
+  jsx-a11y/no-static-element-interactions:
+  - warn
+  - handlers:
+    - onClick
   jsx-a11y/role-has-required-aria-props: warn
   jsx-a11y/role-supports-aria-props: off
   jsx-a11y/scope: warn
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/.gitmodules
diff --git a/.travis.yml b/.travis.yml
index 777ca581c..59d495c43 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -9,6 +9,9 @@ cache:
   - tmp/cache/babel-loader
 dist: trusty
 sudo: required
+branches:
+  only:
+  - master
 
 notifications:
   email: false
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 7cec57180..4b20fe971 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe
 
 ## Enforcement
 
-Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at eugen@zeonfederated.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at beatrix.bitrot@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
 
 Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 299306299..42dfc57dc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,3 +1,36 @@
+#  Contributing to Mastodon Glitch Edition  #
+
+Thank you for your interest in contributing to the `glitch-soc` project!
+Here are some guidelines, and ways you can help.
+
+>   (This document is a bit of a work-in-progress, so please bear with us.
+>   If you don't see what you're looking for here, please don't hesitate to reach out!)
+
+##  Planning  ##
+
+Right now a lot of the planning for this project takes place in our development Discord, or through GitHub Issues and Projects.
+We're working on ways to improve the planning structure and better solicit feedback, and if you feel like you can help in this respect, feel free to give us a holler.
+
+##  Documentation  ##
+
+The documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)).
+Right now, we've mostly focused on the features that make this fork different from upstream in some manner.
+Adding screenshots, improving descriptions, and so forth are all ways to help contribute to the project even if you don't know any code.
+
+##  Frontend Development  ##
+
+Check out [the documentation here](https://glitch-soc.github.io/docs/contributing/frontend/) for more information.
+
+##  Backend Development  ##
+
+See the guidelines below.
+
+ - - -
+
+You should also try to follow the guidelines set out in the original `CONTRIBUTING.md` from `tootsuite/mastodon`, reproduced below.
+
+<blockquote>
+
 CONTRIBUTING
 ============
 
@@ -49,3 +82,5 @@ It is expected that you have a working development environment set up (see back-
 * If you are introducing new strings, they must be using localization methods
 
 If the JavaScript or CSS assets won't compile due to a syntax error, it's a good sign that the pull request isn't ready for submission yet.
+
+</blockquote>
diff --git a/Dockerfile b/Dockerfile
index 7cca02ecf..d455116da 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -61,6 +61,9 @@ RUN apk -U upgrade \
  && rm -rf /tmp/* /var/cache/apk/*
 
 COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/
+COPY stack-fix.c /lib
+RUN gcc -shared -fPIC /lib/stack-fix.c -o /lib/stack-fix.so
+RUN rm /lib/stack-fix.c
 
 RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
  && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
diff --git a/Gemfile b/Gemfile
index a5bf2daff..268bf3ad8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,6 +20,7 @@ gem 'fog-local', '~> 0.4', require: false
 gem 'fog-openstack', '~> 0.1', require: false
 gem 'paperclip', '~> 5.1'
 gem 'paperclip-av-transcoder', '~> 0.6'
+gem 'posix-spawn'
 
 gem 'active_model_serializers', '~> 0.10'
 gem 'addressable', '~> 2.5'
@@ -49,6 +50,7 @@ gem 'oj', '~> 3.3'
 gem 'ostatus2', '~> 2.0'
 gem 'ox', '~> 2.8'
 gem 'pundit', '~> 1.1'
+gem 'premailer-rails'
 gem 'rack-attack', '~> 5.0'
 gem 'rack-cors', '~> 0.4', require: 'rack/cors'
 gem 'rack-timeout', '~> 0.4'
diff --git a/Gemfile.lock b/Gemfile.lock
index f3887b2b8..cede4aaf7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -122,6 +122,8 @@ GEM
     crack (0.4.3)
       safe_yaml (~> 1.0.0)
     crass (1.0.3)
+    css_parser (1.6.0)
+      addressable
     debug_inspector (0.0.3)
     devise (4.3.0)
       bcrypt (~> 3.0)
@@ -323,7 +325,15 @@ GEM
     pghero (1.7.0)
       activerecord
     pkg-config (1.2.8)
+    posix-spawn (0.3.13)
     powerpack (0.1.1)
+    premailer (1.11.1)
+      addressable
+      css_parser (>= 1.6.0)
+      htmlentities (>= 4.0.0)
+    premailer-rails (1.10.1)
+      actionmailer (>= 3, < 6)
+      premailer (~> 1.7, >= 1.7.9)
     pry (0.11.3)
       coderay (~> 1.1.0)
       method_source (~> 0.9.0)
@@ -600,6 +610,8 @@ DEPENDENCIES
   pg (~> 0.20)
   pghero (~> 1.7)
   pkg-config (~> 1.2)
+  posix-spawn
+  premailer-rails
   pry-rails (~> 0.3)
   puma (~> 3.10)
   pundit (~> 1.1)
diff --git a/README.md b/README.md
index 5cf91d52c..998d57005 100644
--- a/README.md
+++ b/README.md
@@ -1,85 +1,10 @@
-![Mastodon](https://i.imgur.com/NhZc40l.png)
-========
+#  Mastodon Glitch Edition  #
 
-[![Build Status](https://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
-[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate]
+>   Now with automated deploys!
 
-[travis]: https://travis-ci.org/tootsuite/mastodon
-[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
+[![Build Status](https://travis-ci.org/glitch-soc/mastodon.svg?branch=master)](https://travis-ci.org/glitch-soc/mastodon)
 
-Mastodon is a **free, open-source social network server** based on **open web protocols** like ActivityPub and OStatus. The social focus of the project is a viable decentralized alternative to commercial social media silos that returns the control of the content distribution channels to the people. The technical focus of the project is a good user interface, a clean REST API for 3rd party apps and robust anti-abuse tools.
+So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it?
 
-Click on the screenshot below to watch a demo of the UI:
-
-[![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo]
-
-[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
-
-**Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
-
-If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
-
-[patreon]: https://www.patreon.com/user?u=619786
-
----
-
-## Resources
-
-- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md)
-- [Use this tool to find Twitter friends on Mastodon](https://bridge.joinmastodon.org)
-- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
-- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
-- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md)
-- [List of sponsors](https://joinmastodon.org/sponsors)
-
-## Features
-
-**No vendor lock-in: Fully interoperable with any conforming platform**
-
-It doesn't have to be Mastodon, whatever implements ActivityPub or OStatus is part of the social network!
-
-**Real-time timeline updates**
-
-See the updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
-
-**Federated thread resolving**
-
-If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI
-
-**Media attachments like images and short videos**
-
-Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos are looped - like vines!
-
-**OAuth2 and a straightforward REST API**
-
-Mastodon acts as an OAuth2 provider so 3rd party apps can use the API
-
-**Fast response times**
-
-Mastodon tries to be as fast and responsive as possible, so all long-running tasks are delegated to background processing
-
-**Deployable via Docker**
-
-You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
-
----
-
-## Development
-
-Please follow the [development guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Development-guide.md) from the documentation repository.
-
-## Deployment
-
-There are guides in the documentation repository for [deploying on various platforms](https://github.com/tootsuite/documentation#running-mastodon).
-
-## Contributing
-
-You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. [Here are the guidelines for code contributions](CONTRIBUTING.md)
-
-**IRC channel**: #mastodon on irc.freenode.net
-
----
-
-## Extra credits
-
-The elephant friend illustrations are created by [Dopatwo](https://mastodon.social/@dopatwo)
+- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
+- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
diff --git a/Vagrantfile b/Vagrantfile
index 0c21bed68..351ab5cfa 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -83,7 +83,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
 
   config.vm.provider :virtualbox do |vb|
     vb.name = "mastodon"
-    vb.customize ["modifyvm", :id, "--memory", "2048"]
+    vb.customize ["modifyvm", :id, "--memory", "4096"]
 
     # Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
     # https://github.com/mitchellh/vagrant/issues/1172
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 47690e81e..8785df14e 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class AboutController < ApplicationController
+  before_action :set_pack
   before_action :set_body_classes
   before_action :set_instance_presenter, only: [:show, :more, :terms]
 
@@ -21,6 +22,10 @@ class AboutController < ApplicationController
 
   helper_method :new_user
 
+  def set_pack
+    use_pack action_name == 'show' ? 'about' : 'common'
+  end
+
   def set_instance_presenter
     @instance_presenter = InstancePresenter.new
   end
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 69fd20e27..c9725ed00 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -8,6 +8,7 @@ class AccountsController < ApplicationController
   def show
     respond_to do |format|
       format.html do
+        use_pack 'public'
         @pinned_statuses = []
 
         if current_account && @account.blocking?(current_account)
@@ -50,7 +51,7 @@ class AccountsController < ApplicationController
   end
 
   def default_statuses
-    @account.statuses.where(visibility: [:public, :unlisted])
+    @account.statuses.not_local_only.where(visibility: [:public, :unlisted])
   end
 
   def only_media_scope
diff --git a/app/controllers/activitypub/follows_controller.rb b/app/controllers/activitypub/follows_controller.rb
deleted file mode 100644
index 038bcbabc..000000000
--- a/app/controllers/activitypub/follows_controller.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-class ActivityPub::FollowsController < Api::BaseController
-  include SignatureVerification
-
-  def show
-    render json: follow_request,
-           serializer: ActivityPub::FollowSerializer,
-           adapter: ActivityPub::Adapter,
-           content_type: 'application/activity+json'
-  end
-
-  private
-
-  def follow_request
-    FollowRequest.includes(:account).references(:account).find_by!(
-      id: params.require(:id),
-      accounts: { domain: nil, username: params.require(:account_username) },
-      target_account: signed_request_account
-    )
-  end
-end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 7fb69d578..fc299f74c 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -5,8 +5,13 @@ module Admin
     include Authorization
     include AccountableConcern
 
+    layout 'admin'
+
     before_action :require_staff!
+    before_action :set_pack
 
-    layout 'admin'
+    def set_pack
+      use_pack 'admin'
+    end
   end
 end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 5983c0fbe..52e68ab35 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -6,8 +6,8 @@ class Api::BaseController < ApplicationController
 
   include RateLimitHeaders
 
-  skip_before_action :verify_authenticity_token
   skip_before_action :store_current_location
+  protect_from_forgery with: :null_session
 
   rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
     render json: { error: e.to_s }, status: 422
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index 0c43cb943..92ad251ef 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -8,10 +8,15 @@ class Api::V1::MutesController < Api::BaseController
   respond_to :json
 
   def index
-    @accounts = load_accounts
+    @data = @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
   end
 
+  def details
+    @data = @mutes = load_mutes
+    render json: @mutes, each_serializer: REST::MuteSerializer
+  end 
+
   private
 
   def load_accounts
@@ -22,6 +27,10 @@ class Api::V1::MutesController < Api::BaseController
     Account.includes(:muted_by).references(:muted_by)
   end
 
+  def load_mutes
+    paginated_mutes.includes(:account, :target_account).to_a
+  end
+
   def paginated_mutes
     Mute.where(account: current_account).paginate_by_max_id(
       limit_param(DEFAULT_ACCOUNTS_LIMIT),
@@ -36,26 +45,34 @@ class Api::V1::MutesController < Api::BaseController
 
   def next_path
     if records_continue?
-      api_v1_mutes_url pagination_params(max_id: pagination_max_id)
+      url_for pagination_params(max_id: pagination_max_id)
     end
   end
 
   def prev_path
-    unless @accounts.empty?
-      api_v1_mutes_url pagination_params(since_id: pagination_since_id)
+    unless@data.empty?
+      url_for pagination_params(since_id: pagination_since_id)
     end
   end
 
   def pagination_max_id
-    @accounts.last.muted_by_ids.last
+    if params[:action] == "details"
+      @mutes.last.id
+    else
+      @accounts.last.muted_by_ids.last
+    end
   end
 
   def pagination_since_id
-    @accounts.first.muted_by_ids.first
+    if params[:action] == "details"
+      @mutes.first.id
+    else
+      @accounts.first.muted_by_ids.first
+    end
   end
 
   def records_continue?
-    @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+    @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
   end
 
   def pagination_params(core_params)
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 8910b77e9..a949752fb 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -24,11 +24,20 @@ class Api::V1::NotificationsController < Api::BaseController
     render_empty
   end
 
+  def destroy
+    dismiss
+  end
+
   def dismiss
     current_account.notifications.find_by!(id: params[:id]).destroy!
     render_empty
   end
 
+  def destroy_multiple
+    current_account.notifications.where(id: params[:ids]).destroy_all
+    render_empty
+  end
+
   private
 
   def load_notifications
diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb
index 997eed6e2..d1b4e0402 100644
--- a/app/controllers/api/v1/search_controller.rb
+++ b/app/controllers/api/v1/search_controller.rb
@@ -3,7 +3,7 @@
 class Api::V1::SearchController < Api::BaseController
   include Authorization
 
-  RESULTS_LIMIT = 5
+  RESULTS_LIMIT = 10
 
   before_action -> { doorkeeper_authorize! :read }
   before_action :require_user!
diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb
new file mode 100644
index 000000000..d455227eb
--- /dev/null
+++ b/app/controllers/api/v1/timelines/direct_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class Api::V1::Timelines::DirectController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read }, only: [:show]
+  before_action :require_user!, only: [:show]
+  after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
+
+  respond_to :json
+
+  def show
+    @statuses = load_statuses
+    render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+  end
+
+  private
+
+  def load_statuses
+    cached_direct_statuses
+  end
+
+  def cached_direct_statuses
+    cache_collection direct_statuses, Status
+  end
+
+  def direct_statuses
+    direct_timeline_statuses.paginate_by_max_id(
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id]
+    )
+  end
+
+  def direct_timeline_statuses
+    Status.as_direct_timeline(current_account)
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def pagination_params(core_params)
+    params.permit(:local, :limit).merge(core_params)
+  end
+
+  def next_path
+    api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id)
+  end
+
+  def prev_path
+    api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id)
+  end
+
+  def pagination_max_id
+    @statuses.last.id
+  end
+
+  def pagination_since_id
+    @statuses.first.id
+  end
+end
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index 52e250d02..68ccbd5e2 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -4,6 +4,7 @@ class Api::Web::PushSubscriptionsController < Api::BaseController
   respond_to :json
 
   before_action :require_user!
+  protect_from_forgery with: :exception
 
   def create
     params.require(:subscription).require(:endpoint)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index f59f2725b..276c6b012 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -12,7 +12,8 @@ class ApplicationController < ActionController::Base
 
   helper_method :current_account
   helper_method :current_session
-  helper_method :current_theme
+  helper_method :current_flavour
+  helper_method :current_skin
   helper_method :single_user_mode?
 
   rescue_from ActionController::RoutingError, with: :not_found
@@ -30,7 +31,7 @@ class ApplicationController < ActionController::Base
   private
 
   def https_enabled?
-    Rails.env.production? && ENV['LOCAL_HTTPS'] == 'true'
+    Rails.env.production?
   end
 
   def store_current_location
@@ -53,6 +54,75 @@ class ApplicationController < ActionController::Base
     new_user_session_path
   end
 
+  def pack(data, pack_name, skin = 'default')
+    return nil unless pack?(data, pack_name)
+    pack_data = {
+      common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
+      flavour: data['name'],
+      pack: pack_name,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+    if data['pack'][pack_name].is_a?(Hash)
+      pack_data[:common] = nil if data['pack'][pack_name]['use_common'] == false
+      pack_data[:pack] = nil unless data['pack'][pack_name]['filename']
+      if data['pack'][pack_name]['preload']
+        pack_data[:preload] = [data['pack'][pack_name]['preload']] if data['pack'][pack_name]['preload'].is_a?(String)
+        pack_data[:preload] = data['pack'][pack_name]['preload'] if data['pack'][pack_name]['preload'].is_a?(Array)
+      end
+      if skin != 'default' && data['skin'][skin]
+        pack_data[:skin] = skin if data['skin'][skin].include?(pack_name)
+      else  #  default skin
+        pack_data[:skin] = 'default' if data['pack'][pack_name]['stylesheet']
+      end
+    end
+    pack_data
+  end
+
+  def pack?(data, pack_name)
+    if data['pack'].is_a?(Hash) && data['pack'].key?(pack_name)
+      return true if data['pack'][pack_name].is_a?(String) || data['pack'][pack_name].is_a?(Hash)
+    end
+    false
+  end
+
+  def nil_pack(data, pack_name, skin = 'default')
+    {
+      common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
+      flavour: data['name'],
+      pack: nil,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+  end
+
+  def resolve_pack(data, pack_name, skin = 'default')
+    result = pack(data, pack_name, skin)
+    unless result
+      if data['name'] && data.key?('fallback')
+        if data['fallback'].nil?
+          return nil_pack(data, pack_name, skin)
+        elsif data['fallback'].is_a?(String) && Themes.instance.flavour(data['fallback'])
+          return resolve_pack(Themes.instance.flavour(data['fallback']), pack_name)
+        elsif data['fallback'].is_a?(Array)
+          data['fallback'].each do |fallback|
+            return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback)
+          end
+        end
+        return nil_pack(data, pack_name, skin)
+      end
+      return data.key?('name') && data['name'] != Setting.default_settings['flavour'] ? resolve_pack(Themes.instance.flavour(Setting.default_settings['flavour']), pack_name) : nil_pack(data, pack_name, skin)
+    end
+    result
+  end
+
+  def use_pack(pack_name)
+    @core = resolve_pack(Themes.instance.core, pack_name)
+    @theme = resolve_pack(Themes.instance.flavour(current_flavour), pack_name, current_skin)
+  end
+
   protected
 
   def forbidden
@@ -83,9 +153,14 @@ class ApplicationController < ActionController::Base
     @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
   end
 
-  def current_theme
-    return Setting.default_settings['theme'] unless Themes.instance.names.include? current_user&.setting_theme
-    current_user.setting_theme
+  def current_flavour
+    return Setting.default_settings['flavour'] unless Themes.instance.flavours.include? current_user&.setting_flavour
+    current_user.setting_flavour
+  end
+
+  def current_skin
+    return 'default' unless Themes.instance.skins_for(current_flavour).include? current_user&.setting_skin
+    current_user.setting_skin
   end
 
   def cache_collection(raw, klass)
@@ -117,6 +192,7 @@ class ApplicationController < ActionController::Base
       format.any  { head code }
       format.html do
         set_locale
+        use_pack 'error'
         render "errors/#{code}", layout: 'error', status: code
       end
     end
@@ -124,15 +200,15 @@ class ApplicationController < ActionController::Base
 
   def render_cached_json(cache_key, **options)
     options[:expires_in] ||= 3.minutes
-    options[:public]     ||= true
     cache_key              = cache_key.join(':') if cache_key.is_a?(Enumerable)
+    cache_public           = options.key?(:public) ? options.delete(:public) : true
     content_type           = options.delete(:content_type) || 'application/json'
 
     data = Rails.cache.fetch(cache_key, { raw: true }.merge(options)) do
       yield.to_json
     end
 
-    expires_in options[:expires_in], public: options[:public]
+    expires_in options[:expires_in], public: cache_public
     render json: data, content_type: content_type
   end
 
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 2fdb281f4..72b8e9dd8 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -2,4 +2,12 @@
 
 class Auth::ConfirmationsController < Devise::ConfirmationsController
   layout 'auth'
+
+  before_action :set_pack
+
+  private
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index 171b997dc..e0400aa3d 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -2,6 +2,7 @@
 
 class Auth::PasswordsController < Devise::PasswordsController
   before_action :check_validity_of_reset_password_token, only: :edit
+  before_action :set_pack
 
   layout 'auth'
 
@@ -17,4 +18,8 @@ class Auth::PasswordsController < Devise::PasswordsController
   def reset_password_token_is_valid?
     resource_class.with_reset_password_token(params[:reset_password_token]).present?
   end
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index b8ff4e54f..2b6a1bdbc 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -5,6 +5,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   before_action :check_enabled_registrations, only: [:new, :create]
   before_action :configure_sign_up_params, only: [:create]
+  before_action :set_pack
   before_action :set_sessions, only: [:edit, :update]
   before_action :set_instance_presenter, only: [:new, :create, :update]
 
@@ -59,6 +60,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   private
 
+  def set_pack
+    use_pack %w(edit update).include?(action_name) ? 'admin' : 'auth'
+  end
+
   def set_instance_presenter
     @instance_presenter = InstancePresenter.new
   end
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index a5acb6c36..f45d77b88 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController
   skip_before_action :require_no_authentication, only: [:create]
   skip_before_action :check_suspension, only: [:destroy]
   prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
+  prepend_before_action :set_pack
   before_action :set_instance_presenter, only: [:new]
 
   def create
@@ -85,6 +86,10 @@ class Auth::SessionsController < Devise::SessionsController
 
   private
 
+  def set_pack
+    use_pack 'auth'
+  end
+
   def set_instance_presenter
     @instance_presenter = InstancePresenter.new
   end
diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb
index 7afe664d1..eda50e07d 100644
--- a/app/controllers/authorize_follows_controller.rb
+++ b/app/controllers/authorize_follows_controller.rb
@@ -4,6 +4,7 @@ class AuthorizeFollowsController < ApplicationController
   layout 'modal'
 
   before_action :authenticate_user!
+  before_action :set_pack
   before_action :set_body_classes
 
   def show
@@ -24,6 +25,10 @@ class AuthorizeFollowsController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'modal'
+  end
+
   def follow_attempt
     FollowService.new.call(current_account, acct_without_prefix)
   end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 399e79665..080cbde11 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -7,7 +7,9 @@ class FollowerAccountsController < ApplicationController
     @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
 
     respond_to do |format|
-      format.html
+      format.html do
+        use_pack 'public'
+      end
 
       format.json do
         render json: collection_presenter,
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 1e73d4bd4..74e83ad81 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -7,7 +7,9 @@ class FollowingAccountsController < ApplicationController
     @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
 
     respond_to do |format|
-      format.html
+      format.html do
+        use_pack 'public'
+      end
 
       format.json do
         render json: collection_presenter,
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 21dde20ce..7437a647e 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -2,6 +2,7 @@
 
 class HomeController < ApplicationController
   before_action :authenticate_user!
+  before_action :set_pack
   before_action :set_initial_state_json
 
   def index
@@ -37,6 +38,10 @@ class HomeController < ApplicationController
     redirect_to(default_redirect_path)
   end
 
+  def set_pack
+    use_pack 'home'
+  end
+
   def set_initial_state_json
     serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
     @initial_state_json   = serializable_resource.to_json
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 38d6c8d73..189e4072e 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -6,6 +6,7 @@ class InvitesController < ApplicationController
   layout 'admin'
 
   before_action :authenticate_user!
+  before_action :set_pack
 
   def index
     authorize :invite, :create?
@@ -37,6 +38,10 @@ class InvitesController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def resource_params
     params.require(:invite).permit(:max_uses, :expires_in)
   end
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index e9cdf9fa8..eb977510b 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -5,6 +5,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
 
   before_action :store_current_location
   before_action :authenticate_resource_owner!
+  before_action :set_pack
 
   include Localized
 
@@ -13,4 +14,8 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
   def store_current_location
     store_location_for(:user, request.url)
   end
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index 395fbc51b..f95d672ec 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -5,6 +5,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
 
   before_action :store_current_location
   before_action :authenticate_resource_owner!
+  before_action :set_pack
 
   include Localized
 
@@ -13,4 +14,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
   def store_current_location
     store_location_for(:user, request.url)
   end
+
+  def set_pack
+    use_pack 'settings'
+  end
 end
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index 3b988e08d..41c021781 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -4,6 +4,7 @@ class RemoteFollowController < ApplicationController
   layout 'modal'
 
   before_action :set_account
+  before_action :set_pack
   before_action :gone, if: :suspended_account?
 
   def new
@@ -31,6 +32,10 @@ class RemoteFollowController < ApplicationController
     { acct: session[:remote_follow] }
   end
 
+  def set_pack
+    use_pack 'modal'
+  end
+
   def set_account
     @account = Account.find_local!(params[:account_username])
   end
diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb
index 8fc9a0fa9..35a6f7f9e 100644
--- a/app/controllers/settings/applications_controller.rb
+++ b/app/controllers/settings/applications_controller.rb
@@ -1,9 +1,7 @@
 # frozen_string_literal: true
 
-class Settings::ApplicationsController < ApplicationController
-  layout 'admin'
+class Settings::ApplicationsController < Settings::BaseController
 
-  before_action :authenticate_user!
   before_action :set_application, only: [:show, :update, :destroy, :regenerate]
   before_action :prepare_scopes, only: [:create, :update]
 
diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb
new file mode 100644
index 000000000..7322d461b
--- /dev/null
+++ b/app/controllers/settings/base_controller.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class Settings::BaseController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_pack
+
+  def set_pack
+    use_pack 'settings'
+  end
+end
diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb
index 80002b995..4c1121471 100644
--- a/app/controllers/settings/deletes_controller.rb
+++ b/app/controllers/settings/deletes_controller.rb
@@ -1,10 +1,8 @@
 # frozen_string_literal: true
 
-class Settings::DeletesController < ApplicationController
-  layout 'admin'
+class Settings::DeletesController < Settings::BaseController
 
-  before_action :check_enabled_deletion
-  before_action :authenticate_user!
+  prepend_before_action :check_enabled_deletion
 
   def show
     @confirmation = Form::DeleteConfirmation.new
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index ae62f00c1..9c03ece86 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -1,10 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::ExportsController < ApplicationController
-  layout 'admin'
-
-  before_action :authenticate_user!
-
+class Settings::ExportsController < Settings::BaseController
   def show
     @export = Export.new(current_account)
   end
diff --git a/app/controllers/settings/flavours_controller.rb b/app/controllers/settings/flavours_controller.rb
new file mode 100644
index 000000000..634387715
--- /dev/null
+++ b/app/controllers/settings/flavours_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class Settings::FlavoursController < Settings::BaseController
+  def index
+    redirect_to action: 'show', flavour: current_flavour
+  end
+
+  def show
+    unless Themes.instance.flavours.include?(params[:flavour]) || (params[:flavour] == current_flavour)
+      redirect_to action: 'show', flavour: current_flavour
+    end
+
+    @listing = Themes.instance.flavours
+    @selected = params[:flavour]
+  end
+
+  def update
+    user_settings.update(user_settings_params)
+    redirect_to action: 'show', flavour: params[:flavour]
+  end
+
+  private
+
+  def user_settings
+    UserSettingsDecorator.new(current_user)
+  end
+
+  def user_settings_params
+    { setting_flavour: params.require(:flavour),
+      setting_skin: params.dig(:user, :setting_skin) }.with_indifferent_access
+  end
+end
diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb
index 9968504e5..141b2270d 100644
--- a/app/controllers/settings/follower_domains_controller.rb
+++ b/app/controllers/settings/follower_domains_controller.rb
@@ -2,11 +2,7 @@
 
 require 'sidekiq-bulk'
 
-class Settings::FollowerDomainsController < ApplicationController
-  layout 'admin'
-
-  before_action :authenticate_user!
-
+class Settings::FollowerDomainsController < Settings::BaseController
   def show
     @account = current_account
     @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10)
diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb
index 0db13d1ca..dbd136ebe 100644
--- a/app/controllers/settings/imports_controller.rb
+++ b/app/controllers/settings/imports_controller.rb
@@ -1,9 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::ImportsController < ApplicationController
-  layout 'admin'
-
-  before_action :authenticate_user!
+class Settings::ImportsController < Settings::BaseController
   before_action :set_account
 
   def show
diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb
new file mode 100644
index 000000000..699b8a3ef
--- /dev/null
+++ b/app/controllers/settings/keyword_mutes_controller.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class Settings::KeywordMutesController < Settings::BaseController
+  before_action :load_keyword_mute, only: [:edit, :update, :destroy]
+
+  def index
+    @keyword_mutes = paginated_keyword_mutes_for_account
+  end
+
+  def new
+    @keyword_mute = keyword_mutes_for_account.build
+  end
+
+  def create
+    @keyword_mute = keyword_mutes_for_account.create(keyword_mute_params)
+
+    if @keyword_mute.persisted?
+      redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render :new
+    end
+  end
+
+  def update
+    if @keyword_mute.update(keyword_mute_params)
+      redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render :edit
+    end
+  end
+
+  def destroy
+    @keyword_mute.destroy!
+
+    redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+  end
+
+  def destroy_all
+    keyword_mutes_for_account.delete_all
+
+    redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+  end
+
+  private
+
+  def keyword_mutes_for_account
+    Glitch::KeywordMute.where(account: current_account)
+  end
+
+  def load_keyword_mute
+    @keyword_mute = keyword_mutes_for_account.find(params[:id])
+  end
+
+  def keyword_mute_params
+    params.require(:keyword_mute).permit(:keyword, :whole_word)
+  end
+
+  def paginated_keyword_mutes_for_account
+    keyword_mutes_for_account.order(:keyword).page params[:page]
+  end
+end
diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb
index ce2530c54..6286e3ebf 100644
--- a/app/controllers/settings/notifications_controller.rb
+++ b/app/controllers/settings/notifications_controller.rb
@@ -1,10 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::NotificationsController < ApplicationController
-  layout 'admin'
-
-  before_action :authenticate_user!
-
+class Settings::NotificationsController < Settings::BaseController
   def show; end
 
   def update
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 069026715..7cd1abe0c 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -1,10 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::PreferencesController < ApplicationController
-  layout 'admin'
-
-  before_action :authenticate_user!
-
+class Settings::PreferencesController < Settings::BaseController
   def show; end
 
   def update
@@ -37,12 +33,12 @@ class Settings::PreferencesController < ApplicationController
       :setting_default_sensitive,
       :setting_unfollow_modal,
       :setting_boost_modal,
+      :setting_favourite_modal,
       :setting_delete_modal,
       :setting_auto_play_gif,
       :setting_reduce_motion,
       :setting_system_font_ui,
       :setting_noindex,
-      :setting_theme,
       notification_emails: %i(follow follow_request reblog favourite mention digest),
       interactions: %i(must_be_follower must_be_following)
     )
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 28f78a4fb..dadc3d911 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -1,11 +1,8 @@
 # frozen_string_literal: true
 
-class Settings::ProfilesController < ApplicationController
+class Settings::ProfilesController < Settings::BaseController
   include ObfuscateFilename
 
-  layout 'admin'
-
-  before_action :authenticate_user!
   before_action :set_account
 
   obfuscate_filename [:account, :avatar]
diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb
index 0da1b027b..780ea64b4 100644
--- a/app/controllers/settings/sessions_controller.rb
+++ b/app/controllers/settings/sessions_controller.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+#  Intentionally does not inherit from BaseController
 class Settings::SessionsController < ApplicationController
   before_action :set_session, only: :destroy
 
diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
index 4cf62db13..f1fa03f0a 100644
--- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
@@ -2,11 +2,7 @@
 
 module Settings
   module TwoFactorAuthentication
-    class ConfirmationsController < ApplicationController
-      layout 'admin'
-
-      before_action :authenticate_user!
-
+    class ConfirmationsController < BaseController
       def new
         prepare_two_factor_form
       end
diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
index e591e9502..94d1567f3 100644
--- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
@@ -2,11 +2,7 @@
 
 module Settings
   module TwoFactorAuthentication
-    class RecoveryCodesController < ApplicationController
-      layout 'admin'
-
-      before_action :authenticate_user!
-
+    class RecoveryCodesController < BaseController
       def create
         @recovery_codes = current_user.generate_otp_backup_codes!
         current_user.save!
diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb
index 863cc7351..8c7737e9d 100644
--- a/app/controllers/settings/two_factor_authentications_controller.rb
+++ b/app/controllers/settings/two_factor_authentications_controller.rb
@@ -1,10 +1,7 @@
 # frozen_string_literal: true
 
 module Settings
-  class TwoFactorAuthenticationsController < ApplicationController
-    layout 'admin'
-
-    before_action :authenticate_user!
+  class TwoFactorAuthenticationsController < BaseController
     before_action :verify_otp_required, only: [:create]
 
     def show
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
index fc2469dea..3cbaccb35 100644
--- a/app/controllers/shares_controller.rb
+++ b/app/controllers/shares_controller.rb
@@ -4,6 +4,7 @@ class SharesController < ApplicationController
   layout 'modal'
 
   before_action :authenticate_user!
+  before_action :set_pack
   before_action :set_body_classes
 
   def show
@@ -14,16 +15,21 @@ class SharesController < ApplicationController
   private
 
   def initial_state_params
+    text = [params[:title], params[:text], params[:url]].compact.join(' ')
     {
       settings: Web::Setting.find_by(user: current_user)&.data || {},
       push_subscription: current_account.user.web_push_subscription(current_session),
       current_account: current_account,
       token: current_session.token,
       admin: Account.find_local(Setting.site_contact_username),
-      text: params[:text],
+      text: text,
     }
   end
 
+  def set_pack
+    use_pack 'share'
+  end
+
   def set_body_classes
     @body_classes = 'modal-layout compose-standalone'
   end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 367ea34e7..d67fac0e5 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -15,6 +15,7 @@ class StatusesController < ApplicationController
   def show
     respond_to do |format|
       format.html do
+        use_pack 'public'
         @ancestors   = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
         @descendants = cache_collection(@status.descendants(current_account), Status)
 
@@ -40,6 +41,7 @@ class StatusesController < ApplicationController
   end
 
   def embed
+    use_pack 'embed'
     response.headers['X-Frame-Options'] = 'ALLOWALL'
     render 'stream_entries/embed', layout: 'embedded'
   end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index cc579dbc8..b597ba4bb 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -14,6 +14,7 @@ class StreamEntriesController < ApplicationController
   def show
     respond_to do |format|
       format.html do
+        use_pack 'public'
         @ancestors   = @stream_entry.activity.reply? ? cache_collection(@stream_entry.activity.ancestors(current_account), Status) : []
         @descendants = cache_collection(@stream_entry.activity.descendants(current_account), Status)
       end
@@ -48,7 +49,7 @@ class StreamEntriesController < ApplicationController
     @type         = @stream_entry.activity_type.downcase
 
     raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil?
-    authorize @stream_entry.activity, :show? if @stream_entry.hidden?
+    authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only?
   rescue Mastodon::NotPermittedError
     # Reraise in order to get a 404
     raise ActiveRecord::RecordNotFound
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 9f3090e37..5d11a8139 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -9,6 +9,7 @@ class TagsController < ApplicationController
 
     respond_to do |format|
       format.html do
+        use_pack 'about'
         serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
         @initial_state_json   = serializable_resource.to_json
       end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index 6c7c38070..9530ad9f3 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -39,6 +39,10 @@ module JsonLdHelper
     !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
   end
 
+  def unsupported_uri_scheme?(uri)
+    !uri.start_with?('http://', 'https://')
+  end
+
   def canonicalize(json)
     graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
     graph.dump(:normalize)
diff --git a/app/helpers/mailer_helper.rb b/app/helpers/mailer_helper.rb
new file mode 100644
index 000000000..b7e3a8da3
--- /dev/null
+++ b/app/helpers/mailer_helper.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+module MailerHelper
+end
diff --git a/app/helpers/settings/keyword_mutes_helper.rb b/app/helpers/settings/keyword_mutes_helper.rb
new file mode 100644
index 000000000..7b98cd59e
--- /dev/null
+++ b/app/helpers/settings/keyword_mutes_helper.rb
@@ -0,0 +1,2 @@
+module Settings::KeywordMutesHelper
+end
diff --git a/app/javascript/packs/admin.js b/app/javascript/core/admin.js
index 993827db5..c0bd09bdd 100644
--- a/app/javascript/packs/admin.js
+++ b/app/javascript/core/admin.js
@@ -1,3 +1,5 @@
+//  This file will be loaded on admin pages, regardless of theme.
+
 import { delegate } from 'rails-ujs';
 
 function handleDeleteStatus(event) {
diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js
new file mode 100644
index 000000000..a7073ef0e
--- /dev/null
+++ b/app/javascript/core/common.js
@@ -0,0 +1,8 @@
+//  This file will be loaded on all pages, regardless of theme.
+
+import { start } from 'rails-ujs';
+import 'font-awesome/css/font-awesome.css';
+
+require.context('../images/', true);
+
+start();
diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js
new file mode 100644
index 000000000..6146e6592
--- /dev/null
+++ b/app/javascript/core/embed.js
@@ -0,0 +1,23 @@
+//  This file will be loaded on embed pages, regardless of theme.
+
+window.addEventListener('message', e => {
+  const data = e.data || {};
+
+  if (!window.parent || data.type !== 'setHeight') {
+    return;
+  }
+
+  function setEmbedHeight () {
+    window.parent.postMessage({
+      type: 'setHeight',
+      id: data.id,
+      height: document.getElementsByTagName('html')[0].scrollHeight,
+    }, '*');
+  };
+
+  if (['interactive', 'complete'].includes(document.readyState)) {
+    setEmbedHeight();
+  } else {
+    document.addEventListener('DOMContentLoaded', setEmbedHeight);
+  }
+});
diff --git a/app/javascript/core/mailer.js b/app/javascript/core/mailer.js
new file mode 100644
index 000000000..baef7e7fb
--- /dev/null
+++ b/app/javascript/core/mailer.js
@@ -0,0 +1 @@
+import 'styles/mailer.scss';
diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js
new file mode 100644
index 000000000..47c34a259
--- /dev/null
+++ b/app/javascript/core/public.js
@@ -0,0 +1,25 @@
+//  This file will be loaded on public pages, regardless of theme.
+
+const { delegate } = require('rails-ujs');
+
+delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
+  if (button !== 0) {
+    return true;
+  }
+  window.location.href = target.href;
+  return false;
+});
+
+delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
+  const contentEl = target.parentNode.parentNode.querySelector('.e-content');
+
+  if (contentEl.style.display === 'block') {
+    contentEl.style.display = 'none';
+    target.parentNode.style.marginBottom = 0;
+  } else {
+    contentEl.style.display = 'block';
+    target.parentNode.style.marginBottom = null;
+  }
+
+  return false;
+});
diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js
new file mode 100644
index 000000000..c9edcf197
--- /dev/null
+++ b/app/javascript/core/settings.js
@@ -0,0 +1,39 @@
+//  This file will be loaded on settings pages, regardless of theme.
+
+const { length } = require('stringz');
+const { delegate } = require('rails-ujs');
+
+import { processBio } from 'flavours/glitch/util/bio_metadata';
+
+delegate(document, '.account_display_name', 'input', ({ target }) => {
+  const nameCounter = document.querySelector('.name-counter');
+
+  if (nameCounter) {
+    nameCounter.textContent = 30 - length(target.value);
+  }
+});
+
+delegate(document, '.account_note', 'input', ({ target }) => {
+  const noteCounter = document.querySelector('.note-counter');
+
+  if (noteCounter) {
+    const noteWithoutMetadata = processBio(target.value).text;
+    noteCounter.textContent = 500 - length(noteWithoutMetadata);
+  }
+});
+
+delegate(document, '#account_avatar', 'change', ({ target }) => {
+  const avatar = document.querySelector('.card.compact .avatar img');
+  const [file] = target.files || [];
+  const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
+
+  avatar.src = url;
+});
+
+delegate(document, '#account_header', 'change', ({ target }) => {
+  const header = document.querySelector('.card.compact');
+  const [file] = target.files || [];
+  const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
+
+  header.style.backgroundImage = `url(${url})`;
+});
diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml
new file mode 100644
index 000000000..f48ab40c0
--- /dev/null
+++ b/app/javascript/core/theme.yml
@@ -0,0 +1,19 @@
+#  These packs will be loaded on every appropriate page, regardless of
+#  theme.
+pack:
+  about:
+  admin: admin.js
+  auth:
+  common:
+    filename: common.js
+    stylesheet: true
+  embed: embed.js
+  error:
+  home:
+  mailer:
+    filename: mailer.js
+    stylesheet: true
+  modal:
+  public: public.js
+  settings: settings.js
+  share:
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
new file mode 100644
index 000000000..8ab92f9e7
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -0,0 +1,661 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+
+export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
+export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
+export const ACCOUNT_FETCH_FAIL    = 'ACCOUNT_FETCH_FAIL';
+
+export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
+export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
+export const ACCOUNT_FOLLOW_FAIL    = 'ACCOUNT_FOLLOW_FAIL';
+
+export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
+export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
+export const ACCOUNT_UNFOLLOW_FAIL    = 'ACCOUNT_UNFOLLOW_FAIL';
+
+export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
+export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
+export const ACCOUNT_BLOCK_FAIL    = 'ACCOUNT_BLOCK_FAIL';
+
+export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
+export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
+export const ACCOUNT_UNBLOCK_FAIL    = 'ACCOUNT_UNBLOCK_FAIL';
+
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL    = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
+
+export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
+export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
+export const FOLLOWERS_FETCH_FAIL    = 'FOLLOWERS_FETCH_FAIL';
+
+export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
+export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
+export const FOLLOWERS_EXPAND_FAIL    = 'FOLLOWERS_EXPAND_FAIL';
+
+export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
+export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
+export const FOLLOWING_FETCH_FAIL    = 'FOLLOWING_FETCH_FAIL';
+
+export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
+export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
+export const FOLLOWING_EXPAND_FAIL    = 'FOLLOWING_EXPAND_FAIL';
+
+export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
+export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
+export const RELATIONSHIPS_FETCH_FAIL    = 'RELATIONSHIPS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
+export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
+export const FOLLOW_REQUESTS_FETCH_FAIL    = 'FOLLOW_REQUESTS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
+export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
+export const FOLLOW_REQUESTS_EXPAND_FAIL    = 'FOLLOW_REQUESTS_EXPAND_FAIL';
+
+export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
+export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
+export const FOLLOW_REQUEST_AUTHORIZE_FAIL    = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
+
+export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
+export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
+export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
+
+export function fetchAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchRelationships([id]));
+
+    if (getState().getIn(['accounts', id], null) !== null) {
+      return;
+    }
+
+    dispatch(fetchAccountRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+      dispatch(fetchAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchAccountFail(id, error));
+    });
+  };
+};
+
+export function fetchAccountRequest(id) {
+  return {
+    type: ACCOUNT_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchAccountSuccess(account) {
+  return {
+    type: ACCOUNT_FETCH_SUCCESS,
+    account,
+  };
+};
+
+export function fetchAccountFail(id, error) {
+  return {
+    type: ACCOUNT_FETCH_FAIL,
+    id,
+    error,
+    skipAlert: true,
+  };
+};
+
+export function followAccount(id, reblogs = true) {
+  return (dispatch, getState) => {
+    const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
+    dispatch(followAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
+      dispatch(followAccountSuccess(response.data, alreadyFollowing));
+    }).catch(error => {
+      dispatch(followAccountFail(error));
+    });
+  };
+};
+
+export function unfollowAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unfollowAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
+      dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(unfollowAccountFail(error));
+    });
+  };
+};
+
+export function followAccountRequest(id) {
+  return {
+    type: ACCOUNT_FOLLOW_REQUEST,
+    id,
+  };
+};
+
+export function followAccountSuccess(relationship, alreadyFollowing) {
+  return {
+    type: ACCOUNT_FOLLOW_SUCCESS,
+    relationship,
+    alreadyFollowing,
+  };
+};
+
+export function followAccountFail(error) {
+  return {
+    type: ACCOUNT_FOLLOW_FAIL,
+    error,
+  };
+};
+
+export function unfollowAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNFOLLOW_REQUEST,
+    id,
+  };
+};
+
+export function unfollowAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_UNFOLLOW_SUCCESS,
+    relationship,
+    statuses,
+  };
+};
+
+export function unfollowAccountFail(error) {
+  return {
+    type: ACCOUNT_UNFOLLOW_FAIL,
+    error,
+  };
+};
+
+export function blockAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(blockAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(blockAccountFail(id, error));
+    });
+  };
+};
+
+export function unblockAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unblockAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
+      dispatch(unblockAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unblockAccountFail(id, error));
+    });
+  };
+};
+
+export function blockAccountRequest(id) {
+  return {
+    type: ACCOUNT_BLOCK_REQUEST,
+    id,
+  };
+};
+
+export function blockAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_BLOCK_SUCCESS,
+    relationship,
+    statuses,
+  };
+};
+
+export function blockAccountFail(error) {
+  return {
+    type: ACCOUNT_BLOCK_FAIL,
+    error,
+  };
+};
+
+export function unblockAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNBLOCK_REQUEST,
+    id,
+  };
+};
+
+export function unblockAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNBLOCK_SUCCESS,
+    relationship,
+  };
+};
+
+export function unblockAccountFail(error) {
+  return {
+    type: ACCOUNT_UNBLOCK_FAIL,
+    error,
+  };
+};
+
+
+export function muteAccount(id, notifications) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(muteAccountFail(id, error));
+    });
+  };
+};
+
+export function unmuteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+      dispatch(unmuteAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unmuteAccountFail(id, error));
+    });
+  };
+};
+
+export function muteAccountRequest(id) {
+  return {
+    type: ACCOUNT_MUTE_REQUEST,
+    id,
+  };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_MUTE_SUCCESS,
+    relationship,
+    statuses,
+  };
+};
+
+export function muteAccountFail(error) {
+  return {
+    type: ACCOUNT_MUTE_FAIL,
+    error,
+  };
+};
+
+export function unmuteAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNMUTE_REQUEST,
+    id,
+  };
+};
+
+export function unmuteAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNMUTE_SUCCESS,
+    relationship,
+  };
+};
+
+export function unmuteAccountFail(error) {
+  return {
+    type: ACCOUNT_UNMUTE_FAIL,
+    error,
+  };
+};
+
+
+export function fetchFollowers(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowersRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchFollowersFail(id, error));
+    });
+  };
+};
+
+export function fetchFollowersRequest(id) {
+  return {
+    type: FOLLOWERS_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchFollowersSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWERS_FETCH_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function fetchFollowersFail(id, error) {
+  return {
+    type: FOLLOWERS_FETCH_FAIL,
+    id,
+    error,
+  };
+};
+
+export function expandFollowers(id) {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'followers', id, 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowersRequest(id));
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(expandFollowersFail(id, error));
+    });
+  };
+};
+
+export function expandFollowersRequest(id) {
+  return {
+    type: FOLLOWERS_EXPAND_REQUEST,
+    id,
+  };
+};
+
+export function expandFollowersSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWERS_EXPAND_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function expandFollowersFail(id, error) {
+  return {
+    type: FOLLOWERS_EXPAND_FAIL,
+    id,
+    error,
+  };
+};
+
+export function fetchFollowing(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowingRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchFollowingFail(id, error));
+    });
+  };
+};
+
+export function fetchFollowingRequest(id) {
+  return {
+    type: FOLLOWING_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchFollowingSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWING_FETCH_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function fetchFollowingFail(id, error) {
+  return {
+    type: FOLLOWING_FETCH_FAIL,
+    id,
+    error,
+  };
+};
+
+export function expandFollowing(id) {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'following', id, 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowingRequest(id));
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(expandFollowingFail(id, error));
+    });
+  };
+};
+
+export function expandFollowingRequest(id) {
+  return {
+    type: FOLLOWING_EXPAND_REQUEST,
+    id,
+  };
+};
+
+export function expandFollowingSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWING_EXPAND_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function expandFollowingFail(id, error) {
+  return {
+    type: FOLLOWING_EXPAND_FAIL,
+    id,
+    error,
+  };
+};
+
+export function fetchRelationships(accountIds) {
+  return (dispatch, getState) => {
+    const loadedRelationships = getState().get('relationships');
+    const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+    if (newAccountIds.length === 0) {
+      return;
+    }
+
+    dispatch(fetchRelationshipsRequest(newAccountIds));
+
+    api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
+      dispatch(fetchRelationshipsSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchRelationshipsFail(error));
+    });
+  };
+};
+
+export function fetchRelationshipsRequest(ids) {
+  return {
+    type: RELATIONSHIPS_FETCH_REQUEST,
+    ids,
+    skipLoading: true,
+  };
+};
+
+export function fetchRelationshipsSuccess(relationships) {
+  return {
+    type: RELATIONSHIPS_FETCH_SUCCESS,
+    relationships,
+    skipLoading: true,
+  };
+};
+
+export function fetchRelationshipsFail(error) {
+  return {
+    type: RELATIONSHIPS_FETCH_FAIL,
+    error,
+    skipLoading: true,
+  };
+};
+
+export function fetchFollowRequests() {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowRequestsRequest());
+
+    api(getState).get('/api/v1/follow_requests').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => dispatch(fetchFollowRequestsFail(error)));
+  };
+};
+
+export function fetchFollowRequestsRequest() {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_REQUEST,
+  };
+};
+
+export function fetchFollowRequestsSuccess(accounts, next) {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function fetchFollowRequestsFail(error) {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFollowRequests() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowRequestsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => dispatch(expandFollowRequestsFail(error)));
+  };
+};
+
+export function expandFollowRequestsRequest() {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_REQUEST,
+  };
+};
+
+export function expandFollowRequestsSuccess(accounts, next) {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function expandFollowRequestsFail(error) {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_FAIL,
+    error,
+  };
+};
+
+export function authorizeFollowRequest(id) {
+  return (dispatch, getState) => {
+    dispatch(authorizeFollowRequestRequest(id));
+
+    api(getState)
+      .post(`/api/v1/follow_requests/${id}/authorize`)
+      .then(() => dispatch(authorizeFollowRequestSuccess(id)))
+      .catch(error => dispatch(authorizeFollowRequestFail(id, error)));
+  };
+};
+
+export function authorizeFollowRequestRequest(id) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
+    id,
+  };
+};
+
+export function authorizeFollowRequestSuccess(id) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+    id,
+  };
+};
+
+export function authorizeFollowRequestFail(id, error) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
+    id,
+    error,
+  };
+};
+
+
+export function rejectFollowRequest(id) {
+  return (dispatch, getState) => {
+    dispatch(rejectFollowRequestRequest(id));
+
+    api(getState)
+      .post(`/api/v1/follow_requests/${id}/reject`)
+      .then(() => dispatch(rejectFollowRequestSuccess(id)))
+      .catch(error => dispatch(rejectFollowRequestFail(id, error)));
+  };
+};
+
+export function rejectFollowRequestRequest(id) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_REQUEST,
+    id,
+  };
+};
+
+export function rejectFollowRequestSuccess(id) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_SUCCESS,
+    id,
+  };
+};
+
+export function rejectFollowRequestFail(id, error) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_FAIL,
+    id,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js
new file mode 100644
index 000000000..f37fdeeb6
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/alerts.js
@@ -0,0 +1,24 @@
+export const ALERT_SHOW    = 'ALERT_SHOW';
+export const ALERT_DISMISS = 'ALERT_DISMISS';
+export const ALERT_CLEAR   = 'ALERT_CLEAR';
+
+export function dismissAlert(alert) {
+  return {
+    type: ALERT_DISMISS,
+    alert,
+  };
+};
+
+export function clearAlert() {
+  return {
+    type: ALERT_CLEAR,
+  };
+};
+
+export function showAlert(title, message) {
+  return {
+    type: ALERT_SHOW,
+    title,
+    message,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js
new file mode 100644
index 000000000..fe44ca19a
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/blocks.js
@@ -0,0 +1,82 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { fetchRelationships } from './accounts';
+
+export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
+export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
+export const BLOCKS_FETCH_FAIL    = 'BLOCKS_FETCH_FAIL';
+
+export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
+export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
+export const BLOCKS_EXPAND_FAIL    = 'BLOCKS_EXPAND_FAIL';
+
+export function fetchBlocks() {
+  return (dispatch, getState) => {
+    dispatch(fetchBlocksRequest());
+
+    api(getState).get('/api/v1/blocks').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(fetchBlocksFail(error)));
+  };
+};
+
+export function fetchBlocksRequest() {
+  return {
+    type: BLOCKS_FETCH_REQUEST,
+  };
+};
+
+export function fetchBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_FETCH_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function fetchBlocksFail(error) {
+  return {
+    type: BLOCKS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandBlocks() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'blocks', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandBlocksRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(expandBlocksFail(error)));
+  };
+};
+
+export function expandBlocksRequest() {
+  return {
+    type: BLOCKS_EXPAND_REQUEST,
+  };
+};
+
+export function expandBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_EXPAND_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function expandBlocksFail(error) {
+  return {
+    type: BLOCKS_EXPAND_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/bundles.js b/app/javascript/flavours/glitch/actions/bundles.js
new file mode 100644
index 000000000..ecc9c8f7d
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/bundles.js
@@ -0,0 +1,25 @@
+export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
+export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
+export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
+
+export function fetchBundleRequest(skipLoading) {
+  return {
+    type: BUNDLE_FETCH_REQUEST,
+    skipLoading,
+  };
+}
+
+export function fetchBundleSuccess(skipLoading) {
+  return {
+    type: BUNDLE_FETCH_SUCCESS,
+    skipLoading,
+  };
+}
+
+export function fetchBundleFail(error, skipLoading) {
+  return {
+    type: BUNDLE_FETCH_FAIL,
+    error,
+    skipLoading,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/cards.js b/app/javascript/flavours/glitch/actions/cards.js
new file mode 100644
index 000000000..c897daf58
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/cards.js
@@ -0,0 +1,52 @@
+import api from 'flavours/glitch/util/api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL    = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+  return (dispatch, getState) => {
+    if (getState().getIn(['cards', id], null) !== null) {
+      return;
+    }
+
+    dispatch(fetchStatusCardRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+      if (!response.data.url) {
+        return;
+      }
+
+      dispatch(fetchStatusCardSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchStatusCardFail(id, error));
+    });
+  };
+};
+
+export function fetchStatusCardRequest(id) {
+  return {
+    type: STATUS_CARD_FETCH_REQUEST,
+    id,
+    skipLoading: true,
+  };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+  return {
+    type: STATUS_CARD_FETCH_SUCCESS,
+    id,
+    card,
+    skipLoading: true,
+  };
+};
+
+export function fetchStatusCardFail(id, error) {
+  return {
+    type: STATUS_CARD_FETCH_FAIL,
+    id,
+    error,
+    skipLoading: true,
+    skipAlert: true,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/columns.js b/app/javascript/flavours/glitch/actions/columns.js
new file mode 100644
index 000000000..bcb0cdf98
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/columns.js
@@ -0,0 +1,40 @@
+import { saveSettings } from './settings';
+
+export const COLUMN_ADD    = 'COLUMN_ADD';
+export const COLUMN_REMOVE = 'COLUMN_REMOVE';
+export const COLUMN_MOVE   = 'COLUMN_MOVE';
+
+export function addColumn(id, params) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_ADD,
+      id,
+      params,
+    });
+
+    dispatch(saveSettings());
+  };
+};
+
+export function removeColumn(uuid) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_REMOVE,
+      uuid,
+    });
+
+    dispatch(saveSettings());
+  };
+};
+
+export function moveColumn(uuid, direction) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_MOVE,
+      uuid,
+      direction,
+    });
+
+    dispatch(saveSettings());
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
new file mode 100644
index 000000000..c46387104
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -0,0 +1,396 @@
+import api from 'flavours/glitch/util/api';
+import { throttle } from 'lodash';
+import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
+import { useEmoji } from './emojis';
+
+import {
+  updateTimeline,
+  refreshHomeTimeline,
+  refreshCommunityTimeline,
+  refreshPublicTimeline,
+  refreshDirectTimeline,
+} from './timelines';
+
+export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
+export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
+export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST';
+export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
+export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
+export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
+export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
+export const COMPOSE_RESET           = 'COMPOSE_RESET';
+export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
+export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS';
+export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL';
+export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
+export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO';
+
+export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
+export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
+export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+
+export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
+export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
+
+export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
+export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
+export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
+export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
+
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
+export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
+export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
+export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL';
+
+export const COMPOSE_DOODLE_SET        = 'COMPOSE_DOODLE_SET';
+
+export function changeCompose(text) {
+  return {
+    type: COMPOSE_CHANGE,
+    text: text,
+  };
+};
+
+export function cycleElefriendCompose() {
+  return {
+    type: COMPOSE_CYCLE_ELEFRIEND,
+  };
+};
+
+export function replyCompose(status, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_REPLY,
+      status: status,
+    });
+
+    if (router && !getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
+export function cancelReplyCompose() {
+  return {
+    type: COMPOSE_REPLY_CANCEL,
+  };
+};
+
+export function resetCompose() {
+  return {
+    type: COMPOSE_RESET,
+  };
+};
+
+export function mentionCompose(account, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_MENTION,
+      account: account,
+    });
+
+    if (!getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
+export function submitCompose() {
+  return function (dispatch, getState) {
+    let status = getState().getIn(['compose', 'text'], '');
+
+    if (!status || !status.length) {
+      return;
+    }
+
+    dispatch(submitComposeRequest());
+    if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
+      status = status + ' 👁️';
+    }
+    api(getState).post('/api/v1/statuses', {
+      status,
+      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+      media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+      sensitive: getState().getIn(['compose', 'sensitive']),
+      spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+      visibility: getState().getIn(['compose', 'privacy']),
+    }, {
+      headers: {
+        'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
+      },
+    }).then(function (response) {
+      dispatch(submitComposeSuccess({ ...response.data }));
+
+      //  If the response has no data then we can't do anything else.
+      if (!response.data) {
+        return;
+      }
+
+      // To make the app more responsive, immediately get the status into the columns
+
+      const insertOrRefresh = (timelineId, refreshAction) => {
+        if (getState().getIn(['timelines', timelineId, 'online'])) {
+          dispatch(updateTimeline(timelineId, { ...response.data }));
+        } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
+          dispatch(refreshAction());
+        }
+      };
+
+      insertOrRefresh('home', refreshHomeTimeline);
+
+      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+        insertOrRefresh('community', refreshCommunityTimeline);
+        insertOrRefresh('public', refreshPublicTimeline);
+      } else if (response.data.visibility === 'direct') {
+        insertOrRefresh('direct', refreshDirectTimeline);
+      }
+    }).catch(function (error) {
+      dispatch(submitComposeFail(error));
+    });
+  };
+};
+
+export function submitComposeRequest() {
+  return {
+    type: COMPOSE_SUBMIT_REQUEST,
+  };
+};
+
+export function submitComposeSuccess(status) {
+  return {
+    type: COMPOSE_SUBMIT_SUCCESS,
+    status: status,
+  };
+};
+
+export function submitComposeFail(error) {
+  return {
+    type: COMPOSE_SUBMIT_FAIL,
+    error: error,
+  };
+};
+
+export function doodleSet(options) {
+  return {
+    type: COMPOSE_DOODLE_SET,
+    options: options,
+  };
+};
+
+export function uploadCompose(files) {
+  return function (dispatch, getState) {
+    if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+      return;
+    }
+
+    dispatch(uploadComposeRequest());
+
+    let data = new FormData();
+    data.append('file', files[0]);
+
+    api(getState).post('/api/v1/media', data, {
+      onUploadProgress: function (e) {
+        dispatch(uploadComposeProgress(e.loaded, e.total));
+      },
+    }).then(function (response) {
+      dispatch(uploadComposeSuccess(response.data));
+    }).catch(function (error) {
+      dispatch(uploadComposeFail(error));
+    });
+  };
+};
+
+export function changeUploadCompose(id, description) {
+  return (dispatch, getState) => {
+    dispatch(changeUploadComposeRequest());
+
+    api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+      dispatch(changeUploadComposeSuccess(response.data));
+    }).catch(error => {
+      dispatch(changeUploadComposeFail(id, error));
+    });
+  };
+};
+
+export function changeUploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+    skipLoading: true,
+  };
+};
+export function changeUploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+    media: media,
+    skipLoading: true,
+  };
+};
+
+export function changeUploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_FAIL,
+    error: error,
+    skipLoading: true,
+  };
+};
+
+export function uploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_REQUEST,
+    skipLoading: true,
+  };
+};
+
+export function uploadComposeProgress(loaded, total) {
+  return {
+    type: COMPOSE_UPLOAD_PROGRESS,
+    loaded: loaded,
+    total: total,
+  };
+};
+
+export function uploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_SUCCESS,
+    media: media,
+    skipLoading: true,
+  };
+};
+
+export function uploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_FAIL,
+    error: error,
+    skipLoading: true,
+  };
+};
+
+export function undoUploadCompose(media_id) {
+  return {
+    type: COMPOSE_UPLOAD_UNDO,
+    media_id: media_id,
+  };
+};
+
+export function clearComposeSuggestions() {
+  return {
+    type: COMPOSE_SUGGESTIONS_CLEAR,
+  };
+};
+
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+  api(getState).get('/api/v1/accounts/search', {
+    params: {
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(response => {
+    dispatch(readyComposeSuggestionsAccounts(token, response.data));
+  });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+  const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+  dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
+export function fetchComposeSuggestions(token) {
+  return (dispatch, getState) => {
+    if (token[0] === ':') {
+      fetchComposeSuggestionsEmojis(dispatch, getState, token);
+    } else {
+      fetchComposeSuggestionsAccounts(dispatch, getState, token);
+    }
+  };
+};
+
+export function readyComposeSuggestionsEmojis(token, emojis) {
+  return {
+    type: COMPOSE_SUGGESTIONS_READY,
+    token,
+    emojis,
+  };
+};
+
+export function readyComposeSuggestionsAccounts(token, accounts) {
+  return {
+    type: COMPOSE_SUGGESTIONS_READY,
+    token,
+    accounts,
+  };
+};
+
+export function selectComposeSuggestion(position, token, suggestion) {
+  return (dispatch, getState) => {
+    const completion = typeof suggestion === 'object' && suggestion.id ? (
+      dispatch(useEmoji(suggestion)),
+      suggestion.native || suggestion.colons
+    ) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
+
+    dispatch({
+      type: COMPOSE_SUGGESTION_SELECT,
+      position,
+      token,
+      completion,
+    });
+  };
+};
+
+export function mountCompose() {
+  return {
+    type: COMPOSE_MOUNT,
+  };
+};
+
+export function unmountCompose() {
+  return {
+    type: COMPOSE_UNMOUNT,
+  };
+};
+
+export function changeComposeAdvancedOption(option, value) {
+  return {
+    option,
+    type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
+    value,
+  };
+}
+
+export function changeComposeSensitivity() {
+  return {
+    type: COMPOSE_SENSITIVITY_CHANGE,
+  };
+};
+
+export function changeComposeSpoilerness() {
+  return {
+    type: COMPOSE_SPOILERNESS_CHANGE,
+  };
+};
+
+export function changeComposeSpoilerText(text) {
+  return {
+    type: COMPOSE_SPOILER_TEXT_CHANGE,
+    text,
+  };
+};
+
+export function changeComposeVisibility(value) {
+  return {
+    type: COMPOSE_VISIBILITY_CHANGE,
+    value,
+  };
+};
+
+export function insertEmojiCompose(position, emoji) {
+  return {
+    type: COMPOSE_EMOJI_INSERT,
+    position,
+    emoji,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js
new file mode 100644
index 000000000..8506df91c
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/domain_blocks.js
@@ -0,0 +1,117 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+
+export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
+export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
+export const DOMAIN_BLOCK_FAIL    = 'DOMAIN_BLOCK_FAIL';
+
+export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
+export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
+export const DOMAIN_UNBLOCK_FAIL    = 'DOMAIN_UNBLOCK_FAIL';
+
+export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
+export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
+export const DOMAIN_BLOCKS_FETCH_FAIL    = 'DOMAIN_BLOCKS_FETCH_FAIL';
+
+export function blockDomain(domain, accountId) {
+  return (dispatch, getState) => {
+    dispatch(blockDomainRequest(domain));
+
+    api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
+      dispatch(blockDomainSuccess(domain, accountId));
+    }).catch(err => {
+      dispatch(blockDomainFail(domain, err));
+    });
+  };
+};
+
+export function blockDomainRequest(domain) {
+  return {
+    type: DOMAIN_BLOCK_REQUEST,
+    domain,
+  };
+};
+
+export function blockDomainSuccess(domain, accountId) {
+  return {
+    type: DOMAIN_BLOCK_SUCCESS,
+    domain,
+    accountId,
+  };
+};
+
+export function blockDomainFail(domain, error) {
+  return {
+    type: DOMAIN_BLOCK_FAIL,
+    domain,
+    error,
+  };
+};
+
+export function unblockDomain(domain, accountId) {
+  return (dispatch, getState) => {
+    dispatch(unblockDomainRequest(domain));
+
+    api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
+      dispatch(unblockDomainSuccess(domain, accountId));
+    }).catch(err => {
+      dispatch(unblockDomainFail(domain, err));
+    });
+  };
+};
+
+export function unblockDomainRequest(domain) {
+  return {
+    type: DOMAIN_UNBLOCK_REQUEST,
+    domain,
+  };
+};
+
+export function unblockDomainSuccess(domain, accountId) {
+  return {
+    type: DOMAIN_UNBLOCK_SUCCESS,
+    domain,
+    accountId,
+  };
+};
+
+export function unblockDomainFail(domain, error) {
+  return {
+    type: DOMAIN_UNBLOCK_FAIL,
+    domain,
+    error,
+  };
+};
+
+export function fetchDomainBlocks() {
+  return (dispatch, getState) => {
+    dispatch(fetchDomainBlocksRequest());
+
+    api(getState).get().then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
+    }).catch(err => {
+      dispatch(fetchDomainBlocksFail(err));
+    });
+  };
+};
+
+export function fetchDomainBlocksRequest() {
+  return {
+    type: DOMAIN_BLOCKS_FETCH_REQUEST,
+  };
+};
+
+export function fetchDomainBlocksSuccess(domains, next) {
+  return {
+    type: DOMAIN_BLOCKS_FETCH_SUCCESS,
+    domains,
+    next,
+  };
+};
+
+export function fetchDomainBlocksFail(error) {
+  return {
+    type: DOMAIN_BLOCKS_FETCH_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/emojis.js b/app/javascript/flavours/glitch/actions/emojis.js
new file mode 100644
index 000000000..7cd9d4b7b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/emojis.js
@@ -0,0 +1,14 @@
+import { saveSettings } from './settings';
+
+export const EMOJI_USE = 'EMOJI_USE';
+
+export function useEmoji(emoji) {
+  return dispatch => {
+    dispatch({
+      type: EMOJI_USE,
+      emoji,
+    });
+
+    dispatch(saveSettings());
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/favourites.js b/app/javascript/flavours/glitch/actions/favourites.js
new file mode 100644
index 000000000..0c0f3af44
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/favourites.js
@@ -0,0 +1,87 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL    = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL    = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+  return (dispatch, getState) => {
+    if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) {
+      return;
+    }
+
+    dispatch(fetchFavouritedStatusesRequest());
+
+    api(getState).get('/api/v1/favourites').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_REQUEST,
+  };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next,
+  };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFavouritedStatuses() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+    if (url === null || getState().getIn(['status_lists', 'favourites', 'isLoading'])) {
+      return;
+    }
+
+    dispatch(expandFavouritedStatusesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function expandFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_REQUEST,
+  };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+    statuses,
+    next,
+  };
+};
+
+export function expandFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/height_cache.js b/app/javascript/flavours/glitch/actions/height_cache.js
new file mode 100644
index 000000000..4c752993f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/height_cache.js
@@ -0,0 +1,17 @@
+export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
+export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
+
+export function setHeight (key, id, height) {
+  return {
+    type: HEIGHT_CACHE_SET,
+    key,
+    id,
+    height,
+  };
+};
+
+export function clearHeight () {
+  return {
+    type: HEIGHT_CACHE_CLEAR,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js
new file mode 100644
index 000000000..ceeb2773b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/interactions.js
@@ -0,0 +1,313 @@
+import api from 'flavours/glitch/util/api';
+
+export const REBLOG_REQUEST = 'REBLOG_REQUEST';
+export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
+export const REBLOG_FAIL    = 'REBLOG_FAIL';
+
+export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
+export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
+export const FAVOURITE_FAIL    = 'FAVOURITE_FAIL';
+
+export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
+export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
+export const UNREBLOG_FAIL    = 'UNREBLOG_FAIL';
+
+export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
+export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
+export const UNFAVOURITE_FAIL    = 'UNFAVOURITE_FAIL';
+
+export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
+export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
+export const REBLOGS_FETCH_FAIL    = 'REBLOGS_FETCH_FAIL';
+
+export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
+export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
+export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';
+
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL    = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL    = 'UNPIN_FAIL';
+
+export function reblog(status) {
+  return function (dispatch, getState) {
+    dispatch(reblogRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
+      // The reblog API method returns a new status wrapped around the original. In this case we are only
+      // interested in how the original is modified, hence passing it skipping the wrapper
+      dispatch(reblogSuccess(status, response.data.reblog));
+    }).catch(function (error) {
+      dispatch(reblogFail(status, error));
+    });
+  };
+};
+
+export function unreblog(status) {
+  return (dispatch, getState) => {
+    dispatch(unreblogRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
+      dispatch(unreblogSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unreblogFail(status, error));
+    });
+  };
+};
+
+export function reblogRequest(status) {
+  return {
+    type: REBLOG_REQUEST,
+    status: status,
+  };
+};
+
+export function reblogSuccess(status, response) {
+  return {
+    type: REBLOG_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function reblogFail(status, error) {
+  return {
+    type: REBLOG_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function unreblogRequest(status) {
+  return {
+    type: UNREBLOG_REQUEST,
+    status: status,
+  };
+};
+
+export function unreblogSuccess(status, response) {
+  return {
+    type: UNREBLOG_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function unreblogFail(status, error) {
+  return {
+    type: UNREBLOG_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function favourite(status) {
+  return function (dispatch, getState) {
+    dispatch(favouriteRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
+      dispatch(favouriteSuccess(status, response.data));
+    }).catch(function (error) {
+      dispatch(favouriteFail(status, error));
+    });
+  };
+};
+
+export function unfavourite(status) {
+  return (dispatch, getState) => {
+    dispatch(unfavouriteRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
+      dispatch(unfavouriteSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unfavouriteFail(status, error));
+    });
+  };
+};
+
+export function favouriteRequest(status) {
+  return {
+    type: FAVOURITE_REQUEST,
+    status: status,
+  };
+};
+
+export function favouriteSuccess(status, response) {
+  return {
+    type: FAVOURITE_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function favouriteFail(status, error) {
+  return {
+    type: FAVOURITE_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function unfavouriteRequest(status) {
+  return {
+    type: UNFAVOURITE_REQUEST,
+    status: status,
+  };
+};
+
+export function unfavouriteSuccess(status, response) {
+  return {
+    type: UNFAVOURITE_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function unfavouriteFail(status, error) {
+  return {
+    type: UNFAVOURITE_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function fetchReblogs(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchReblogsRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+      dispatch(fetchReblogsSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchReblogsFail(id, error));
+    });
+  };
+};
+
+export function fetchReblogsRequest(id) {
+  return {
+    type: REBLOGS_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchReblogsSuccess(id, accounts) {
+  return {
+    type: REBLOGS_FETCH_SUCCESS,
+    id,
+    accounts,
+  };
+};
+
+export function fetchReblogsFail(id, error) {
+  return {
+    type: REBLOGS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function fetchFavourites(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFavouritesRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+      dispatch(fetchFavouritesSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchFavouritesFail(id, error));
+    });
+  };
+};
+
+export function fetchFavouritesRequest(id) {
+  return {
+    type: FAVOURITES_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchFavouritesSuccess(id, accounts) {
+  return {
+    type: FAVOURITES_FETCH_SUCCESS,
+    id,
+    accounts,
+  };
+};
+
+export function fetchFavouritesFail(id, error) {
+  return {
+    type: FAVOURITES_FETCH_FAIL,
+    error,
+  };
+};
+
+export function pin(status) {
+  return (dispatch, getState) => {
+    dispatch(pinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+      dispatch(pinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(pinFail(status, error));
+    });
+  };
+};
+
+export function pinRequest(status) {
+  return {
+    type: PIN_REQUEST,
+    status,
+  };
+};
+
+export function pinSuccess(status, response) {
+  return {
+    type: PIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function pinFail(status, error) {
+  return {
+    type: PIN_FAIL,
+    status,
+    error,
+  };
+};
+
+export function unpin (status) {
+  return (dispatch, getState) => {
+    dispatch(unpinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+      dispatch(unpinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unpinFail(status, error));
+    });
+  };
+};
+
+export function unpinRequest(status) {
+  return {
+    type: UNPIN_REQUEST,
+    status,
+  };
+};
+
+export function unpinSuccess(status, response) {
+  return {
+    type: UNPIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function unpinFail(status, error) {
+  return {
+    type: UNPIN_FAIL,
+    status,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js
new file mode 100644
index 000000000..3c3af5fee
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/lists.js
@@ -0,0 +1,313 @@
+import api from 'flavours/glitch/util/api';
+
+export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
+export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
+export const LIST_FETCH_FAIL    = 'LIST_FETCH_FAIL';
+
+export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
+export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
+export const LISTS_FETCH_FAIL    = 'LISTS_FETCH_FAIL';
+
+export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
+export const LIST_EDITOR_RESET        = 'LIST_EDITOR_RESET';
+export const LIST_EDITOR_SETUP        = 'LIST_EDITOR_SETUP';
+
+export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
+export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
+export const LIST_CREATE_FAIL    = 'LIST_CREATE_FAIL';
+
+export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
+export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
+export const LIST_UPDATE_FAIL    = 'LIST_UPDATE_FAIL';
+
+export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
+export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
+export const LIST_DELETE_FAIL    = 'LIST_DELETE_FAIL';
+
+export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
+export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
+export const LIST_ACCOUNTS_FETCH_FAIL    = 'LIST_ACCOUNTS_FETCH_FAIL';
+
+export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
+export const LIST_EDITOR_SUGGESTIONS_READY  = 'LIST_EDITOR_SUGGESTIONS_READY';
+export const LIST_EDITOR_SUGGESTIONS_CLEAR  = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
+
+export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
+export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
+export const LIST_EDITOR_ADD_FAIL    = 'LIST_EDITOR_ADD_FAIL';
+
+export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
+export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
+export const LIST_EDITOR_REMOVE_FAIL    = 'LIST_EDITOR_REMOVE_FAIL';
+
+export const fetchList = id => (dispatch, getState) => {
+  if (getState().getIn(['lists', id])) {
+    return;
+  }
+
+  dispatch(fetchListRequest(id));
+
+  api(getState).get(`/api/v1/lists/${id}`)
+    .then(({ data }) => dispatch(fetchListSuccess(data)))
+    .catch(err => dispatch(fetchListFail(id, err)));
+};
+
+export const fetchListRequest = id => ({
+  type: LIST_FETCH_REQUEST,
+  id,
+});
+
+export const fetchListSuccess = list => ({
+  type: LIST_FETCH_SUCCESS,
+  list,
+});
+
+export const fetchListFail = (id, error) => ({
+  type: LIST_FETCH_FAIL,
+  id,
+  error,
+});
+
+export const fetchLists = () => (dispatch, getState) => {
+  dispatch(fetchListsRequest());
+
+  api(getState).get('/api/v1/lists')
+    .then(({ data }) => dispatch(fetchListsSuccess(data)))
+    .catch(err => dispatch(fetchListsFail(err)));
+};
+
+export const fetchListsRequest = () => ({
+  type: LISTS_FETCH_REQUEST,
+});
+
+export const fetchListsSuccess = lists => ({
+  type: LISTS_FETCH_SUCCESS,
+  lists,
+});
+
+export const fetchListsFail = error => ({
+  type: LISTS_FETCH_FAIL,
+  error,
+});
+
+export const submitListEditor = shouldReset => (dispatch, getState) => {
+  const listId = getState().getIn(['listEditor', 'listId']);
+  const title  = getState().getIn(['listEditor', 'title']);
+
+  if (listId === null) {
+    dispatch(createList(title, shouldReset));
+  } else {
+    dispatch(updateList(listId, title, shouldReset));
+  }
+};
+
+export const setupListEditor = listId => (dispatch, getState) => {
+  dispatch({
+    type: LIST_EDITOR_SETUP,
+    list: getState().getIn(['lists', listId]),
+  });
+
+  dispatch(fetchListAccounts(listId));
+};
+
+export const changeListEditorTitle = value => ({
+  type: LIST_EDITOR_TITLE_CHANGE,
+  value,
+});
+
+export const createList = (title, shouldReset) => (dispatch, getState) => {
+  dispatch(createListRequest());
+
+  api(getState).post('/api/v1/lists', { title }).then(({ data }) => {
+    dispatch(createListSuccess(data));
+
+    if (shouldReset) {
+      dispatch(resetListEditor());
+    }
+  }).catch(err => dispatch(createListFail(err)));
+};
+
+export const createListRequest = () => ({
+  type: LIST_CREATE_REQUEST,
+});
+
+export const createListSuccess = list => ({
+  type: LIST_CREATE_SUCCESS,
+  list,
+});
+
+export const createListFail = error => ({
+  type: LIST_CREATE_FAIL,
+  error,
+});
+
+export const updateList = (id, title, shouldReset) => (dispatch, getState) => {
+  dispatch(updateListRequest(id));
+
+  api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => {
+    dispatch(updateListSuccess(data));
+
+    if (shouldReset) {
+      dispatch(resetListEditor());
+    }
+  }).catch(err => dispatch(updateListFail(id, err)));
+};
+
+export const updateListRequest = id => ({
+  type: LIST_UPDATE_REQUEST,
+  id,
+});
+
+export const updateListSuccess = list => ({
+  type: LIST_UPDATE_SUCCESS,
+  list,
+});
+
+export const updateListFail = (id, error) => ({
+  type: LIST_UPDATE_FAIL,
+  id,
+  error,
+});
+
+export const resetListEditor = () => ({
+  type: LIST_EDITOR_RESET,
+});
+
+export const deleteList = id => (dispatch, getState) => {
+  dispatch(deleteListRequest(id));
+
+  api(getState).delete(`/api/v1/lists/${id}`)
+    .then(() => dispatch(deleteListSuccess(id)))
+    .catch(err => dispatch(deleteListFail(id, err)));
+};
+
+export const deleteListRequest = id => ({
+  type: LIST_DELETE_REQUEST,
+  id,
+});
+
+export const deleteListSuccess = id => ({
+  type: LIST_DELETE_SUCCESS,
+  id,
+});
+
+export const deleteListFail = (id, error) => ({
+  type: LIST_DELETE_FAIL,
+  id,
+  error,
+});
+
+export const fetchListAccounts = listId => (dispatch, getState) => {
+  dispatch(fetchListAccountsRequest(listId));
+
+  api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } })
+    .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data)))
+    .catch(err => dispatch(fetchListAccountsFail(listId, err)));
+};
+
+export const fetchListAccountsRequest = id => ({
+  type: LIST_ACCOUNTS_FETCH_REQUEST,
+  id,
+});
+
+export const fetchListAccountsSuccess = (id, accounts, next) => ({
+  type: LIST_ACCOUNTS_FETCH_SUCCESS,
+  id,
+  accounts,
+  next,
+});
+
+export const fetchListAccountsFail = (id, error) => ({
+  type: LIST_ACCOUNTS_FETCH_FAIL,
+  id,
+  error,
+});
+
+export const fetchListSuggestions = q => (dispatch, getState) => {
+  const params = {
+    q,
+    resolve: false,
+    limit: 4,
+    following: true,
+  };
+
+  api(getState).get('/api/v1/accounts/search', { params })
+    .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data)));
+};
+
+export const fetchListSuggestionsReady = (query, accounts) => ({
+  type: LIST_EDITOR_SUGGESTIONS_READY,
+  query,
+  accounts,
+});
+
+export const clearListSuggestions = () => ({
+  type: LIST_EDITOR_SUGGESTIONS_CLEAR,
+});
+
+export const changeListSuggestions = value => ({
+  type: LIST_EDITOR_SUGGESTIONS_CHANGE,
+  value,
+});
+
+export const addToListEditor = accountId => (dispatch, getState) => {
+  dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
+};
+
+export const addToList = (listId, accountId) => (dispatch, getState) => {
+  dispatch(addToListRequest(listId, accountId));
+
+  api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
+    .then(() => dispatch(addToListSuccess(listId, accountId)))
+    .catch(err => dispatch(addToListFail(listId, accountId, err)));
+};
+
+export const addToListRequest = (listId, accountId) => ({
+  type: LIST_EDITOR_ADD_REQUEST,
+  listId,
+  accountId,
+});
+
+export const addToListSuccess = (listId, accountId) => ({
+  type: LIST_EDITOR_ADD_SUCCESS,
+  listId,
+  accountId,
+});
+
+export const addToListFail = (listId, accountId, error) => ({
+  type: LIST_EDITOR_ADD_FAIL,
+  listId,
+  accountId,
+  error,
+});
+
+export const removeFromListEditor = accountId => (dispatch, getState) => {
+  dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
+};
+
+export const removeFromList = (listId, accountId) => (dispatch, getState) => {
+  dispatch(removeFromListRequest(listId, accountId));
+
+  api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
+    .then(() => dispatch(removeFromListSuccess(listId, accountId)))
+    .catch(err => dispatch(removeFromListFail(listId, accountId, err)));
+};
+
+export const removeFromListRequest = (listId, accountId) => ({
+  type: LIST_EDITOR_REMOVE_REQUEST,
+  listId,
+  accountId,
+});
+
+export const removeFromListSuccess = (listId, accountId) => ({
+  type: LIST_EDITOR_REMOVE_SUCCESS,
+  listId,
+  accountId,
+});
+
+export const removeFromListFail = (listId, accountId, error) => ({
+  type: LIST_EDITOR_REMOVE_FAIL,
+  listId,
+  accountId,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/local_settings.js b/app/javascript/flavours/glitch/actions/local_settings.js
new file mode 100644
index 000000000..28660a4e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/local_settings.js
@@ -0,0 +1,24 @@
+export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
+
+export function changeLocalSetting(key, value) {
+  return dispatch => {
+    dispatch({
+      type: LOCAL_SETTING_CHANGE,
+      key,
+      value,
+    });
+
+    dispatch(saveLocalSettings());
+  };
+};
+
+//  __TODO :__
+//  Right now `saveLocalSettings()` doesn't keep track of which user
+//  is currently signed in, but it might be better to give each user
+//  their *own* local settings.
+export function saveLocalSettings() {
+  return (_, getState) => {
+    const localSettings = getState().get('local_settings').toJS();
+    localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js
new file mode 100644
index 000000000..80e15c28e
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/modal.js
@@ -0,0 +1,16 @@
+export const MODAL_OPEN  = 'MODAL_OPEN';
+export const MODAL_CLOSE = 'MODAL_CLOSE';
+
+export function openModal(type, props) {
+  return {
+    type: MODAL_OPEN,
+    modalType: type,
+    modalProps: props,
+  };
+};
+
+export function closeModal() {
+  return {
+    type: MODAL_CLOSE,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js
new file mode 100644
index 000000000..e06130533
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -0,0 +1,103 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { fetchRelationships } from './accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
+export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
+export const MUTES_FETCH_FAIL    = 'MUTES_FETCH_FAIL';
+
+export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
+export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
+export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
+
+export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
+export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+
+export function fetchMutes() {
+  return (dispatch, getState) => {
+    dispatch(fetchMutesRequest());
+
+    api(getState).get('/api/v1/mutes').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(fetchMutesFail(error)));
+  };
+};
+
+export function fetchMutesRequest() {
+  return {
+    type: MUTES_FETCH_REQUEST,
+  };
+};
+
+export function fetchMutesSuccess(accounts, next) {
+  return {
+    type: MUTES_FETCH_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function fetchMutesFail(error) {
+  return {
+    type: MUTES_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandMutes() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'mutes', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandMutesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(expandMutesFail(error)));
+  };
+};
+
+export function expandMutesRequest() {
+  return {
+    type: MUTES_EXPAND_REQUEST,
+  };
+};
+
+export function expandMutesSuccess(accounts, next) {
+  return {
+    type: MUTES_EXPAND_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function expandMutesFail(error) {
+  return {
+    type: MUTES_EXPAND_FAIL,
+    error,
+  };
+};
+
+export function initMuteModal(account) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_INIT_MODAL,
+      account,
+    });
+
+    dispatch(openModal('MUTE'));
+  };
+}
+
+export function toggleHideNotifications() {
+  return dispatch => {
+    dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
new file mode 100644
index 000000000..cf27eff90
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -0,0 +1,265 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { List as ImmutableList } from 'immutable';
+import IntlMessageFormat from 'intl-messageformat';
+import { fetchRelationships } from './accounts';
+import { defineMessages } from 'react-intl';
+
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+
+// tracking the notif cleaning request
+export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
+export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS';
+export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL';
+export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE';
+export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes
+// Unmark notifications (when the cleaning mode is left)
+export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE';
+// Mark one for delete
+export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
+
+export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
+export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
+export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL';
+
+export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
+
+export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+
+defineMessages({
+  mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+});
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+  const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+
+  if (accountIds > 0) {
+    dispatch(fetchRelationships(accountIds));
+  }
+};
+
+const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  html = html.replace(/<br \/>|<br>|\n/g, ' ');
+  wrapper.innerHTML = html;
+  return wrapper.textContent;
+};
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+  return (dispatch, getState) => {
+    const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+    const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+    dispatch({
+      type: NOTIFICATIONS_UPDATE,
+      notification,
+      account: notification.account,
+      status: notification.status,
+      meta: playSound ? { sound: 'boop' } : undefined,
+    });
+
+    fetchRelatedRelationships(dispatch, [notification]);
+
+    // Desktop notifications
+    if (typeof window.Notification !== 'undefined' && showAlert) {
+      const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+      const body  = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
+
+      const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
+      notify.addEventListener('click', () => {
+        window.focus();
+        notify.close();
+      });
+    }
+  };
+};
+
+const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+
+export function refreshNotifications() {
+  return (dispatch, getState) => {
+    const params = {};
+    const ids    = getState().getIn(['notifications', 'items']);
+
+    let skipLoading = false;
+
+    if (ids.size > 0) {
+      params.since_id = ids.first().get('id');
+    }
+
+    if (getState().getIn(['notifications', 'loaded'])) {
+      skipLoading = true;
+    }
+
+    params.exclude_types = excludeTypesFromSettings(getState());
+
+    dispatch(refreshNotificationsRequest(skipLoading));
+
+    api(getState).get('/api/v1/notifications', { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
+      fetchRelatedRelationships(dispatch, response.data);
+    }).catch(error => {
+      dispatch(refreshNotificationsFail(error, skipLoading));
+    });
+  };
+};
+
+export function refreshNotificationsRequest(skipLoading) {
+  return {
+    type: NOTIFICATIONS_REFRESH_REQUEST,
+    skipLoading,
+  };
+};
+
+export function refreshNotificationsSuccess(notifications, skipLoading, next) {
+  return {
+    type: NOTIFICATIONS_REFRESH_SUCCESS,
+    notifications,
+    accounts: notifications.map(item => item.account),
+    statuses: notifications.map(item => item.status).filter(status => !!status),
+    skipLoading,
+    next,
+  };
+};
+
+export function refreshNotificationsFail(error, skipLoading) {
+  return {
+    type: NOTIFICATIONS_REFRESH_FAIL,
+    error,
+    skipLoading,
+  };
+};
+
+export function expandNotifications() {
+  return (dispatch, getState) => {
+    const items  = getState().getIn(['notifications', 'items'], ImmutableList());
+
+    if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+      return;
+    }
+
+    const params = {
+      max_id: items.last().get('id'),
+      limit: 20,
+      exclude_types: excludeTypesFromSettings(getState()),
+    };
+
+    dispatch(expandNotificationsRequest());
+
+    api(getState).get('/api/v1/notifications', { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
+      fetchRelatedRelationships(dispatch, response.data);
+    }).catch(error => {
+      dispatch(expandNotificationsFail(error));
+    });
+  };
+};
+
+export function expandNotificationsRequest() {
+  return {
+    type: NOTIFICATIONS_EXPAND_REQUEST,
+  };
+};
+
+export function expandNotificationsSuccess(notifications, next) {
+  return {
+    type: NOTIFICATIONS_EXPAND_SUCCESS,
+    notifications,
+    accounts: notifications.map(item => item.account),
+    statuses: notifications.map(item => item.status).filter(status => !!status),
+    next,
+  };
+};
+
+export function expandNotificationsFail(error) {
+  return {
+    type: NOTIFICATIONS_EXPAND_FAIL,
+    error,
+  };
+};
+
+export function clearNotifications() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: NOTIFICATIONS_CLEAR,
+    });
+
+    api(getState).post('/api/v1/notifications/clear');
+  };
+};
+
+export function scrollTopNotifications(top) {
+  return {
+    type: NOTIFICATIONS_SCROLL_TOP,
+    top,
+  };
+};
+
+export function deleteMarkedNotifications() {
+  return (dispatch, getState) => {
+    dispatch(deleteMarkedNotificationsRequest());
+
+    let ids = [];
+    getState().getIn(['notifications', 'items']).forEach((n) => {
+      if (n.get('markedForDelete')) {
+        ids.push(n.get('id'));
+      }
+    });
+
+    if (ids.length === 0) {
+      return;
+    }
+
+    api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => {
+      dispatch(deleteMarkedNotificationsSuccess());
+    }).catch(error => {
+      console.error(error);
+      dispatch(deleteMarkedNotificationsFail(error));
+    });
+  };
+};
+
+export function enterNotificationClearingMode(yes) {
+  return {
+    type: NOTIFICATIONS_ENTER_CLEARING_MODE,
+    yes: yes,
+  };
+};
+
+export function markAllNotifications(yes) {
+  return {
+    type: NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+    yes: yes, // true, false or null. null = invert
+  };
+};
+
+export function deleteMarkedNotificationsRequest() {
+  return {
+    type: NOTIFICATIONS_DELETE_MARKED_REQUEST,
+  };
+};
+
+export function deleteMarkedNotificationsFail() {
+  return {
+    type: NOTIFICATIONS_DELETE_MARKED_FAIL,
+  };
+};
+
+export function markNotificationForDelete(id, yes) {
+  return {
+    type: NOTIFICATION_MARK_FOR_DELETE,
+    id: id,
+    yes: yes,
+  };
+};
+
+export function deleteMarkedNotificationsSuccess() {
+  return {
+    type: NOTIFICATIONS_DELETE_MARKED_SUCCESS,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/onboarding.js b/app/javascript/flavours/glitch/actions/onboarding.js
new file mode 100644
index 000000000..a161c50ef
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/onboarding.js
@@ -0,0 +1,14 @@
+import { openModal } from './modal';
+import { changeSetting, saveSettings } from './settings';
+
+export function showOnboardingOnce() {
+  return (dispatch, getState) => {
+    const alreadySeen = getState().getIn(['settings', 'onboarded']);
+
+    if (!alreadySeen) {
+      dispatch(openModal('ONBOARDING'));
+      dispatch(changeSetting(['onboarded'], true));
+      dispatch(saveSettings());
+    }
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/pin_statuses.js b/app/javascript/flavours/glitch/actions/pin_statuses.js
new file mode 100644
index 000000000..d3d1a154f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/pin_statuses.js
@@ -0,0 +1,40 @@
+import api from 'flavours/glitch/util/api';
+
+export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
+export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
+export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
+
+import { me } from 'flavours/glitch/util/initial_state';
+
+export function fetchPinnedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchPinnedStatusesRequest());
+
+    api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+      dispatch(fetchPinnedStatusesSuccess(response.data, null));
+    }).catch(error => {
+      dispatch(fetchPinnedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchPinnedStatusesRequest() {
+  return {
+    type: PINNED_STATUSES_FETCH_REQUEST,
+  };
+};
+
+export function fetchPinnedStatusesSuccess(statuses, next) {
+  return {
+    type: PINNED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next,
+  };
+};
+
+export function fetchPinnedStatusesFail(error) {
+  return {
+    type: PINNED_STATUSES_FETCH_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/index.js b/app/javascript/flavours/glitch/actions/push_notifications/index.js
new file mode 100644
index 000000000..2ffec500a
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/index.js
@@ -0,0 +1,23 @@
+import {
+  SET_BROWSER_SUPPORT,
+  SET_SUBSCRIPTION,
+  CLEAR_SUBSCRIPTION,
+  SET_ALERTS,
+  setAlerts,
+} from './setter';
+import { register, saveSettings } from './registerer';
+
+export {
+  SET_BROWSER_SUPPORT,
+  SET_SUBSCRIPTION,
+  CLEAR_SUBSCRIPTION,
+  SET_ALERTS,
+  register,
+};
+
+export function changeAlerts(path, value) {
+  return dispatch => {
+    dispatch(setAlerts(path, value));
+    dispatch(saveSettings());
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
new file mode 100644
index 000000000..5ad11f73f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
@@ -0,0 +1,149 @@
+import api from 'flavours/glitch/util/api';
+import { pushNotificationsSetting } from 'flavours/glitch/util/settings';
+import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
+
+// Taken from https://www.npmjs.com/package/web-push
+const urlBase64ToUint8Array = (base64String) => {
+  const padding = '='.repeat((4 - base64String.length % 4) % 4);
+  const base64 = (base64String + padding)
+    .replace(/\-/g, '+')
+    .replace(/_/g, '/');
+
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+};
+
+const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
+
+const getRegistration = () => navigator.serviceWorker.ready;
+
+const getPushSubscription = (registration) =>
+  registration.pushManager.getSubscription()
+    .then(subscription => ({ registration, subscription }));
+
+const subscribe = (registration) =>
+  registration.pushManager.subscribe({
+    userVisibleOnly: true,
+    applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
+  });
+
+const unsubscribe = ({ registration, subscription }) =>
+  subscription ? subscription.unsubscribe().then(() => registration) : registration;
+
+const sendSubscriptionToBackend = (getState, subscription, me) => {
+  const params = { subscription };
+
+  if (me) {
+    const data = pushNotificationsSetting.get(me);
+    if (data) {
+      params.data = data;
+    }
+  }
+
+  return api(getState).post('/api/web/push_subscriptions', params).then(response => response.data);
+};
+
+// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
+const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
+
+export function register () {
+  return (dispatch, getState) => {
+    dispatch(setBrowserSupport(supportsPushNotifications));
+    const me = getState().getIn(['meta', 'me']);
+
+    if (me && !pushNotificationsSetting.get(me)) {
+      const alerts = getState().getIn(['push_notifications', 'alerts']);
+      if (alerts) {
+        pushNotificationsSetting.set(me, { alerts: alerts });
+      }
+    }
+
+    if (supportsPushNotifications) {
+      if (!getApplicationServerKey()) {
+        console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
+        return;
+      }
+
+      getRegistration()
+        .then(getPushSubscription)
+        .then(({ registration, subscription }) => {
+          if (subscription !== null) {
+            // We have a subscription, check if it is still valid
+            const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
+            const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
+            const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']);
+
+            // If the VAPID public key did not change and the endpoint corresponds
+            // to the endpoint saved in the backend, the subscription is valid
+            if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
+              return subscription;
+            } else {
+              // Something went wrong, try to subscribe again
+              return unsubscribe({ registration, subscription }).then(subscribe).then(
+                subscription => sendSubscriptionToBackend(getState, subscription, me));
+            }
+          }
+
+          // No subscription, try to subscribe
+          return subscribe(registration).then(
+            subscription => sendSubscriptionToBackend(getState, subscription, me));
+        })
+        .then(subscription => {
+          // If we got a PushSubscription (and not a subscription object from the backend)
+          // it means that the backend subscription is valid (and was set during hydration)
+          if (!(subscription instanceof PushSubscription)) {
+            dispatch(setSubscription(subscription));
+            if (me) {
+              pushNotificationsSetting.set(me, { alerts: subscription.alerts });
+            }
+          }
+        })
+        .catch(error => {
+          if (error.code === 20 && error.name === 'AbortError') {
+            console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
+          } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
+            console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
+          }
+
+          // Clear alerts and hide UI settings
+          dispatch(clearSubscription());
+          if (me) {
+            pushNotificationsSetting.remove(me);
+          }
+
+          try {
+            getRegistration()
+              .then(getPushSubscription)
+              .then(unsubscribe);
+          } catch (e) {
+
+          }
+        });
+    } else {
+      console.warn('Your browser does not support Web Push Notifications.');
+    }
+  };
+}
+
+export function saveSettings() {
+  return (_, getState) => {
+    const state = getState().get('push_notifications');
+    const subscription = state.get('subscription');
+    const alerts = state.get('alerts');
+    const data = { alerts };
+
+    api(getState).put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+      data,
+    }).then(() => {
+      const me = getState().getIn(['meta', 'me']);
+      if (me) {
+        pushNotificationsSetting.set(me, data);
+      }
+    });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/setter.js b/app/javascript/flavours/glitch/actions/push_notifications/setter.js
new file mode 100644
index 000000000..5561766bf
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/setter.js
@@ -0,0 +1,34 @@
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS';
+
+export function setBrowserSupport (value) {
+  return {
+    type: SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setSubscription (subscription) {
+  return {
+    type: SET_SUBSCRIPTION,
+    subscription,
+  };
+}
+
+export function clearSubscription () {
+  return {
+    type: CLEAR_SUBSCRIPTION,
+  };
+}
+
+export function setAlerts (path, value) {
+  return dispatch => {
+    dispatch({
+      type: SET_ALERTS,
+      path,
+      value,
+    });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/reports.js b/app/javascript/flavours/glitch/actions/reports.js
new file mode 100644
index 000000000..ad4fd18a9
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/reports.js
@@ -0,0 +1,80 @@
+import api from 'flavours/glitch/util/api';
+import { openModal, closeModal } from './modal';
+
+export const REPORT_INIT   = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL    = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE  = 'REPORT_STATUS_TOGGLE';
+export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
+
+export function initReport(account, status) {
+  return dispatch => {
+    dispatch({
+      type: REPORT_INIT,
+      account,
+      status,
+    });
+
+    dispatch(openModal('REPORT'));
+  };
+};
+
+export function cancelReport() {
+  return {
+    type: REPORT_CANCEL,
+  };
+};
+
+export function toggleStatusReport(statusId, checked) {
+  return {
+    type: REPORT_STATUS_TOGGLE,
+    statusId,
+    checked,
+  };
+};
+
+export function submitReport() {
+  return (dispatch, getState) => {
+    dispatch(submitReportRequest());
+
+    api(getState).post('/api/v1/reports', {
+      account_id: getState().getIn(['reports', 'new', 'account_id']),
+      status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+      comment: getState().getIn(['reports', 'new', 'comment']),
+    }).then(response => {
+      dispatch(closeModal());
+      dispatch(submitReportSuccess(response.data));
+    }).catch(error => dispatch(submitReportFail(error)));
+  };
+};
+
+export function submitReportRequest() {
+  return {
+    type: REPORT_SUBMIT_REQUEST,
+  };
+};
+
+export function submitReportSuccess(report) {
+  return {
+    type: REPORT_SUBMIT_SUCCESS,
+    report,
+  };
+};
+
+export function submitReportFail(error) {
+  return {
+    type: REPORT_SUBMIT_FAIL,
+    error,
+  };
+};
+
+export function changeReportComment(comment) {
+  return {
+    type: REPORT_COMMENT_CHANGE,
+    comment,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js
new file mode 100644
index 000000000..e86bd848e
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/search.js
@@ -0,0 +1,73 @@
+import api from 'flavours/glitch/util/api';
+
+export const SEARCH_CHANGE = 'SEARCH_CHANGE';
+export const SEARCH_CLEAR  = 'SEARCH_CLEAR';
+export const SEARCH_SHOW   = 'SEARCH_SHOW';
+
+export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
+export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
+export const SEARCH_FETCH_FAIL    = 'SEARCH_FETCH_FAIL';
+
+export function changeSearch(value) {
+  return {
+    type: SEARCH_CHANGE,
+    value,
+  };
+};
+
+export function clearSearch() {
+  return {
+    type: SEARCH_CLEAR,
+  };
+};
+
+export function submitSearch() {
+  return (dispatch, getState) => {
+    const value = getState().getIn(['search', 'value']);
+
+    if (value.length === 0) {
+      return;
+    }
+
+    dispatch(fetchSearchRequest());
+
+    api(getState).get('/api/v1/search', {
+      params: {
+        q: value,
+        resolve: true,
+      },
+    }).then(response => {
+      dispatch(fetchSearchSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchSearchFail(error));
+    });
+  };
+};
+
+export function fetchSearchRequest() {
+  return {
+    type: SEARCH_FETCH_REQUEST,
+  };
+};
+
+export function fetchSearchSuccess(results) {
+  return {
+    type: SEARCH_FETCH_SUCCESS,
+    results,
+    accounts: results.accounts,
+    statuses: results.statuses,
+  };
+};
+
+export function fetchSearchFail(error) {
+  return {
+    type: SEARCH_FETCH_FAIL,
+    error,
+  };
+};
+
+export function showSearch() {
+  return {
+    type: SEARCH_SHOW,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/settings.js b/app/javascript/flavours/glitch/actions/settings.js
new file mode 100644
index 000000000..87b2ae76d
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/settings.js
@@ -0,0 +1,31 @@
+import api from 'flavours/glitch/util/api';
+import { debounce } from 'lodash';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE   = 'SETTING_SAVE';
+
+export function changeSetting(path, value) {
+  return dispatch => {
+    dispatch({
+      type: SETTING_CHANGE,
+      path,
+      value,
+    });
+
+    dispatch(saveSettings());
+  };
+};
+
+const debouncedSave = debounce((dispatch, getState) => {
+  if (getState().getIn(['settings', 'saved'])) {
+    return;
+  }
+
+  const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
+
+  api(getState).put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+}, 5000, { trailing: true });
+
+export function saveSettings() {
+  return (dispatch, getState) => debouncedSave(dispatch, getState);
+};
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
new file mode 100644
index 000000000..8b49083ac
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -0,0 +1,217 @@
+import api from 'flavours/glitch/util/api';
+
+import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
+
+export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
+export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
+export const STATUS_FETCH_FAIL    = 'STATUS_FETCH_FAIL';
+
+export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
+export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
+export const STATUS_DELETE_FAIL    = 'STATUS_DELETE_FAIL';
+
+export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
+export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
+export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
+
+export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
+export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
+export const STATUS_MUTE_FAIL    = 'STATUS_MUTE_FAIL';
+
+export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
+export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
+export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
+
+export function fetchStatusRequest(id, skipLoading) {
+  return {
+    type: STATUS_FETCH_REQUEST,
+    id,
+    skipLoading,
+  };
+};
+
+export function fetchStatus(id) {
+  return (dispatch, getState) => {
+    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+    dispatch(fetchContext(id));
+    dispatch(fetchStatusCard(id));
+
+    if (skipLoading) {
+      return;
+    }
+
+    dispatch(fetchStatusRequest(id, skipLoading));
+
+    api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+      dispatch(fetchStatusSuccess(response.data, skipLoading));
+    }).catch(error => {
+      dispatch(fetchStatusFail(id, error, skipLoading));
+    });
+  };
+};
+
+export function fetchStatusSuccess(status, skipLoading) {
+  return {
+    type: STATUS_FETCH_SUCCESS,
+    status,
+    skipLoading,
+  };
+};
+
+export function fetchStatusFail(id, error, skipLoading) {
+  return {
+    type: STATUS_FETCH_FAIL,
+    id,
+    error,
+    skipLoading,
+    skipAlert: true,
+  };
+};
+
+export function deleteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(deleteStatusRequest(id));
+
+    api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+      dispatch(deleteStatusSuccess(id));
+      dispatch(deleteFromTimelines(id));
+    }).catch(error => {
+      dispatch(deleteStatusFail(id, error));
+    });
+  };
+};
+
+export function deleteStatusRequest(id) {
+  return {
+    type: STATUS_DELETE_REQUEST,
+    id: id,
+  };
+};
+
+export function deleteStatusSuccess(id) {
+  return {
+    type: STATUS_DELETE_SUCCESS,
+    id: id,
+  };
+};
+
+export function deleteStatusFail(id, error) {
+  return {
+    type: STATUS_DELETE_FAIL,
+    id: id,
+    error: error,
+  };
+};
+
+export function fetchContext(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchContextRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+      dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
+    }).catch(error => {
+      if (error.response && error.response.status === 404) {
+        dispatch(deleteFromTimelines(id));
+      }
+
+      dispatch(fetchContextFail(id, error));
+    });
+  };
+};
+
+export function fetchContextRequest(id) {
+  return {
+    type: CONTEXT_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchContextSuccess(id, ancestors, descendants) {
+  return {
+    type: CONTEXT_FETCH_SUCCESS,
+    id,
+    ancestors,
+    descendants,
+    statuses: ancestors.concat(descendants),
+  };
+};
+
+export function fetchContextFail(id, error) {
+  return {
+    type: CONTEXT_FETCH_FAIL,
+    id,
+    error,
+    skipAlert: true,
+  };
+};
+
+export function muteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(muteStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
+      dispatch(muteStatusSuccess(id));
+    }).catch(error => {
+      dispatch(muteStatusFail(id, error));
+    });
+  };
+};
+
+export function muteStatusRequest(id) {
+  return {
+    type: STATUS_MUTE_REQUEST,
+    id,
+  };
+};
+
+export function muteStatusSuccess(id) {
+  return {
+    type: STATUS_MUTE_SUCCESS,
+    id,
+  };
+};
+
+export function muteStatusFail(id, error) {
+  return {
+    type: STATUS_MUTE_FAIL,
+    id,
+    error,
+  };
+};
+
+export function unmuteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
+      dispatch(unmuteStatusSuccess(id));
+    }).catch(error => {
+      dispatch(unmuteStatusFail(id, error));
+    });
+  };
+};
+
+export function unmuteStatusRequest(id) {
+  return {
+    type: STATUS_UNMUTE_REQUEST,
+    id,
+  };
+};
+
+export function unmuteStatusSuccess(id) {
+  return {
+    type: STATUS_UNMUTE_SUCCESS,
+    id,
+  };
+};
+
+export function unmuteStatusFail(id, error) {
+  return {
+    type: STATUS_UNMUTE_FAIL,
+    id,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js
new file mode 100644
index 000000000..a1db0fdd5
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/store.js
@@ -0,0 +1,17 @@
+import { Iterable, fromJS } from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
+
+const convertState = rawState =>
+  fromJS(rawState, (k, v) =>
+    Iterable.isIndexed(v) ? v.toList() : v.toMap());
+
+export function hydrateStore(rawState) {
+  const state = convertState(rawState);
+
+  return {
+    type: STORE_HYDRATE,
+    state,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
new file mode 100644
index 000000000..ae51e8349
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -0,0 +1,55 @@
+import { connectStream } from 'flavours/glitch/util/stream';
+import {
+  updateTimeline,
+  deleteFromTimelines,
+  refreshHomeTimeline,
+  connectTimeline,
+  disconnectTimeline,
+} from './timelines';
+import { updateNotifications, refreshNotifications } from './notifications';
+import { getLocale } from 'mastodon/locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
+
+  return connectStream (path, pollingRefresh, (dispatch, getState) => {
+    const locale = getState().getIn(['meta', 'locale']);
+    return {
+      onConnect() {
+        dispatch(connectTimeline(timelineId));
+      },
+
+      onDisconnect() {
+        dispatch(disconnectTimeline(timelineId));
+      },
+
+      onReceive (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+          break;
+        }
+      },
+    };
+  });
+}
+
+function refreshHomeTimelineAndNotification (dispatch) {
+  dispatch(refreshHomeTimeline());
+  dispatch(refreshNotifications());
+}
+
+export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
+export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
+export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
+export const connectPublicStream = () => connectTimelineStream('public', 'public');
+export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
+export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
+export const connectListStream = (id) => connectTimelineStream(`list:${id}`, `list&list=${id}`);
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
new file mode 100644
index 000000000..9a5b2e6da
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -0,0 +1,210 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
+
+export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
+export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
+export const TIMELINE_REFRESH_FAIL    = 'TIMELINE_REFRESH_FAIL';
+
+export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
+export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
+export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
+
+export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+
+export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
+
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
+  return {
+    type: TIMELINE_REFRESH_SUCCESS,
+    timeline,
+    statuses,
+    skipLoading,
+    next,
+  };
+};
+
+export function updateTimeline(timeline, status) {
+  return (dispatch, getState) => {
+    const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+    const parents = [];
+
+    if (status.in_reply_to_id) {
+      let parent = getState().getIn(['statuses', status.in_reply_to_id]);
+
+      while (parent && parent.get('in_reply_to_id')) {
+        parents.push(parent.get('id'));
+        parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
+      }
+    }
+
+    dispatch({
+      type: TIMELINE_UPDATE,
+      timeline,
+      status,
+      references,
+    });
+
+    if (parents.length > 0) {
+      dispatch({
+        type: TIMELINE_CONTEXT_UPDATE,
+        status,
+        references: parents,
+      });
+    }
+  };
+};
+
+export function deleteFromTimelines(id) {
+  return (dispatch, getState) => {
+    const accountId  = getState().getIn(['statuses', id, 'account']);
+    const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+    const reblogOf   = getState().getIn(['statuses', id, 'reblog'], null);
+
+    dispatch({
+      type: TIMELINE_DELETE,
+      id,
+      accountId,
+      references,
+      reblogOf,
+    });
+  };
+};
+
+export function refreshTimelineRequest(timeline, skipLoading) {
+  return {
+    type: TIMELINE_REFRESH_REQUEST,
+    timeline,
+    skipLoading,
+  };
+};
+
+export function refreshTimeline(timelineId, path, params = {}) {
+  return function (dispatch, getState) {
+    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+
+    if (timeline.get('isLoading') || timeline.get('online')) {
+      return;
+    }
+
+    const ids      = timeline.get('items', ImmutableList());
+    const newestId = ids.size > 0 ? ids.first() : null;
+
+    let skipLoading = timeline.get('loaded');
+
+    if (newestId !== null) {
+      params.since_id = newestId;
+    }
+
+    dispatch(refreshTimelineRequest(timelineId, skipLoading));
+
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(refreshTimelineFail(timelineId, error, skipLoading));
+    });
+  };
+};
+
+export const refreshHomeTimeline         = () => refreshTimeline('home', '/api/v1/timelines/home');
+export const refreshPublicTimeline       = () => refreshTimeline('public', '/api/v1/timelines/public');
+export const refreshCommunityTimeline    = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
+export const refreshDirectTimeline       = () => refreshTimeline('direct', '/api/v1/timelines/direct');
+export const refreshAccountTimeline      = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const refreshHashtagTimeline      = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+export const refreshListTimeline         = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
+
+export function refreshTimelineFail(timeline, error, skipLoading) {
+  return {
+    type: TIMELINE_REFRESH_FAIL,
+    timeline,
+    error,
+    skipLoading,
+    skipAlert: error.response && error.response.status === 404,
+  };
+};
+
+export function expandTimeline(timelineId, path, params = {}) {
+  return (dispatch, getState) => {
+    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+    const ids      = timeline.get('items', ImmutableList());
+
+    if (timeline.get('isLoading') || ids.size === 0) {
+      return;
+    }
+
+    params.max_id = ids.last();
+    params.limit  = 10;
+
+    dispatch(expandTimelineRequest(timelineId));
+
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandTimelineFail(timelineId, error));
+    });
+  };
+};
+
+export const expandHomeTimeline         = () => expandTimeline('home', '/api/v1/timelines/home');
+export const expandPublicTimeline       = () => expandTimeline('public', '/api/v1/timelines/public');
+export const expandCommunityTimeline    = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
+export const expandDirectTimeline       = () => expandTimeline('direct', '/api/v1/timelines/direct');
+export const expandAccountTimeline      = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const expandHashtagTimeline      = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+export const expandListTimeline         = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
+
+export function expandTimelineRequest(timeline) {
+  return {
+    type: TIMELINE_EXPAND_REQUEST,
+    timeline,
+  };
+};
+
+export function expandTimelineSuccess(timeline, statuses, next) {
+  return {
+    type: TIMELINE_EXPAND_SUCCESS,
+    timeline,
+    statuses,
+    next,
+  };
+};
+
+export function expandTimelineFail(timeline, error) {
+  return {
+    type: TIMELINE_EXPAND_FAIL,
+    timeline,
+    error,
+  };
+};
+
+export function scrollTopTimeline(timeline, top) {
+  return {
+    type: TIMELINE_SCROLL_TOP,
+    timeline,
+    top,
+  };
+};
+
+export function connectTimeline(timeline) {
+  return {
+    type: TIMELINE_CONNECT,
+    timeline,
+  };
+};
+
+export function disconnectTimeline(timeline) {
+  return {
+    type: TIMELINE_DISCONNECT,
+    timeline,
+  };
+};
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
new file mode 100644
index 000000000..df17f1897
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -0,0 +1,141 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import DisplayName from './display_name';
+import Permalink from './permalink';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'flavours/glitch/util/initial_state';
+
+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}' },
+  mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' },
+  unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' },
+});
+
+@injectIntl
+export default class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onMuteNotifications: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hidden: PropTypes.bool,
+    small: PropTypes.bool,
+  };
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  }
+
+  handleMuteNotifications = () => {
+    this.props.onMuteNotifications(this.props.account, true);
+  }
+
+  handleUnmuteNotifications = () => {
+    this.props.onMuteNotifications(this.props.account, false);
+  }
+
+  render () {
+    const {
+      account,
+      hidden,
+      intl,
+      small,
+    } = this.props;
+
+    if (!account) {
+      return <div />;
+    }
+
+    if (hidden) {
+      return (
+        <div>
+          {account.get('display_name')}
+          {account.get('username')}
+        </div>
+      );
+    }
+
+    let buttons;
+
+    if (account.get('id') !== me && !small && 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-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
+      } else if (muting) {
+        let hidingNotificationsButton;
+        if (account.getIn(['relationship', 'muting_notifications'])) {
+          hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
+        } else {
+          hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username')  })} onClick={this.handleMuteNotifications} />;
+        }
+        buttons = (
+          <Fragment>
+            <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
+            {hidingNotificationsButton}
+          </Fragment>
+        );
+      } else if (!account.get('moved')) {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
+    }
+
+    return small ? (
+      <Permalink
+        className='account small'
+        href={account.get('url')}
+        to={`/accounts/${account.get('id')}`}
+      >
+        <div className='account__avatar-wrapper'>
+          <Avatar
+            account={account}
+            size={24}
+          />
+        </div>
+        <DisplayName
+          account={account}
+          inline
+        />
+      </Permalink>
+    ) : (
+      <div className='account'>
+        <div className='account__wrapper'>
+          <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            <DisplayName account={account} />
+          </Permalink>
+          {buttons ?
+            <div className='account__relationship'>
+              {buttons}
+            </div>
+            : null}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/attachment_list.js b/app/javascript/flavours/glitch/components/attachment_list.js
new file mode 100644
index 000000000..3a28c70f3
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/attachment_list.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+export default class AttachmentList extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { media } = this.props;
+
+    return (
+      <div className='attachment-list'>
+        <div className='attachment-list__icon'>
+          <i className='fa fa-link' />
+        </div>
+
+        <ul className='attachment-list__list'>
+          {media.map(attachment =>
+            (<li key={attachment.get('id')}>
+              <a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
+            </li>)
+          )}
+        </ul>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/avatar.js b/app/javascript/flavours/glitch/components/avatar.js
new file mode 100644
index 000000000..c5e9072c4
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/avatar.js
@@ -0,0 +1,77 @@
+import classNames from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+
+export default class Avatar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    className: PropTypes.string,
+    size: PropTypes.number.isRequired,
+    style: PropTypes.object,
+    inline: PropTypes.bool,
+    animate: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    animate: autoPlayGif,
+    size: 20,
+    inline: false,
+  };
+
+  state = {
+    hovering: false,
+  };
+
+  handleMouseEnter = () => {
+    if (this.props.animate) return;
+    this.setState({ hovering: true });
+  }
+
+  handleMouseLeave = () => {
+    if (this.props.animate) return;
+    this.setState({ hovering: false });
+  }
+
+  render () {
+    const {
+      account,
+      animate,
+      className,
+      inline,
+      size,
+    } = this.props;
+    const { hovering } = this.state;
+
+    const src = account.get('avatar');
+    const staticSrc = account.get('avatar_static');
+
+    const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
+
+    const style = {
+      ...this.props.style,
+      width: `${size}px`,
+      height: `${size}px`,
+      backgroundSize: `${size}px ${size}px`,
+    };
+
+    if (hovering || animate) {
+      style.backgroundImage = `url(${src})`;
+    } else {
+      style.backgroundImage = `url(${staticSrc})`;
+    }
+
+    return (
+      <div
+        className={computedClass}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        style={style}
+        data-avatar-of={`@${account.get('acct')}`}
+      />
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.js b/app/javascript/flavours/glitch/components/avatar_overlay.js
new file mode 100644
index 000000000..23db5182b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/avatar_overlay.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+
+export default class AvatarOverlay extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    friend: ImmutablePropTypes.map.isRequired,
+    animate: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    animate: autoPlayGif,
+  };
+
+  render() {
+    const { account, friend, animate } = this.props;
+
+    const baseStyle = {
+      backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
+    };
+
+    const overlayStyle = {
+      backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`,
+    };
+
+    return (
+      <div className='account__avatar-overlay'>
+        <div className='account__avatar-overlay-base' style={baseStyle} data-avatar-of={`@${account.get('acct')}`} />
+        <div className='account__avatar-overlay-overlay' style={overlayStyle} data-avatar-of={`@${friend.get('acct')}`} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/button.js b/app/javascript/flavours/glitch/components/button.js
new file mode 100644
index 000000000..16868010c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/button.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class Button extends React.PureComponent {
+
+  static propTypes = {
+    text: PropTypes.node,
+    onClick: PropTypes.func,
+    disabled: PropTypes.bool,
+    block: PropTypes.bool,
+    secondary: PropTypes.bool,
+    size: PropTypes.number,
+    className: PropTypes.string,
+    style: PropTypes.object,
+    children: PropTypes.node,
+    title: PropTypes.string,
+  };
+
+  static defaultProps = {
+    size: 36,
+  };
+
+  handleClick = (e) => {
+    if (!this.props.disabled) {
+      this.props.onClick(e);
+    }
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  focus() {
+    this.node.focus();
+  }
+
+  render () {
+    let attrs = {
+      className: classNames('button', this.props.className, {
+        'button-secondary': this.props.secondary,
+        'button--block': this.props.block,
+      }),
+      disabled: this.props.disabled,
+      onClick: this.handleClick,
+      ref: this.setRef,
+      style: {
+        padding: `0 ${this.props.size / 2.25}px`,
+        height: `${this.props.size}px`,
+        lineHeight: `${this.props.size}px`,
+        ...this.props.style,
+      },
+    };
+
+    if (this.props.title) attrs.title = this.props.title;
+
+    return (
+      <button {...attrs}>
+        {this.props.text || this.props.children}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/collapsable.js b/app/javascript/flavours/glitch/components/collapsable.js
new file mode 100644
index 000000000..0e8b04033
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/collapsable.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+
+const Collapsable = ({ fullHeight, isVisible, children }) => (
+  <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
+    {({ opacity, height }) =>
+      (<div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
+        {children}
+      </div>)
+    }
+  </Motion>
+);
+
+Collapsable.propTypes = {
+  fullHeight: PropTypes.number.isRequired,
+  isVisible: PropTypes.bool.isRequired,
+  children: PropTypes.node.isRequired,
+};
+
+export default Collapsable;
diff --git a/app/javascript/flavours/glitch/components/column.js b/app/javascript/flavours/glitch/components/column.js
new file mode 100644
index 000000000..57c4c7a40
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollTop } from 'flavours/glitch/util/scroll';
+
+export default class Column extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+    extraClasses: PropTypes.string,
+    name: PropTypes.string,
+  };
+
+  scrollTop () {
+    const scrollable = this.node.querySelector('.scrollable');
+
+    if (!scrollable) {
+      return;
+    }
+
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+  handleWheel = () => {
+    if (typeof this._interruptScrollAnimation !== 'function') {
+      return;
+    }
+
+    this._interruptScrollAnimation();
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidMount () {
+    this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+  }
+
+  componentWillUnmount () {
+    this.node.removeEventListener('wheel', this.handleWheel);
+  }
+
+  render () {
+    const { children, extraClasses, name } = this.props;
+
+    return (
+      <div role='region' data-column={name} className={`column ${extraClasses || ''}`} ref={this.setRef}>
+        {children}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/column_back_button.js b/app/javascript/flavours/glitch/components/column_back_button.js
new file mode 100644
index 000000000..50c3bf11f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column_back_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButton extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  handleClick = () => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  render () {
+    return (
+      <button onClick={this.handleClick} className='column-back-button'>
+        <i className='fa fa-fw fa-chevron-left column-back-button__icon' />
+        <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.js b/app/javascript/flavours/glitch/components/column_back_button_slim.js
new file mode 100644
index 000000000..2cdf1b25b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column_back_button_slim.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButtonSlim extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  handleClick = () => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  render () {
+    return (
+      <div className='column-back-button--slim'>
+        <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
+          <i className='fa fa-fw fa-chevron-left column-back-button__icon' />
+          <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
new file mode 100644
index 000000000..ae90b6f81
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -0,0 +1,213 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
+
+const messages = defineMessages({
+  show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+  hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+  moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
+  moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
+  enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
+});
+
+@injectIntl
+export default class ColumnHeader extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    title: PropTypes.node.isRequired,
+    icon: PropTypes.string.isRequired,
+    active: PropTypes.bool,
+    localSettings : ImmutablePropTypes.map,
+    multiColumn: PropTypes.bool,
+    focusable: PropTypes.bool,
+    showBackButton: PropTypes.bool,
+    notifCleaning: PropTypes.bool, // true only for the notification column
+    notifCleaningActive: PropTypes.bool,
+    onEnterCleaningMode: PropTypes.func,
+    children: PropTypes.node,
+    pinned: PropTypes.bool,
+    onPin: PropTypes.func,
+    onMove: PropTypes.func,
+    onClick: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  static defaultProps = {
+    focusable: true,
+  }
+
+  state = {
+    collapsed: true,
+    animating: false,
+    animatingNCD: false,
+  };
+
+  handleToggleClick = (e) => {
+    e.stopPropagation();
+    this.setState({ collapsed: !this.state.collapsed, animating: true });
+  }
+
+  handleTitleClick = () => {
+    this.props.onClick();
+  }
+
+  handleMoveLeft = () => {
+    this.props.onMove(-1);
+  }
+
+  handleMoveRight = () => {
+    this.props.onMove(1);
+  }
+
+  handleBackClick = () => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  handleTransitionEnd = () => {
+    this.setState({ animating: false });
+  }
+
+  handleTransitionEndNCD = () => {
+    this.setState({ animatingNCD: false });
+  }
+
+  onEnterCleaningMode = () => {
+    this.setState({ animatingNCD: true });
+    this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
+  }
+
+  render () {
+    const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props;
+    const { collapsed, animating, animatingNCD } = this.state;
+
+    let title = this.props.title;
+
+    const wrapperClassName = classNames('column-header__wrapper', {
+      'active': active,
+    });
+
+    const buttonClassName = classNames('column-header', {
+      'active': active,
+    });
+
+    const collapsibleClassName = classNames('column-header__collapsible', {
+      'collapsed': collapsed,
+      'animating': animating,
+    });
+
+    const collapsibleButtonClassName = classNames('column-header__button', {
+      'active': !collapsed,
+    });
+
+    const notifCleaningButtonClassName = classNames('column-header__button', {
+      'active': notifCleaningActive,
+    });
+
+    const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
+      'collapsed': !notifCleaningActive,
+      'animating': animatingNCD,
+    });
+
+    let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+    //*glitch
+    const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
+
+    if (children) {
+      extraContent = (
+        <div key='extra-content' className='column-header__collapsible__extra'>
+          {children}
+        </div>
+      );
+    }
+
+    if (multiColumn && pinned) {
+      pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
+
+      moveButtons = (
+        <div key='move-buttons' className='column-header__setting-arrows'>
+          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button>
+          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button>
+        </div>
+      );
+    } else if (multiColumn) {
+      pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
+    }
+
+    if (!pinned && (multiColumn || showBackButton)) {
+      backButton = (
+        <button onClick={this.handleBackClick} className='column-header__back-button'>
+          <i className='fa fa-fw fa-chevron-left column-back-button__icon' />
+          <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+        </button>
+      );
+    }
+
+    const collapsedContent = [
+      extraContent,
+    ];
+
+    if (multiColumn) {
+      collapsedContent.push(moveButtons);
+      collapsedContent.push(pinButton);
+    }
+
+    if (children || multiColumn) {
+      collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>;
+    }
+
+    return (
+      <div className={wrapperClassName}>
+        <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
+          <i className={`fa fa-fw fa-${icon} column-header__icon`} />
+          <span className='column-header__title'>
+            {title}
+          </span>
+          <div className='column-header__buttons'>
+            {backButton}
+            { notifCleaning ? (
+              <button
+                aria-label={msgEnterNotifCleaning}
+                title={msgEnterNotifCleaning}
+                onClick={this.onEnterCleaningMode}
+                className={notifCleaningButtonClassName}
+              >
+                <i className='fa fa-eraser' />
+              </button>
+            ) : null}
+            {collapseButton}
+          </div>
+        </h1>
+
+        { notifCleaning ? (
+          <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
+            <div className='column-header__collapsible-inner nopad-drawer'>
+              {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
+            </div>
+          </div>
+        ) : null}
+
+        <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
+          <div className='column-header__collapsible-inner'>
+            {(!collapsed || animating) && collapsedContent}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
new file mode 100644
index 000000000..4c65aaefa
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -0,0 +1,30 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  The component.
+export default function DisplayName ({
+  account,
+  className,
+  inline,
+}) {
+  const computedClass = classNames('display-name', { inline }, className);
+
+  //  The result.
+  return account ? (
+    <span className={computedClass}>
+      <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
+      {inline ? ' ' : null}
+      <span className='display-name__account'>@{account.get('acct')}</span>
+    </span>
+  ) : null;
+}
+
+//  Props.
+DisplayName.propTypes = {
+  account: ImmutablePropTypes.map,
+  className: PropTypes.string,
+  inline: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
new file mode 100644
index 000000000..7ba7fb22b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -0,0 +1,215 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from './icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class DropdownMenu extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    items: PropTypes.array.isRequired,
+    onClose: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    placement: PropTypes.string,
+    arrowOffsetLeft: PropTypes.string,
+    arrowOffsetTop: PropTypes.string,
+  };
+
+  static defaultProps = {
+    style: {},
+    placement: 'bottom',
+  };
+
+  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;
+  }
+
+  handleClick = e => {
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+    const { action, to } = this.props.items[i];
+
+    this.props.onClose();
+
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+    } else if (to) {
+      e.preventDefault();
+      this.context.router.history.push(to);
+    }
+  }
+
+  renderItem (option, i) {
+    if (option === null) {
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
+    }
+
+    const { text, href = '#' } = option;
+
+    return (
+      <li className='dropdown-menu__item' key={`${text}-${i}`}>
+        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
+          {text}
+        </a>
+      </li>
+    );
+  }
+
+  render () {
+    const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = 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='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
+            <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
+
+            <ul>
+              {items.map((option, i) => this.renderItem(option, i))}
+            </ul>
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+export default class Dropdown extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    icon: PropTypes.string.isRequired,
+    items: PropTypes.array.isRequired,
+    size: PropTypes.number.isRequired,
+    ariaLabel: PropTypes.string,
+    disabled: PropTypes.bool,
+    status: ImmutablePropTypes.map,
+    isUserTouching: PropTypes.func,
+    isModalOpen: PropTypes.bool.isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+  };
+
+  static defaultProps = {
+    ariaLabel: 'Menu',
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  handleClick = () => {
+    if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
+      const { status, items } = this.props;
+
+      this.props.onModalOpen({
+        status,
+        actions: items.map(
+          (item, i) => item ? {
+            ...item,
+            name: `${item.text}-${i}`,
+            onClick: this.handleItemClick.bind(this, i),
+          } : null
+        ),
+      });
+
+      return;
+    }
+
+    this.setState({ expanded: !this.state.expanded });
+  }
+
+  handleClose = () => {
+    if (this.props.onModalClose) {
+      this.props.onModalClose();
+    }
+
+    this.setState({ expanded: false });
+  }
+
+  handleKeyDown = e => {
+    switch(e.key) {
+    case 'Enter':
+      this.handleClick();
+      break;
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  }
+
+  handleItemClick = (i, e) => {
+    const { action, to } = this.props.items[i];
+
+    this.handleClose();
+
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+    } else if (to) {
+      e.preventDefault();
+      this.context.router.history.push(to);
+    }
+  }
+
+  setTargetRef = c => {
+    this.target = c;
+  }
+
+  findTarget = () => {
+    return this.target;
+  }
+
+  render () {
+    const { icon, items, size, ariaLabel, disabled } = this.props;
+    const { expanded } = this.state;
+
+    return (
+      <div onKeyDown={this.handleKeyDown}>
+        <IconButton
+          icon={icon}
+          title={ariaLabel}
+          active={expanded}
+          disabled={disabled}
+          size={size}
+          ref={this.setTargetRef}
+          onClick={this.handleClick}
+        />
+
+        <Overlay show={expanded} placement='bottom' target={this.findTarget}>
+          <DropdownMenu items={items} onClose={this.handleClose} />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/extended_video_player.js b/app/javascript/flavours/glitch/components/extended_video_player.js
new file mode 100644
index 000000000..f8bd067e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/extended_video_player.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ExtendedVideoPlayer extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    time: PropTypes.number,
+    controls: PropTypes.bool.isRequired,
+    muted: PropTypes.bool.isRequired,
+  };
+
+  handleLoadedData = () => {
+    if (this.props.time) {
+      this.video.currentTime = this.props.time;
+    }
+  }
+
+  componentDidMount () {
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  }
+
+  componentWillUnmount () {
+    this.video.removeEventListener('loadeddata', this.handleLoadedData);
+  }
+
+  setRef = (c) => {
+    this.video = c;
+  }
+
+  render () {
+    const { src, muted, controls, alt } = this.props;
+
+    return (
+      <div className='extended-video-player'>
+        <video
+          ref={this.setRef}
+          src={src}
+          autoPlay
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          muted={muted}
+          controls={controls}
+          loop={!controls}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js
new file mode 100644
index 000000000..8f55a0115
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon.js
@@ -0,0 +1,26 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  This just renders a FontAwesome icon.
+export default function Icon ({
+  className,
+  fullwidth,
+  icon,
+}) {
+  const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className);
+  return icon ? (
+    <span
+      aria-hidden='true'
+      className={computedClass}
+    />
+  ) : null;
+}
+
+//  Props.
+Icon.propTypes = {
+  className: PropTypes.string,
+  fullwidth: PropTypes.bool,
+  icon: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js
new file mode 100644
index 000000000..dfbe75110
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon_button.js
@@ -0,0 +1,137 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class IconButton extends React.PureComponent {
+
+  static propTypes = {
+    className: PropTypes.string,
+    title: PropTypes.string.isRequired,
+    icon: PropTypes.string.isRequired,
+    onClick: PropTypes.func,
+    size: PropTypes.number,
+    active: PropTypes.bool,
+    pressed: PropTypes.bool,
+    expanded: PropTypes.bool,
+    style: PropTypes.object,
+    activeStyle: PropTypes.object,
+    disabled: PropTypes.bool,
+    inverted: PropTypes.bool,
+    animate: PropTypes.bool,
+    flip: PropTypes.bool,
+    overlay: PropTypes.bool,
+    tabIndex: PropTypes.string,
+    label: PropTypes.string,
+  };
+
+  static defaultProps = {
+    size: 18,
+    active: false,
+    disabled: false,
+    animate: false,
+    overlay: false,
+    tabIndex: '0',
+  };
+
+  handleClick = (e) =>  {
+    e.preventDefault();
+
+    if (!this.props.disabled) {
+      this.props.onClick(e);
+    }
+  }
+
+  render () {
+    let style = {
+      fontSize: `${this.props.size}px`,
+      height: `${this.props.size * 1.28571429}px`,
+      lineHeight: `${this.props.size}px`,
+      ...this.props.style,
+      ...(this.props.active ? this.props.activeStyle : {}),
+    };
+    if (!this.props.label) {
+      style.width = `${this.props.size * 1.28571429}px`;
+    } else {
+      style.textAlign = 'left';
+    }
+
+    const {
+      active,
+      animate,
+      className,
+      disabled,
+      expanded,
+      icon,
+      inverted,
+      flip,
+      overlay,
+      pressed,
+      tabIndex,
+      title,
+    } = this.props;
+
+    const classes = classNames(className, 'icon-button', {
+      active,
+      disabled,
+      inverted,
+      overlayed: overlay,
+    });
+
+    const flipDeg = flip ? -180 : -360;
+    const rotateDeg = active ? flipDeg : 0;
+
+    const motionDefaultStyle = {
+      rotate: rotateDeg,
+    };
+
+    const springOpts = {
+      stiffness: this.props.flip ? 60 : 120,
+      damping: 7,
+    };
+    const motionStyle = {
+      rotate: animate ? spring(rotateDeg, springOpts) : 0,
+    };
+
+    if (!animate) {
+      // Perf optimization: avoid unnecessary <Motion> components unless
+      // we actually need to animate.
+      return (
+        <button
+          aria-label={title}
+          aria-pressed={pressed}
+          aria-expanded={expanded}
+          title={title}
+          className={classes}
+          onClick={this.handleClick}
+          style={style}
+          tabIndex={tabIndex}
+        >
+          <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
+        </button>
+      );
+    }
+
+    return (
+      <Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
+        {({ rotate }) =>
+          (<button
+            aria-label={title}
+            aria-pressed={pressed}
+            aria-expanded={expanded}
+            title={title}
+            className={classes}
+            onClick={this.handleClick}
+            style={style}
+            tabIndex={tabIndex}
+          >
+            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
+            {this.props.label}
+          </button>)
+        }
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/intersection_observer_article.js b/app/javascript/flavours/glitch/components/intersection_observer_article.js
new file mode 100644
index 000000000..8b06f9a8c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/intersection_observer_article.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
+import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry';
+import { is } from 'immutable';
+
+// Diff these props in the "rendered" state
+const updateOnPropsForRendered = ['id', 'index', 'listLength'];
+// Diff these props in the "unrendered" state
+const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
+
+export default class IntersectionObserverArticle extends React.Component {
+
+  static propTypes = {
+    intersectionObserverWrapper: PropTypes.object.isRequired,
+    id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    saveHeightKey: PropTypes.string,
+    cachedHeight: PropTypes.number,
+    onHeightChange: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  state = {
+    isHidden: false, // set to true in requestIdleCallback to trigger un-render
+  }
+
+  shouldComponentUpdate (nextProps, nextState) {
+    const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
+    const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
+    if (!!isUnrendered !== !!willBeUnrendered) {
+      // If we're going from rendered to unrendered (or vice versa) then update
+      return true;
+    }
+    // Otherwise, diff based on props
+    const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
+    return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
+  }
+
+  componentDidMount () {
+    const { intersectionObserverWrapper, id } = this.props;
+
+    intersectionObserverWrapper.observe(
+      id,
+      this.node,
+      this.handleIntersection
+    );
+
+    this.componentMounted = true;
+  }
+
+  componentWillUnmount () {
+    const { intersectionObserverWrapper, id } = this.props;
+    intersectionObserverWrapper.unobserve(id, this.node);
+
+    this.componentMounted = false;
+  }
+
+  handleIntersection = (entry) => {
+    this.entry = entry;
+
+    scheduleIdleTask(this.calculateHeight);
+    this.setState(this.updateStateAfterIntersection);
+  }
+
+  updateStateAfterIntersection = (prevState) => {
+    if (prevState.isIntersecting && !this.entry.isIntersecting) {
+      scheduleIdleTask(this.hideIfNotIntersecting);
+    }
+    return {
+      isIntersecting: this.entry.isIntersecting,
+      isHidden: false,
+    };
+  }
+
+  calculateHeight = () => {
+    const { onHeightChange, saveHeightKey, id } = this.props;
+    // save the height of the fully-rendered element (this is expensive
+    // on Chrome, where we need to fall back to getBoundingClientRect)
+    this.height = getRectFromEntry(this.entry).height;
+
+    if (onHeightChange && saveHeightKey) {
+      onHeightChange(saveHeightKey, id, this.height);
+    }
+  }
+
+  hideIfNotIntersecting = () => {
+    if (!this.componentMounted) {
+      return;
+    }
+
+    // When the browser gets a chance, test if we're still not intersecting,
+    // and if so, set our isHidden to true to trigger an unrender. The point of
+    // this is to save DOM nodes and avoid using up too much memory.
+    // See: https://github.com/tootsuite/mastodon/issues/2900
+    this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+  }
+
+  handleRef = (node) => {
+    this.node = node;
+  }
+
+  render () {
+    const { children, id, index, listLength, cachedHeight } = this.props;
+    const { isIntersecting, isHidden } = this.state;
+
+    if (!isIntersecting && (isHidden || cachedHeight)) {
+      return (
+        <article
+          ref={this.handleRef}
+          aria-posinset={index}
+          aria-setsize={listLength}
+          style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
+          data-id={id}
+          tabIndex='0'
+        >
+          {children && React.cloneElement(children, { hidden: true })}
+        </article>
+      );
+    }
+
+    return (
+      <article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'>
+        {children && React.cloneElement(children, { hidden: false })}
+      </article>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/link.js b/app/javascript/flavours/glitch/components/link.js
new file mode 100644
index 000000000..de99f7d42
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/link.js
@@ -0,0 +1,97 @@
+//  Inspired by <CommonLink> from Mastodon GO!
+//  ~ 😘 kibi!
+
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  We don't handle clicks that are made with modifiers, since these
+  //  often have special browser meanings (eg, "open in new tab").
+  click (e) {
+    const { onClick } = this.props;
+    if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
+      return;
+    }
+    onClick(e);
+    e.preventDefault();  //  Prevents following of the link
+  },
+};
+
+//  The component.
+export default class Link extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { click } = this.handlers;
+    const {
+      children,
+      className,
+      href,
+      onClick,
+      role,
+      title,
+      ...rest
+    } = this.props;
+    const computedClass = classNames('link', className, `role-${role}`);
+
+    //  We assume that our `onClick` is a routing function and give it
+    //  the qualities of a link even if no `href` is provided. However,
+    //  if we have neither an `onClick` or an `href`, our link is
+    //  purely presentational.
+    const conditionalProps = {};
+    if (href) {
+      conditionalProps.href = href;
+      conditionalProps.onClick = click;
+    } else if (onClick) {
+      conditionalProps.onClick = click;
+      conditionalProps.role = 'link';
+      conditionalProps.tabIndex = 0;
+    } else {
+      conditionalProps.role = 'presentation';
+    }
+
+    //  If we were provided a `role` it overwrites any that we may have
+    //  set above.  This can be used for "links" which are actually
+    //  buttons.
+    if (role) {
+      conditionalProps.role = role;
+    }
+
+    //  Rendering.  We set `rel='noopener'` for user privacy, and our
+    //  `target` as `'_blank'`.
+    return (
+      <a
+        className={computedClass}
+        {...conditionalProps}
+        rel='noopener'
+        target='_blank'
+        title={title}
+        {...rest}
+      >{children}</a>
+    );
+  }
+
+}
+
+//  Props.
+Link.propTypes = {
+  children: PropTypes.node,
+  className: PropTypes.string,
+  href: PropTypes.string,  //  The link destination
+  onClick: PropTypes.func,  //  A function to call instead of opening the link
+  role: PropTypes.string,  //  An ARIA role for the link
+  title: PropTypes.string,  //  A title for the link
+};
diff --git a/app/javascript/flavours/glitch/components/load_more.js b/app/javascript/flavours/glitch/components/load_more.js
new file mode 100644
index 000000000..c4c8c94a2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/load_more.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class LoadMore extends React.PureComponent {
+
+  static propTypes = {
+    onClick: PropTypes.func,
+    visible: PropTypes.bool,
+  }
+
+  static defaultProps = {
+    visible: true,
+  }
+
+  render() {
+    const { visible } = this.props;
+
+    return (
+      <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
+        <FormattedMessage id='status.load_more' defaultMessage='Load more' />
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/loading_indicator.js b/app/javascript/flavours/glitch/components/loading_indicator.js
new file mode 100644
index 000000000..d6a5adb6f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/loading_indicator.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const LoadingIndicator = () => (
+  <div className='loading-indicator'>
+    <div className='loading-indicator__figure' />
+    <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
+  </div>
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
new file mode 100644
index 000000000..6928af6d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -0,0 +1,305 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { is } from 'immutable';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from 'flavours/glitch/util/is_mobile';
+import classNames from 'classnames';
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+
+const messages = defineMessages({
+  hidden: {
+    defaultMessage: 'Media hidden',
+    id: 'status.media_hidden',
+  },
+  sensitive: {
+    defaultMessage: 'Sensitive',
+    id: 'media_gallery.sensitive',
+  },
+  toggle: {
+    defaultMessage: 'Click to view',
+    id: 'status.sensitive_toggle',
+  },
+  toggle_visible: {
+    defaultMessage: 'Toggle visibility',
+    id: 'media_gallery.toggle_visible',
+  },
+  warning: {
+    defaultMessage: 'Sensitive content',
+    id: 'status.sensitive_warning',
+  },
+});
+
+class Item extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    attachment: ImmutablePropTypes.map.isRequired,
+    standalone: PropTypes.bool,
+    index: PropTypes.number.isRequired,
+    size: PropTypes.number.isRequired,
+    letterbox: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    standalone: false,
+    index: 0,
+    size: 1,
+  };
+
+  handleMouseEnter = (e) => {
+    if (this.hoverToPlay()) {
+      e.target.play();
+    }
+  }
+
+  handleMouseLeave = (e) => {
+    if (this.hoverToPlay()) {
+      e.target.pause();
+      e.target.currentTime = 0;
+    }
+  }
+
+  hoverToPlay () {
+    const { attachment } = this.props;
+    return !autoPlayGif && attachment.get('type') === 'gifv';
+  }
+
+  handleClick = (e) => {
+    const { index, onClick } = this.props;
+
+    if (this.context.router && e.button === 0) {
+      e.preventDefault();
+      onClick(index);
+    }
+
+    e.stopPropagation();
+  }
+
+  render () {
+    const { attachment, index, size, standalone, letterbox } = this.props;
+
+    let width  = 50;
+    let height = 100;
+    let top    = 'auto';
+    let left   = 'auto';
+    let bottom = 'auto';
+    let right  = 'auto';
+
+    if (size === 1) {
+      width = 100;
+    }
+
+    if (size === 4 || (size === 3 && index > 0)) {
+      height = 50;
+    }
+
+    if (size === 2) {
+      if (index === 0) {
+        right = '2px';
+      } else {
+        left = '2px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '2px';
+      } else if (index > 0) {
+        left = '2px';
+      }
+
+      if (index === 1) {
+        bottom = '2px';
+      } else if (index > 1) {
+        top = '2px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '2px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '2px';
+      }
+
+      if (index < 2) {
+        bottom = '2px';
+      } else {
+        top = '2px';
+      }
+    }
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'image') {
+      const previewUrl = attachment.get('preview_url');
+      const previewWidth = attachment.getIn(['meta', 'small', 'width']);
+
+      const originalUrl = attachment.get('url');
+      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+
+      const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
+
+      const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
+      const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+
+      thumbnail = (
+        <a
+          className='media-gallery__item-thumbnail'
+          href={attachment.get('remote_url') || originalUrl}
+          onClick={this.handleClick}
+          target='_blank'
+        >
+          <img className={letterbox ? 'letterbox' : null} src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
+        </a>
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      const autoPlay = !isIOS() && autoPlayGif;
+
+      thumbnail = (
+        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
+          <video
+            className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
+            aria-label={attachment.get('description')}
+            role='application'
+            src={attachment.get('url')}
+            onClick={this.handleClick}
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            autoPlay={autoPlay}
+            loop
+            muted
+          />
+
+          <span className='media-gallery__gifv__label'>GIF</span>
+        </div>
+      );
+    }
+
+    return (
+      <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        {thumbnail}
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+export default class MediaGallery extends React.PureComponent {
+
+  static propTypes = {
+    sensitive: PropTypes.bool,
+    standalone: PropTypes.bool,
+    letterbox: PropTypes.bool,
+    fullwidth: PropTypes.bool,
+    media: ImmutablePropTypes.list.isRequired,
+    size: PropTypes.object,
+    onOpenMedia: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  static defaultProps = {
+    standalone: false,
+  };
+
+  state = {
+    visible: !this.props.sensitive,
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (!is(nextProps.media, this.props.media)) {
+      this.setState({ visible: !nextProps.sensitive });
+    }
+  }
+
+  handleOpen = () => {
+    this.setState({ visible: !this.state.visible });
+  }
+
+  handleClick = (index) => {
+    this.props.onOpenMedia(this.props.media, index);
+  }
+
+  render () {
+    const {
+      handleClick,
+      handleOpen,
+    } = this;
+    const {
+      fullwidth,
+      intl,
+      letterbox,
+      media,
+      sensitive,
+      standalone,
+    } = this.props;
+    const { visible } = this.state;
+    const size = media.take(4).size;
+    const computedClass = classNames('media-gallery', `size-${size}`, { 'full-width': fullwidth });
+
+    return (
+      <div className={computedClass}>
+        {visible ? (
+          <div className='sensitive-info'>
+            <IconButton
+              icon='eye'
+              onClick={handleOpen}
+              overlay
+              title={intl.formatMessage(messages.toggle_visible)}
+            />
+            {sensitive ? (
+              <span className='sensitive-marker'>
+                <FormattedMessage {...messages.sensitive} />
+              </span>
+            ) : null}
+          </div>
+        ) : null}
+        {function () {
+          switch (true) {
+          case !visible:
+            return (
+              <button
+                className='media-spoiler'
+                onClick={handleOpen}
+              >
+                <span className='media-spoiler__warning'>
+                  <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />
+                </span>
+                <span className='media-spoiler__trigger'>
+                  <FormattedMessage {...messages.toggle} />
+                </span>
+              </button>
+            );
+          case standalone && media.size === 1 && !!media.getIn([0, 'meta', 'small', 'aspect']):
+            return (
+              <Item
+                attachment={media.get(0)}
+                onClick={handleClick}
+                standalone
+              />
+            );
+          default:
+            return media.take(4).map(
+              (attachment, i) => (
+                <Item
+                  attachment={attachment}
+                  index={i}
+                  key={attachment.get('id')}
+                  letterbox={letterbox}
+                  onClick={handleClick}
+                  size={size}
+                />
+              )
+            );
+          }
+        }()}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/missing_indicator.js b/app/javascript/flavours/glitch/components/missing_indicator.js
new file mode 100644
index 000000000..87df7f61c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/missing_indicator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const MissingIndicator = () => (
+  <div className='missing-indicator'>
+    <div>
+      <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
+    </div>
+  </div>
+);
+
+export default MissingIndicator;
diff --git a/app/javascript/flavours/glitch/components/notification_purge_buttons.js b/app/javascript/flavours/glitch/components/notification_purge_buttons.js
new file mode 100644
index 000000000..e0c1543b0
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/notification_purge_buttons.js
@@ -0,0 +1,58 @@
+/**
+ * Buttons widget for controlling the notification clearing mode.
+ * In idle state, the cleaning mode button is shown. When the mode is active,
+ * a Confirm and Abort buttons are shown in its place.
+ */
+
+
+//  Package imports  //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
+  btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
+  btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
+  btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
+});
+
+@injectIntl
+export default class NotificationPurgeButtons extends ImmutablePureComponent {
+
+  static propTypes = {
+    onDeleteMarked : PropTypes.func.isRequired,
+    onMarkAll : PropTypes.func.isRequired,
+    onMarkNone : PropTypes.func.isRequired,
+    onInvert : PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    markNewForDelete: PropTypes.bool,
+  };
+
+  render () {
+    const { intl, markNewForDelete } = this.props;
+
+    //className='active'
+    return (
+      <div className='column-header__notif-cleaning-buttons'>
+        <button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}>
+          <b>∀</b><br />{intl.formatMessage(messages.btnAll)}
+        </button>
+
+        <button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}>
+          <b>∅</b><br />{intl.formatMessage(messages.btnNone)}
+        </button>
+
+        <button onClick={this.props.onInvert}>
+          <b>¬</b><br />{intl.formatMessage(messages.btnInvert)}
+        </button>
+
+        <button onClick={this.props.onDeleteMarked}>
+          <i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)}
+        </button>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/permalink.js b/app/javascript/flavours/glitch/components/permalink.js
new file mode 100644
index 000000000..d6556b584
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/permalink.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class Permalink extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    className: PropTypes.string,
+    href: PropTypes.string.isRequired,
+    to: PropTypes.string.isRequired,
+    children: PropTypes.node,
+  };
+
+  handleClick = (e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(this.props.to);
+    }
+  }
+
+  render () {
+    const {
+      children,
+      className,
+      href,
+      to,
+      ...other
+    } = this.props;
+
+    return (
+      <a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
+        {children}
+      </a>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js
new file mode 100644
index 000000000..51588e78c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/relative_timestamp.js
@@ -0,0 +1,147 @@
+import React from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const messages = defineMessages({
+  just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+  seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+  minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+  hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+  days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+});
+
+const dateFormatOptions = {
+  hour12: false,
+  year: 'numeric',
+  month: 'short',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+};
+
+const shortDateFormatOptions = {
+  month: 'numeric',
+  day: 'numeric',
+};
+
+const SECOND = 1000;
+const MINUTE = 1000 * 60;
+const HOUR   = 1000 * 60 * 60;
+const DAY    = 1000 * 60 * 60 * 24;
+
+const MAX_DELAY = 2147483647;
+
+const selectUnits = delta => {
+  const absDelta = Math.abs(delta);
+
+  if (absDelta < MINUTE) {
+    return 'second';
+  } else if (absDelta < HOUR) {
+    return 'minute';
+  } else if (absDelta < DAY) {
+    return 'hour';
+  }
+
+  return 'day';
+};
+
+const getUnitDelay = units => {
+  switch (units) {
+  case 'second':
+    return SECOND;
+  case 'minute':
+    return MINUTE;
+  case 'hour':
+    return HOUR;
+  case 'day':
+    return DAY;
+  default:
+    return MAX_DELAY;
+  }
+};
+
+@injectIntl
+export default class RelativeTimestamp extends React.Component {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    timestamp: PropTypes.string.isRequired,
+  };
+
+  state = {
+    now: this.props.intl.now(),
+  };
+
+  shouldComponentUpdate (nextProps, nextState) {
+    // As of right now the locale doesn't change without a new page load,
+    // but we might as well check in case that ever changes.
+    return this.props.timestamp !== nextProps.timestamp ||
+      this.props.intl.locale !== nextProps.intl.locale ||
+      this.state.now !== nextState.now;
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.timestamp !== nextProps.timestamp) {
+      this.setState({ now: this.props.intl.now() });
+    }
+  }
+
+  componentDidMount () {
+    this._scheduleNextUpdate(this.props, this.state);
+  }
+
+  componentWillUpdate (nextProps, nextState) {
+    this._scheduleNextUpdate(nextProps, nextState);
+  }
+
+  componentWillUnmount () {
+    clearTimeout(this._timer);
+  }
+
+  _scheduleNextUpdate (props, state) {
+    clearTimeout(this._timer);
+
+    const { timestamp }  = props;
+    const delta          = (new Date(timestamp)).getTime() - state.now;
+    const unitDelay      = getUnitDelay(selectUnits(delta));
+    const unitRemainder  = Math.abs(delta % unitDelay);
+    const updateInterval = 1000 * 10;
+    const delay          = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
+
+    this._timer = setTimeout(() => {
+      this.setState({ now: this.props.intl.now() });
+    }, delay);
+  }
+
+  render () {
+    const { timestamp, intl } = this.props;
+
+    const date  = new Date(timestamp);
+    const delta = this.state.now - date.getTime();
+
+    let relativeTime;
+
+    if (delta < 10 * SECOND) {
+      relativeTime = intl.formatMessage(messages.just_now);
+    } else if (delta < 3 * DAY) {
+      if (delta < MINUTE) {
+        relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+      } else if (delta < HOUR) {
+        relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+      } else if (delta < DAY) {
+        relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+      } else {
+        relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+      }
+    } else {
+      relativeTime = intl.formatDate(date, shortDateFormatOptions);
+    }
+
+    return (
+      <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
+        {relativeTime}
+      </time>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
new file mode 100644
index 000000000..8b1e3c93d
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -0,0 +1,198 @@
+import React, { PureComponent } from 'react';
+import { ScrollContainer } from 'react-router-scroll-4';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
+import LoadMore from './load_more';
+import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen';
+
+export default class ScrollableList extends PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    scrollKey: PropTypes.string.isRequired,
+    onScrollToBottom: PropTypes.func,
+    onScrollToTop: PropTypes.func,
+    onScroll: PropTypes.func,
+    trackScroll: PropTypes.bool,
+    shouldUpdateScroll: PropTypes.func,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    prepend: PropTypes.node,
+    emptyMessage: PropTypes.node,
+    children: PropTypes.node,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  state = {
+    lastMouseMove: null,
+  };
+
+  intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+  handleScroll = throttle(() => {
+    if (this.node) {
+      const { scrollTop, scrollHeight, clientHeight } = this.node;
+      const offset = scrollHeight - scrollTop - clientHeight;
+      this._oldScrollPosition = scrollHeight - scrollTop;
+
+      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+        this.props.onScrollToBottom();
+      } else if (scrollTop < 100 && this.props.onScrollToTop) {
+        this.props.onScrollToTop();
+      } else if (this.props.onScroll) {
+        this.props.onScroll();
+      }
+    }
+  }, 150, {
+    trailing: true,
+  });
+
+  handleMouseMove = throttle(() => {
+    this._lastMouseMove = new Date();
+  }, 300);
+
+  handleMouseLeave = () => {
+    this._lastMouseMove = null;
+  }
+
+  componentDidMount () {
+    this.attachScrollListener();
+    this.attachIntersectionObserver();
+    attachFullscreenListener(this.onFullScreenChange);
+
+    // Handle initial scroll posiiton
+    this.handleScroll();
+  }
+
+  componentDidUpdate (prevProps) {
+    const someItemInserted = React.Children.count(prevProps.children) > 0 &&
+      React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
+      this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
+
+    // Reset the scroll position when a new child comes in in order not to
+    // jerk the scrollbar around if you're already scrolled down the page.
+    if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
+      const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
+
+      if (this.node.scrollTop !== newScrollTop) {
+        this.node.scrollTop = newScrollTop;
+      }
+    } else {
+      this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
+    }
+  }
+
+  componentWillUnmount () {
+    this.detachScrollListener();
+    this.detachIntersectionObserver();
+    detachFullscreenListener(this.onFullScreenChange);
+  }
+
+  onFullScreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  attachIntersectionObserver () {
+    this.intersectionObserverWrapper.connect({
+      root: this.node,
+      rootMargin: '300% 0px',
+    });
+  }
+
+  detachIntersectionObserver () {
+    this.intersectionObserverWrapper.disconnect();
+  }
+
+  attachScrollListener () {
+    this.node.addEventListener('scroll', this.handleScroll);
+  }
+
+  detachScrollListener () {
+    this.node.removeEventListener('scroll', this.handleScroll);
+  }
+
+  getFirstChildKey (props) {
+    const { children } = props;
+    let firstChild = children;
+    if (children instanceof ImmutableList) {
+      firstChild = children.get(0);
+    } else if (Array.isArray(children)) {
+      firstChild = children[0];
+    }
+    return firstChild && firstChild.key;
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.props.onScrollToBottom();
+  }
+
+  _recentlyMoved () {
+    return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
+  }
+
+  render () {
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const { fullscreen } = this.state;
+    const childrenCount = React.Children.count(children);
+
+    const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
+    let scrollableArea = null;
+
+    if (isLoading || childrenCount > 0 || !emptyMessage) {
+      scrollableArea = (
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
+          <div role='feed' className='item-list'>
+            {prepend}
+
+            {React.Children.map(this.props.children, (child, index) => (
+              <IntersectionObserverArticleContainer
+                key={child.key}
+                id={child.key}
+                index={index}
+                listLength={childrenCount}
+                intersectionObserverWrapper={this.intersectionObserverWrapper}
+                saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
+              >
+                {child}
+              </IntersectionObserverArticleContainer>
+            ))}
+
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          {emptyMessage}
+        </div>
+      );
+    }
+
+    if (trackScroll) {
+      return (
+        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+          {scrollableArea}
+        </ScrollContainer>
+      );
+    } else {
+      return scrollableArea;
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/setting_text.js b/app/javascript/flavours/glitch/components/setting_text.js
new file mode 100644
index 000000000..2c1b70bc3
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/setting_text.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class SettingText extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    settingPath: PropTypes.array.isRequired,
+    label: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(this.props.settingPath, e.target.value);
+  }
+
+  render () {
+    const { settings, settingPath, label } = this.props;
+
+    return (
+      <label>
+        <span style={{ display: 'none' }}>{label}</span>
+        <input
+          className='setting-text'
+          value={settings.getIn(settingPath)}
+          onChange={this.handleChange}
+          placeholder={label}
+        />
+      </label>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
new file mode 100644
index 000000000..feffc2d59
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -0,0 +1,441 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusPrepend from './status_prepend';
+import StatusHeader from './status_header';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
+import classNames from 'classnames';
+
+// We use the component (and not the container) since we do not want
+// to use the progress bar to show download progress
+import Bundle from '../features/ui/components/bundle';
+
+export default class Status extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    containerId: PropTypes.string,
+    id: PropTypes.string,
+    status: ImmutablePropTypes.map,
+    account: ImmutablePropTypes.map,
+    onReply: PropTypes.func,
+    onFavourite: PropTypes.func,
+    onReblog: PropTypes.func,
+    onDelete: PropTypes.func,
+    onPin: PropTypes.func,
+    onOpenMedia: PropTypes.func,
+    onOpenVideo: PropTypes.func,
+    onBlock: PropTypes.func,
+    onEmbed: PropTypes.func,
+    onHeightChange: PropTypes.func,
+    muted: PropTypes.bool,
+    collapse: PropTypes.bool,
+    hidden: PropTypes.bool,
+    prepend: PropTypes.string,
+    withDismiss: PropTypes.bool,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
+  };
+
+  state = {
+    isExpanded: null,
+    markedForDelete: false,
+  }
+
+  // Avoid checking props that are functions (and whose equality will always
+  // evaluate to false. See react-immutable-pure-component for usage.
+  updateOnProps = [
+    'status',
+    'account',
+    'settings',
+    'prepend',
+    'boostModal',
+    'favouriteModal',
+    'muted',
+    'collapse',
+    'notification',
+    'hidden',
+  ]
+
+  updateOnStates = [
+    'isExpanded',
+    'markedForDelete',
+  ]
+
+  //  If our settings have changed to disable collapsed statuses, then we
+  //  need to make sure that we uncollapse every one. We do that by watching
+  //  for changes to `settings.collapsed.enabled` in
+  //  `componentWillReceiveProps()`.
+
+  //  We also need to watch for changes on the `collapse` prop---if this
+  //  changes to anything other than `undefined`, then we need to collapse or
+  //  uncollapse our status accordingly.
+  componentWillReceiveProps (nextProps) {
+    if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
+      if (this.state.isExpanded === false) {
+        this.setExpansion(null);
+      }
+    } else if (
+      nextProps.collapse !== this.props.collapse &&
+      nextProps.collapse !== undefined
+    ) this.setExpansion(nextProps.collapse ? false : null);
+  }
+
+  //  When mounting, we just check to see if our status should be collapsed,
+  //  and collapse it if so. We don't need to worry about whether collapsing
+  //  is enabled here, because `setExpansion()` already takes that into
+  //  account.
+
+  //  The cases where a status should be collapsed are:
+  //
+  //   -  The `collapse` prop has been set to `true`
+  //   -  The user has decided in local settings to collapse all statuses.
+  //   -  The user has decided to collapse all notifications ('muted'
+  //      statuses).
+  //   -  The user has decided to collapse long statuses and the status is
+  //      over 400px (without media, or 650px with).
+  //   -  The status is a reply and the user has decided to collapse all
+  //      replies.
+  //   -  The status contains media and the user has decided to collapse all
+  //      statuses with media.
+  //   -  The status is a reblog the user has decided to collapse all
+  //      statuses which are reblogs.
+  componentDidMount () {
+    const { node } = this;
+    const {
+      status,
+      settings,
+      collapse,
+      muted,
+      prepend,
+    } = this.props;
+    const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
+
+    if (function () {
+      switch (true) {
+      case !!collapse:
+      case !!autoCollapseSettings.get('all'):
+      case autoCollapseSettings.get('notifications') && !!muted:
+      case autoCollapseSettings.get('lengthy') && node.clientHeight > (
+        status.get('media_attachments').size && !muted ? 650 : 400
+      ):
+      case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
+      case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
+      case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size:
+        return true;
+      default:
+        return false;
+      }
+    }()) this.setExpansion(false);
+  }
+
+  //  `setExpansion()` sets the value of `isExpanded` in our state. It takes
+  //  one argument, `value`, which gives the desired value for `isExpanded`.
+  //  The default for this argument is `null`.
+
+  //  `setExpansion()` automatically checks for us whether toot collapsing
+  //  is enabled, so we don't have to.
+  setExpansion = (value) => {
+    switch (true) {
+    case value === undefined || value === null:
+      this.setState({ isExpanded: null });
+      break;
+    case !value && this.props.settings.getIn(['collapsed', 'enabled']):
+      this.setState({ isExpanded: false });
+      break;
+    case !!value:
+      this.setState({ isExpanded: true });
+      break;
+    }
+  }
+
+  //  `parseClick()` takes a click event and responds appropriately.
+  //  If our status is collapsed, then clicking on it should uncollapse it.
+  //  If `Shift` is held, then clicking on it should collapse it.
+  //  Otherwise, we open the url handed to us in `destination`, if
+  //  applicable.
+  parseClick = (e, destination) => {
+    const { router } = this.context;
+    const { status } = this.props;
+    const { isExpanded } = this.state;
+    if (!router) return;
+    if (destination === undefined) {
+      destination = `/statuses/${
+        status.getIn(['reblog', 'id'], status.get('id'))
+      }`;
+    }
+    if (e.button === 0) {
+      if (isExpanded === false) this.setExpansion(null);
+      else if (e.shiftKey) {
+        this.setExpansion(false);
+        document.getSelection().removeAllRanges();
+      } else router.history.push(destination);
+      e.preventDefault();
+    }
+  }
+
+  handleAccountClick = (e) => {
+    if (this.context.router && e.button === 0) {
+      const id = e.currentTarget.getAttribute('data-id');
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${id}`);
+    }
+  }
+
+  handleExpandedToggle = () => {
+    if (this.props.status.get('spoiler_text')) {
+      this.setExpansion(this.state.isExpanded ? null : true);
+    }
+  };
+
+  handleOpenVideo = startTime => {
+    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.props.onReply(this.props.status, this.context.router.history);
+  }
+
+  handleHotkeyFavourite = (e) => {
+    this.props.onFavourite(this.props.status, e);
+  }
+
+  handleHotkeyBoost = e => {
+    this.props.onReblog(this.props.status, e);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.props.onMention(this.props.status.get('account'), this.context.router.history);
+  }
+
+  handleHotkeyOpen = () => {
+    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.props.onMoveUp(this.props.containerId || this.props.id);
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.props.onMoveDown(this.props.containerId || this.props.id);
+  }
+
+  handleRef = c => {
+    this.node = c;
+  }
+
+  renderLoadingMediaGallery () {
+    return <div className='media_gallery' style={{ height: '110px' }} />;
+  }
+
+  renderLoadingVideoPlayer () {
+    return <div className='media-spoiler-video' style={{ height: '110px' }} />;
+  }
+
+  render () {
+    const {
+      handleRef,
+      parseClick,
+      setExpansion,
+    } = this;
+    const { router } = this.context;
+    const {
+      status,
+      account,
+      settings,
+      collapsed,
+      muted,
+      prepend,
+      intersectionObserverWrapper,
+      onOpenVideo,
+      onOpenMedia,
+      notification,
+      hidden,
+      ...other
+    } = this.props;
+    const { isExpanded } = this.state;
+    let background = null;
+    let attachments = null;
+    let media = null;
+    let mediaIcon = null;
+
+    if (status === null) {
+      return null;
+    }
+
+    if (hidden) {
+      return (
+        <div
+          ref={this.handleRef}
+          data-id={status.get('id')}
+          style={{
+            height: `${this.height}px`,
+            opacity: 0,
+            overflow: 'hidden',
+          }}
+        >
+          {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+          {' '}
+          {status.get('content')}
+        </div>
+      );
+    }
+
+    //  If user backgrounds for collapsed statuses are enabled, then we
+    //  initialize our background accordingly. This will only be rendered if
+    //  the status is collapsed.
+    if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
+      background = status.getIn(['account', 'header']);
+    }
+
+    //  This handles our media attachments. Note that we don't show media on
+    //  muted (notification) statuses. If the media type is unknown, then we
+    //  simply ignore it.
+
+    //  After we have generated our appropriate media element and stored it in
+    //  `media`, we snatch the thumbnail to use as our `background` if media
+    //  backgrounds for collapsed statuses are enabled.
+    attachments = status.get('media_attachments');
+    if (attachments.size > 0 && !muted) {
+      if (attachments.some(item => item.get('type') === 'unknown')) {  //  Media type is 'unknown'
+        /*  Do nothing  */
+      } else if (attachments.getIn([0, 'type']) === 'video') {  //  Media type is 'video'
+        const video = status.getIn(['media_attachments', 0]);
+
+        media = (
+          <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
+            {Component => (<Component
+              preview={video.get('preview_url')}
+              src={video.get('url')}
+              sensitive={status.get('sensitive')}
+              letterbox={settings.getIn(['media', 'letterbox'])}
+              fullwidth={settings.getIn(['media', 'fullwidth'])}
+              onOpenVideo={this.handleOpenVideo}
+            />)}
+          </Bundle>
+        );
+        mediaIcon = 'video-camera';
+      } else {  //  Media type is 'image' or 'gifv'
+        media = (
+          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
+            {Component => (
+              <Component
+                media={attachments}
+                sensitive={status.get('sensitive')}
+                letterbox={settings.getIn(['media', 'letterbox'])}
+                fullwidth={settings.getIn(['media', 'fullwidth'])}
+                onOpenMedia={this.props.onOpenMedia}
+              />
+            )}
+          </Bundle>
+        );
+        mediaIcon = 'picture-o';
+      }
+
+      if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
+        background = attachments.getIn([0, 'preview_url']);
+      }
+    }
+
+    //  Here we prepare extra data-* attributes for CSS selectors.
+    //  Users can use those for theming, hiding avatars etc via UserStyle
+    const selectorAttribs = {
+      'data-status-by': `@${status.getIn(['account', 'acct'])}`,
+    };
+
+    if (prepend && account) {
+      const notifKind = {
+        favourite: 'favourited',
+        reblog: 'boosted',
+        reblogged_by: 'boosted',
+      }[prepend];
+
+      selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
+    }
+
+    const handlers = {
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      open: this.handleHotkeyOpen,
+      openProfile: this.handleHotkeyOpenProfile,
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      toggleSpoiler: this.handleExpandedToggle,
+    };
+
+    const computedClass = classNames('status', `status-${status.get('visibility')}`, {
+      collapsed: isExpanded === false,
+      'has-background': isExpanded === false && background,
+      'marked-for-delete': this.state.markedForDelete,
+      muted,
+    }, 'focusable');
+
+    return (
+      <HotKeys handlers={handlers}>
+        <div
+          className={computedClass}
+          style={isExpanded === false && background ? { backgroundImage: `url(${background})` } : null}
+          {...selectorAttribs}
+          ref={handleRef}
+          tabIndex='0'
+        >
+          {prepend && account ? (
+            <StatusPrepend
+              type={prepend}
+              account={account}
+              parseClick={parseClick}
+              notificationId={this.props.notificationId}
+            />
+          ) : null}
+          <StatusHeader
+            status={status}
+            friend={account}
+            mediaIcon={mediaIcon}
+            collapsible={settings.getIn(['collapsed', 'enabled'])}
+            collapsed={isExpanded === false}
+            parseClick={parseClick}
+            setExpansion={setExpansion}
+          />
+          <StatusContent
+            status={status}
+            media={media}
+            mediaIcon={mediaIcon}
+            expanded={isExpanded}
+            setExpansion={setExpansion}
+            parseClick={parseClick}
+            disabled={!router}
+          />
+          {isExpanded !== false ? (
+            <StatusActionBar
+              {...other}
+              status={status}
+              account={status.get('account')}
+            />
+          ) : null}
+          {notification ? (
+            <NotificationOverlayContainer
+              notification={notification}
+            />
+          ) : null}
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
new file mode 100644
index 000000000..cb663e773
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -0,0 +1,185 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'flavours/glitch/util/initial_state';
+import RelativeTimestamp from './relative_timestamp';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  share: { id: 'status.share', defaultMessage: 'Share' },
+  more: { id: 'status.more', defaultMessage: 'More' },
+  replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  open: { id: 'status.open', defaultMessage: 'Expand this status' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+  muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+  unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
+});
+
+@injectIntl
+export default class StatusActionBar extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReply: PropTypes.func,
+    onFavourite: PropTypes.func,
+    onReblog: PropTypes.func,
+    onDelete: PropTypes.func,
+    onMention: PropTypes.func,
+    onMute: PropTypes.func,
+    onBlock: PropTypes.func,
+    onReport: PropTypes.func,
+    onEmbed: PropTypes.func,
+    onMuteConversation: PropTypes.func,
+    onPin: PropTypes.func,
+    withDismiss: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  // Avoid checking props that are functions (and whose equality will always
+  // evaluate to false. See react-immutable-pure-component for usage.
+  updateOnProps = [
+    'status',
+    'withDismiss',
+  ]
+
+  handleReplyClick = () => {
+    this.props.onReply(this.props.status, this.context.router.history);
+  }
+
+  handleShareClick = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  }
+
+  handleFavouriteClick = (e) => {
+    this.props.onFavourite(this.props.status, e);
+  }
+
+  handleReblogClick = (e) => {
+    this.props.onReblog(this.props.status, e);
+  }
+
+  handleDeleteClick = () => {
+    this.props.onDelete(this.props.status);
+  }
+
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
+  handleMentionClick = () => {
+    this.props.onMention(this.props.status.get('account'), this.context.router.history);
+  }
+
+  handleMuteClick = () => {
+    this.props.onMute(this.props.status.get('account'));
+  }
+
+  handleBlockClick = () => {
+    this.props.onBlock(this.props.status.get('account'));
+  }
+
+  handleOpen = () => {
+    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+  }
+
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
+  handleReport = () => {
+    this.props.onReport(this.props.status);
+  }
+
+  handleConversationMuteClick = () => {
+    this.props.onMuteConversation(this.props.status);
+  }
+
+  render () {
+    const { status, intl, withDismiss } = this.props;
+
+    const mutingConversation = status.get('muted');
+    const anonymousAccess    = !me;
+    const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+
+    let menu = [];
+    let reblogIcon = 'retweet';
+    let replyIcon;
+    let replyTitle;
+
+    menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
+    menu.push(null);
+
+    if (status.getIn(['account', 'id']) === me || withDismiss) {
+      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+      menu.push(null);
+    }
+
+    if (status.getIn(['account', 'id']) === me) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.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 (status.get('in_reply_to_id', null) === null) {
+      replyIcon = 'reply';
+      replyTitle = intl.formatMessage(messages.reply);
+    } else {
+      replyIcon = 'reply-all';
+      replyTitle = intl.formatMessage(messages.replyAll);
+    }
+
+    const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
+    );
+
+    return (
+      <div className='status__action-bar'>
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+        <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        {shareButton}
+
+        <div className='status__action-bar-dropdown'>
+          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
+        </div>
+
+        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
new file mode 100644
index 000000000..0c40e62cc
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -0,0 +1,245 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from 'flavours/glitch/util/rtl';
+import { FormattedMessage } from 'react-intl';
+import Permalink from './permalink';
+import classnames from 'classnames';
+
+export default class StatusContent extends React.PureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    expanded: PropTypes.bool,
+    setExpansion: PropTypes.func,
+    media: PropTypes.element,
+    mediaIcon: PropTypes.string,
+    parseClick: PropTypes.func,
+    disabled: PropTypes.bool,
+  };
+
+  state = {
+    hidden: true,
+  };
+
+  _updateStatusLinks () {
+    const node  = this.node;
+    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.status.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 {
+        link.addEventListener('click', this.onLinkClick.bind(this), false);
+        link.setAttribute('title', link.href);
+      }
+
+      link.setAttribute('target', '_blank');
+      link.setAttribute('rel', 'noopener');
+    }
+  }
+
+  componentDidMount () {
+    this._updateStatusLinks();
+  }
+
+  componentDidUpdate () {
+    this._updateStatusLinks();
+  }
+
+  onLinkClick = (e) => {
+    if (this.props.expanded === false) {
+      if (this.props.parseClick) this.props.parseClick(e);
+    }
+  }
+
+  onMentionClick = (mention, e) => {
+    if (this.props.parseClick) {
+      this.props.parseClick(e, `/accounts/${mention.get('id')}`);
+    }
+  }
+
+  onHashtagClick = (hashtag, e) => {
+    hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+    if (this.props.parseClick) {
+      this.props.parseClick(e, `/timelines/tag/${hashtag}`);
+    }
+  }
+
+  handleMouseDown = (e) => {
+    this.startXY = [e.clientX, e.clientY];
+  }
+
+  handleMouseUp = (e) => {
+    const { parseClick } = this.props;
+
+    if (!this.startXY) {
+      return;
+    }
+
+    const [ startX, startY ] = this.startXY;
+    const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+    if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
+      return;
+    }
+
+    if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
+      parseClick(e);
+    }
+
+    this.startXY = null;
+  }
+
+  handleSpoilerClick = (e) => {
+    e.preventDefault();
+
+    if (this.props.setExpansion) {
+      this.props.setExpansion(this.props.expanded ? null : true);
+    } else {
+      this.setState({ hidden: !this.state.hidden });
+    }
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  render () {
+    const {
+      status,
+      media,
+      mediaIcon,
+      parseClick,
+      disabled,
+    } = this.props;
+
+    const hidden = this.props.setExpansion ? !this.props.expanded : this.state.hidden;
+
+    const content = { __html: status.get('contentHtml') };
+    const spoilerContent = { __html: status.get('spoilerHtml') };
+    const directionStyle = { direction: 'ltr' };
+    const classNames = classnames('status__content', {
+      'status__content--with-action': parseClick && !disabled,
+      'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+    });
+
+    if (isRtl(status.get('search_index'))) {
+      directionStyle.direction = 'rtl';
+    }
+
+    if (status.get('spoiler_text').length > 0) {
+      let mentionsPlaceholder = '';
+
+      const mentionLinks = status.get('mentions').map(item => (
+        <Permalink
+          to={`/accounts/${item.get('id')}`}
+          href={item.get('url')}
+          key={item.get('id')}
+          className='mention'
+        >
+          @<span>{item.get('username')}</span>
+        </Permalink>
+      )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+      const toggleText = hidden ? [
+        <FormattedMessage
+          id='status.show_more'
+          defaultMessage='Show more'
+          key='0'
+        />,
+        mediaIcon ? (
+          <i
+            className={
+              `fa fa-fw fa-${mediaIcon} status__content__spoiler-icon`
+            }
+            aria-hidden='true'
+            key='1'
+          />
+        ) : null,
+      ] : [
+        <FormattedMessage
+          id='status.show_less'
+          defaultMessage='Show less'
+          key='0'
+        />,
+      ];
+
+      if (hidden) {
+        mentionsPlaceholder = <div>{mentionLinks}</div>;
+      }
+
+      return (
+        <div className={classNames} tabIndex='0'>
+          <p
+            style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
+            onMouseDown={this.handleMouseDown}
+            onMouseUp={this.handleMouseUp}
+          >
+            <span dangerouslySetInnerHTML={spoilerContent} />
+            {' '}
+            <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
+              {toggleText}
+            </button>
+          </p>
+
+          {mentionsPlaceholder}
+
+          <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
+            <div
+              ref={this.setRef}
+              style={directionStyle}
+              tabIndex={!hidden ? 0 : null}
+              onMouseDown={this.handleMouseDown}
+              onMouseUp={this.handleMouseUp}
+              dangerouslySetInnerHTML={content}
+            />
+            {media}
+          </div>
+
+        </div>
+      );
+    } else if (parseClick) {
+      return (
+        <div
+          className={classNames}
+          style={directionStyle}
+          tabIndex='0'
+        >
+          <div
+            ref={this.setRef}
+            onMouseDown={this.handleMouseDown}
+            onMouseUp={this.handleMouseUp}
+            dangerouslySetInnerHTML={content}
+            tabIndex='0'
+          />
+          {media}
+        </div>
+      );
+    } else {
+      return (
+        <div
+          className='status__content'
+          style={directionStyle}
+          tabIndex='0'
+        >
+          <div ref={this.setRef} dangerouslySetInnerHTML={content} tabIndex='0' />
+          {media}
+        </div>
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js
new file mode 100644
index 000000000..e8c7a771e
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_header.js
@@ -0,0 +1,120 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+
+//  Mastodon imports.
+import Avatar from './avatar';
+import AvatarOverlay from './avatar_overlay';
+import DisplayName from './display_name';
+import IconButton from './icon_button';
+import VisibilityIcon from './status_visibility_icon';
+
+//  Messages for use with internationalization stuff.
+const messages = defineMessages({
+  collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
+  uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
+  public: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+@injectIntl
+export default class StatusHeader extends React.PureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    friend: ImmutablePropTypes.map,
+    mediaIcon: PropTypes.string,
+    collapsible: PropTypes.bool,
+    collapsed: PropTypes.bool,
+    parseClick: PropTypes.func.isRequired,
+    setExpansion: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  //  Handles clicks on collapsed button
+  handleCollapsedClick = (e) => {
+    const { collapsed, setExpansion } = this.props;
+    if (e.button === 0) {
+      setExpansion(collapsed ? null : false);
+      e.preventDefault();
+    }
+  }
+
+  //  Handles clicks on account name/image
+  handleAccountClick = (e) => {
+    const { status, parseClick } = this.props;
+    parseClick(e, `/accounts/${+status.getIn(['account', 'id'])}`);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      status,
+      friend,
+      mediaIcon,
+      collapsible,
+      collapsed,
+      intl,
+    } = this.props;
+
+    const account = status.get('account');
+
+    return (
+      <header className='status__info'>
+        <a
+          href={account.get('url')}
+          target='_blank'
+          className='status__avatar'
+          onClick={this.handleAccountClick}
+        >
+          {
+            friend ? (
+              <AvatarOverlay account={account} friend={friend} />
+            ) : (
+              <Avatar account={account} size={48} />
+            )
+          }
+        </a>
+        <a
+          href={account.get('url')}
+          target='_blank'
+          className='status__display-name'
+          onClick={this.handleAccountClick}
+        >
+          <DisplayName account={account} />
+        </a>
+        <div className='status__info__icons'>
+          {mediaIcon ? (
+            <i
+              className={`fa fa-fw fa-${mediaIcon}`}
+              aria-hidden='true'
+            />
+          ) : null}
+          {(
+            <VisibilityIcon visibility={status.get('visibility')} />
+          )}
+          {collapsible ? (
+            <IconButton
+              className='status__collapse-button'
+              animate flip
+              active={collapsed}
+              title={
+                collapsed ?
+                  intl.formatMessage(messages.uncollapse) :
+                  intl.formatMessage(messages.collapse)
+              }
+              icon='angle-double-up'
+              onClick={this.handleCollapsedClick}
+            />
+          ) : null}
+        </div>
+
+      </header>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
new file mode 100644
index 000000000..f190ba6ab
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from './scrollable_list';
+
+export default class StatusList extends ImmutablePureComponent {
+
+  static propTypes = {
+    scrollKey: PropTypes.string.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    onScrollToBottom: PropTypes.func,
+    onScrollToTop: PropTypes.func,
+    onScroll: PropTypes.func,
+    trackScroll: PropTypes.bool,
+    shouldUpdateScroll: PropTypes.func,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    prepend: PropTypes.node,
+    emptyMessage: PropTypes.node,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  handleMoveUp = id => {
+    const elementIndex = this.props.statusIds.indexOf(id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.statusIds.indexOf(id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { statusIds, ...other } = this.props;
+    const { isLoading } = other;
+
+    const scrollableContent = (isLoading || statusIds.size > 0) ? (
+      statusIds.map((statusId) => (
+        <StatusContainer
+          key={statusId}
+          id={statusId}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
+      ))
+    ) : null;
+
+    return (
+      <ScrollableList {...other} ref={this.setRef}>
+        {scrollableContent}
+      </ScrollableList>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
new file mode 100644
index 000000000..bd2559e46
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -0,0 +1,83 @@
+//  Package imports  //
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+
+export default class StatusPrepend extends React.PureComponent {
+
+  static propTypes = {
+    type: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    parseClick: PropTypes.func.isRequired,
+    notificationId: PropTypes.number,
+  };
+
+  handleClick = (e) => {
+    const { account, parseClick } = this.props;
+    parseClick(e, `/accounts/${+account.get('id')}`);
+  }
+
+  Message = () => {
+    const { type, account } = this.props;
+    let link = (
+      <a
+        onClick={this.handleClick}
+        href={account.get('url')}
+        className='status__display-name'
+      >
+        <b
+          dangerouslySetInnerHTML={{
+            __html : account.get('display_name_html') || account.get('username'),
+          }}
+        />
+      </a>
+    );
+    switch (type) {
+    case 'reblogged_by':
+      return (
+        <FormattedMessage
+          id='status.reblogged_by'
+          defaultMessage='{name} boosted'
+          values={{ name : link }}
+        />
+      );
+    case 'favourite':
+      return (
+        <FormattedMessage
+          id='notification.favourite'
+          defaultMessage='{name} favourited your status'
+          values={{ name : link }}
+        />
+      );
+    case 'reblog':
+      return (
+        <FormattedMessage
+          id='notification.reblog'
+          defaultMessage='{name} boosted your status'
+          values={{ name : link }}
+        />
+      );
+    }
+    return null;
+  }
+
+  render () {
+    const { Message } = this;
+    const { type } = this.props;
+
+    return !type ? null : (
+      <aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}>
+        <div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
+          <i
+            className={`fa fa-fw fa-${
+              type === 'favourite' ? 'star star-icon' : 'retweet'
+            } status__prepend-icon`}
+          />
+        </div>
+        <Message />
+      </aside>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_visibility_icon.js b/app/javascript/flavours/glitch/components/status_visibility_icon.js
new file mode 100644
index 000000000..017b69cbb
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_visibility_icon.js
@@ -0,0 +1,48 @@
+//  Package imports  //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  public: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+@injectIntl
+export default class VisibilityIcon extends ImmutablePureComponent {
+
+  static propTypes = {
+    visibility: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    withLabel: PropTypes.bool,
+  };
+
+  render() {
+    const { withLabel, visibility, intl } = this.props;
+
+    const visibilityClass = {
+      public: 'globe',
+      unlisted: 'unlock-alt',
+      private: 'lock',
+      direct: 'envelope',
+    }[visibility];
+
+    const label = intl.formatMessage(messages[visibility]);
+
+    const icon = (<i
+      className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`}
+      title={label}
+      aria-hidden='true'
+    />);
+
+    if (withLabel) {
+      return (<span style={{ whiteSpace: 'nowrap' }}>{icon} {label}</span>);
+    } else {
+      return icon;
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js
new file mode 100644
index 000000000..9c8ffab1f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/text_icon_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class TextIconButton extends React.PureComponent {
+
+  static propTypes = {
+    label: PropTypes.string.isRequired,
+    title: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    ariaControls: PropTypes.string,
+  };
+
+  handleClick = (e) => {
+    e.preventDefault();
+    this.props.onClick();
+  }
+
+  render () {
+    const { label, title, active, ariaControls } = this.props;
+
+    return (
+      <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}>
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/account_container.js b/app/javascript/flavours/glitch/containers/account_container.js
new file mode 100644
index 000000000..bc84d299b
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/account_container.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Account from 'flavours/glitch/components/account';
+import {
+  followAccount,
+  unfollowAccount,
+  blockAccount,
+  unblockAccount,
+  muteAccount,
+  unmuteAccount,
+} from 'flavours/glitch/actions/accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { unfollowModal } from 'flavours/glitch/util/initial_state';
+
+const messages = defineMessages({
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.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));
+    }
+  },
+
+
+  onMuteNotifications (account, notifications) {
+    dispatch(muteAccount(account.get('id'), notifications));
+  },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/flavours/glitch/containers/card_container.js b/app/javascript/flavours/glitch/containers/card_container.js
new file mode 100644
index 000000000..dec7df522
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/card_container.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Card from 'flavours/glitch/features/status/components/card';
+import { fromJS } from 'immutable';
+
+export default class CardContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string,
+    card: PropTypes.array.isRequired,
+  };
+
+  render () {
+    const { card, ...props } = this.props;
+    return <Card card={fromJS(card)} {...props} />;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/compose_container.js b/app/javascript/flavours/glitch/containers/compose_container.js
new file mode 100644
index 000000000..60f6a9c9f
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/compose_container.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'flavours/glitch/store/configureStore';
+import { hydrateStore } from 'flavours/glitch/actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import Compose from 'flavours/glitch/features/standalone/compose';
+import initialState from 'flavours/glitch/util/initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+  store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          <Compose />
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..0b4f72fa1
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
@@ -0,0 +1,16 @@
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import { connect } from 'react-redux';
+import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+
+const mapStateToProps = state => ({
+  isModalOpen: state.get('modal').modalType === 'ACTIONS',
+});
+
+const mapDispatchToProps = dispatch => ({
+  isUserTouching,
+  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+  onModalClose: () => dispatch(closeModal()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js b/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js
new file mode 100644
index 000000000..f2741f2d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import IntersectionObserverArticle from 'flavours/glitch/components/intersection_observer_article';
+import { setHeight } from 'flavours/glitch/actions/height_cache';
+
+const makeMapStateToProps = (state, props) => ({
+  cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onHeightChange (key, id, height) {
+    dispatch(setHeight(key, id, height));
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle);
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
new file mode 100644
index 000000000..1c98cd5f7
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'flavours/glitch/store/configureStore';
+import { showOnboardingOnce } from 'flavours/glitch/actions/onboarding';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll-4';
+import UI from 'flavours/glitch/features/ui';
+import { hydrateStore } from 'flavours/glitch/actions/store';
+import { connectUserStream } from 'flavours/glitch/actions/streaming';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'locales';
+import initialState from 'flavours/glitch/util/initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export const store = configureStore();
+const hydrateAction = hydrateStore(initialState);
+store.dispatch(hydrateAction);
+
+export default class Mastodon extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  componentDidMount() {
+    this.disconnect = store.dispatch(connectUserStream());
+
+    // Desktop notifications
+    // Ask after 1 minute
+    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
+      window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
+    }
+
+    // Protocol handler
+    // Ask after 5 minutes
+    if (typeof navigator.registerProtocolHandler !== 'undefined') {
+      const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
+      window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
+    }
+
+    store.dispatch(showOnboardingOnce());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          <BrowserRouter basename='/web'>
+            <ScrollContext>
+              <Route path='/' component={UI} />
+            </ScrollContext>
+          </BrowserRouter>
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/media_gallery_container.js b/app/javascript/flavours/glitch/containers/media_gallery_container.js
new file mode 100644
index 000000000..54bfbf453
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/media_gallery_container.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import { fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class MediaGalleryContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    media: PropTypes.array.isRequired,
+  };
+
+  handleOpenMedia = () => {}
+
+  render () {
+    const { locale, media, ...props } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <MediaGallery
+          {...props}
+          media={fromJS(media)}
+          onOpenMedia={this.handleOpenMedia}
+        />
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js b/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js
new file mode 100644
index 000000000..2570cf4a5
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js
@@ -0,0 +1,49 @@
+//  Package imports.
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+
+//  Our imports.
+import NotificationPurgeButtons from 'flavours/glitch/components/notification_purge_buttons';
+import {
+  deleteMarkedNotifications,
+  enterNotificationClearingMode,
+  markAllNotifications,
+} from 'flavours/glitch/actions/notifications';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const messages = defineMessages({
+  clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
+  clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onEnterCleaningMode(yes) {
+    dispatch(enterNotificationClearingMode(yes));
+  },
+
+  onDeleteMarked() {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.clearMessage),
+      confirm: intl.formatMessage(messages.clearConfirm),
+      onConfirm: () => dispatch(deleteMarkedNotifications()),
+    }));
+  },
+
+  onMarkAll() {
+    dispatch(markAllNotifications(true));
+  },
+
+  onMarkNone() {
+    dispatch(markAllNotifications(false));
+  },
+
+  onInvert() {
+    dispatch(markAllNotifications(null));
+  },
+});
+
+const mapStateToProps = state => ({
+  markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
new file mode 100644
index 000000000..c0b9b5800
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Status from 'flavours/glitch/components/status';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import {
+  replyCompose,
+  mentionCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  reblog,
+  favourite,
+  unreblog,
+  unfavourite,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import { blockAccount } from 'flavours/glitch/actions/accounts';
+import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
+
+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?' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => {
+
+    let status = getStatus(state, props.id);
+    let reblogStatus = status ? status.get('reblog', null) : null;
+    let account = undefined;
+    let prepend = undefined;
+
+    if (reblogStatus !== null && typeof reblogStatus === 'object') {
+      account = status.get('account');
+      status = reblogStatus;
+      prepend = 'reblogged_by';
+    }
+
+    return {
+      containerId : props.containerId || props.id,  //  Should match reblogStatus's id for reblogs
+      status      : status,
+      account     : account || props.account,
+      settings    : state.get('local_settings'),
+      prepend     : prepend || props.prepend,
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onReply (status, router) {
+    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 }));
+      }
+    }
+  },
+
+  onModalFavourite (status) {
+    dispatch(favourite(status));
+  },
+
+  onFavourite (status, e) {
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      if (e.shiftKey || !favouriteModal) {
+        this.onModalFavourite(status);
+      } else {
+        dispatch(openModal('FAVOURITE', { status, onFavourite: this.onModalFavourite }));
+      }
+    }
+  },
+
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', { url: status.get('url') }));
+  },
+
+  onDelete (status) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id')));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(messages.deleteMessage),
+        confirm: intl.formatMessage(messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+      }));
+    }
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onOpenMedia (media, index) {
+    dispatch(openModal('MEDIA', { media, index }));
+  },
+
+  onOpenVideo (media, time) {
+    dispatch(openModal('VIDEO', { media, time }));
+  },
+
+  onBlock (account) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockConfirm),
+      onConfirm: () => dispatch(blockAccount(account.get('id'))),
+    }));
+  },
+
+  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)(Status));
diff --git a/app/javascript/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js
new file mode 100644
index 000000000..c5ffe1b63
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/timeline_container.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'flavours/glitch/store/configureStore';
+import { hydrateStore } from 'flavours/glitch/actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline';
+import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline';
+import initialState from 'flavours/glitch/util/initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+  store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    hashtag: PropTypes.string,
+  };
+
+  render () {
+    const { locale, hashtag } = this.props;
+
+    let timeline;
+
+    if (hashtag) {
+      timeline = <HashtagTimeline hashtag={hashtag} />;
+    } else {
+      timeline = <PublicTimeline />;
+    }
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          {timeline}
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/video_container.js b/app/javascript/flavours/glitch/containers/video_container.js
new file mode 100644
index 000000000..b206e9a10
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/video_container.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import Video from 'flavours/glitch/features/video';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class VideoContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  render () {
+    const { locale, ...props } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Video {...props} />
+      </IntlProvider>
+    );
+  }
+
+}
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..df8cb3733
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -0,0 +1,144 @@
+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 { Link } from 'react-router-dom';
+import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+import { me } from 'flavours/glitch/util/initial_state';
+
+const messages = defineMessages({
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
+  media: { id: 'account.media', defaultMessage: 'Media' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
+  showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onFollow: PropTypes.func,
+    onBlock: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onBlockDomain: PropTypes.func.isRequired,
+    onUnblockDomain: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleShare = () => {
+    navigator.share({
+      url: this.props.account.get('url'),
+    });
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    let menu = [];
+    let extraInfo = '';
+
+    menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    if ('share' in navigator) {
+      menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+    }
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` });
+    menu.push(null);
+
+    if (account.get('id') === me) {
+      menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+    } else {
+      if (account.getIn(['relationship', 'following'])) {
+        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 });
+        }
+      }
+
+      if (account.getIn(['relationship', 'muting'])) {
+        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+      }
+
+      if (account.getIn(['relationship', 'blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+    }
+
+    if (account.get('acct') !== account.get('username')) {
+      const domain = account.get('acct').split('@')[1];
+
+      extraInfo = (
+        <div className='account__disclaimer'>
+          <FormattedMessage
+            id='account.disclaimer_full'
+            defaultMessage="Information below may reflect the user's profile incompletely."
+          />
+          {' '}
+          <a target='_blank' rel='noopener' href={account.get('url')}>
+            <FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' />
+          </a>
+        </div>
+      );
+
+      menu.push(null);
+
+      if (account.getIn(['relationship', 'domain_blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
+      }
+    }
+
+    return (
+      <div>
+        {extraInfo}
+
+        <div className='account__action-bar'>
+          <div className='account__action-bar-dropdown'>
+            <DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' />
+          </div>
+
+          <div className='account__action-bar-links'>
+            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
+              <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
+              <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
+            </Link>
+
+            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
+              <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
+              <strong><FormattedNumber value={account.get('following_count')} /></strong>
+            </Link>
+
+            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
+              <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
+              <strong><FormattedNumber value={account.get('followers_count')} /></strong>
+            </Link>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
new file mode 100644
index 000000000..767e4da47
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import Avatar from 'flavours/glitch/components/avatar';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+import emojify from 'flavours/glitch/util/emoji';
+import { me } from 'flavours/glitch/util/initial_state';
+import { processBio } from 'flavours/glitch/util/bio_metadata';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+});
+
+@injectIntl
+export default class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    onFollow: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { account, intl } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    let displayName = account.get('display_name_html');
+    let info        = '';
+    let actionBtn   = '';
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+      info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
+    }
+
+    if (me !== account.get('id')) {
+      if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = (
+          <div className='account--action-button'>
+            <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />
+          </div>
+        );
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = (
+          <div className='account--action-button'>
+            <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
+          </div>
+        );
+      }
+    }
+
+    const { text, metadata } = processBio(account.get('note'));
+
+    return (
+      <div className='account__header__wrapper'>
+        <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
+          <div>
+            <a
+              href={account.get('url')}
+              className='account__header__avatar'
+              role='presentation'
+              target='_blank'
+              rel='noopener'
+            >
+              <Avatar account={account} size={90} />
+            </a>
+
+            <span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} />
+            <span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span>
+            <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
+
+            {info}
+            {actionBtn}
+          </div>
+        </div>
+
+        {metadata.length && (
+          <table className='account__metadata'>
+            <tbody>
+              {(() => {
+                let data = [];
+                for (let i = 0; i < metadata.length; i++) {
+                  data.push(
+                    <tr key={i}>
+                      <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
+                      <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
+                    </tr>
+                  );
+                }
+                return data;
+              })()}
+            </tbody>
+          </table>
+        ) || null}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..e52d3b0bb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Permalink from 'flavours/glitch/components/permalink';
+
+export default class MediaItem extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { media } = this.props;
+    const status = media.get('status');
+
+    let content, style;
+
+    if (media.get('type') === 'gifv') {
+      content = <span className='media-gallery__gifv__label'>GIF</span>;
+    }
+
+    if (!status.get('sensitive')) {
+      style = { backgroundImage: `url(${media.get('preview_url')})` };
+    }
+
+    return (
+      <div className='account-gallery__item'>
+        <Permalink
+          to={`/statuses/${status.get('id')}`}
+          href={status.get('url')}
+          style={style}
+        >
+          {content}
+        </Permalink>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
new file mode 100644
index 000000000..df66b3b21
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+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 { FormattedMessage } from 'react-intl';
+import { ScrollContainer } from 'react-router-scroll-4';
+import LoadMore from 'flavours/glitch/components/load_more';
+
+const mapStateToProps = (state, props) => ({
+  medias: getAccountGallery(state, props.params.accountId),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
+  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountGallery extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    medias: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+  };
+
+  componentDidMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+    }
+  }
+
+  handleScrollToBottom = () => {
+    if (this.props.hasMore) {
+      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+    }
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+
+    if (150 > offset && !this.props.isLoading) {
+      this.handleScrollToBottom();
+    }
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.handleScrollToBottom();
+  }
+
+  render () {
+    const { medias, isLoading, hasMore } = this.props;
+
+    let loadMore = null;
+
+    if (!medias && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    if (!isLoading && medias.size > 0 && hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='account_gallery'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <HeaderContainer accountId={this.props.params.accountId} />
+
+            <div className='account-section-headline'>
+              <FormattedMessage id='account.media' defaultMessage='Media' />
+            </div>
+
+            <div className='account-gallery__container'>
+              {medias.map(media =>
+                (<MediaItem
+                  key={media.get('id')}
+                  media={media}
+                />)
+              )}
+              {loadMore}
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..4ad677fbe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from 'flavours/glitch/features/account/components/header';
+import ActionBar from 'flavours/glitch/features/account/components/action_bar';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onBlockDomain: PropTypes.func.isRequired,
+    onUnblockDomain: PropTypes.func.isRequired,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMention = () => {
+    this.props.onMention(this.props.account, this.context.router.history);
+  }
+
+  handleReport = () => {
+    this.props.onReport(this.props.account);
+  }
+
+  handleReblogToggle = () => {
+    this.props.onReblogToggle(this.props.account);
+  }
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  }
+
+  handleBlockDomain = () => {
+    const domain = this.props.account.get('acct').split('@')[1];
+
+    if (!domain) return;
+
+    this.props.onBlockDomain(domain, this.props.account.get('id'));
+  }
+
+  handleUnblockDomain = () => {
+    const domain = this.props.account.get('acct').split('@')[1];
+
+    if (!domain) return;
+
+    this.props.onUnblockDomain(domain, this.props.account.get('id'));
+  }
+
+  render () {
+    const { account } = this.props;
+
+    if (account === null) {
+      return <MissingIndicator />;
+    }
+
+    return (
+      <div className='account-timeline__header'>
+        <InnerHeader
+          account={account}
+          onFollow={this.handleFollow}
+        />
+
+        <ActionBar
+          account={account}
+          onBlock={this.handleBlock}
+          onMention={this.handleMention}
+          onReblogToggle={this.handleReblogToggle}
+          onReport={this.handleReport}
+          onMute={this.handleMute}
+          onBlockDomain={this.handleBlockDomain}
+          onUnblockDomain={this.handleUnblockDomain}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..37ff445b2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
@@ -0,0 +1,104 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Header from '../components/header';
+import {
+  followAccount,
+  unfollowAccount,
+  blockAccount,
+  unblockAccount,
+  unmuteAccount,
+} from 'flavours/glitch/actions/accounts';
+import { mentionCompose } from 'flavours/glitch/actions/compose';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+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';
+
+const messages = defineMessages({
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+      if (unfollowModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unfollowConfirm),
+          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unfollowAccount(account.get('id')));
+      }
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+        confirm: intl.formatMessage(messages.blockConfirm),
+        onConfirm: () => dispatch(blockAccount(account.get('id'))),
+      }));
+    }
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onReblogToggle (account) {
+    if (account.getIn(['relationship', 'showing_reblogs'])) {
+      dispatch(followAccount(account.get('id'), false));
+    } else {
+      dispatch(followAccount(account.get('id'), true));
+    }
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+  onBlockDomain (domain, accountId) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockDomainConfirm),
+      onConfirm: () => dispatch(blockDomain(domain, accountId)),
+    }));
+  },
+
+  onUnblockDomain (domain, accountId) {
+    dispatch(unblockDomain(domain, accountId));
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
new file mode 100644
index 000000000..75dba5049
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { refreshAccountTimeline, 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 HeaderContainer from './containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import { List as ImmutableList } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
+  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountTimeline extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
+    }
+  }
+
+  handleScrollToBottom = () => {
+    if (!this.props.isLoading && this.props.hasMore) {
+      this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
+    }
+  }
+
+  render () {
+    const { statusIds, isLoading, hasMore } = this.props;
+
+    if (!statusIds && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column name='account'>
+        <ColumnBackButton />
+
+        <StatusList
+          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          scrollKey='account_timeline'
+          statusIds={statusIds}
+          isLoading={isLoading}
+          hasMore={hasMore}
+          onScrollToBottom={this.handleScrollToBottom}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/blocks/index.js b/app/javascript/flavours/glitch/features/blocks/index.js
new file mode 100644
index 000000000..edd448921
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/blocks/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+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 } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'blocks', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Blocks extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchBlocks());
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandBlocks());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column name='blocks' icon='ban' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='blocks'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..aad5f3976
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingText from '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' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { settings, onChange, intl } = this.props;
+
+    return (
+      <div>
+        <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
+
+        <div className='column-settings__row'>
+          <SettingText settings={settings} 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..39387abb9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'community']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (path, checked) {
+    dispatch(changeSetting(['community', ...path], 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..55355414f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import {
+  refreshCommunityTimeline,
+  expandCommunityTimeline,
+} from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+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 => ({
+  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class CommunityTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('COMMUNITY', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshCommunityTimeline());
+    this.disconnect = dispatch(connectCommunityStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandCommunityTimeline());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='local'>
+        <ColumnHeader
+          icon='users'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`community_timeline-${columnId}`}
+          timelineId='community'
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
new file mode 100644
index 000000000..29a2f4775
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -0,0 +1,439 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Actions.
+import {
+  cancelReplyCompose,
+  changeCompose,
+  changeComposeAdvancedOption,
+  changeComposeSensitivity,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  changeUploadCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  mountCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  undoUploadCompose,
+  unmountCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  closeModal,
+  openModal,
+} from 'flavours/glitch/actions/modal';
+
+//  Components.
+import ComposerOptions from './options';
+import ComposerPublisher from './publisher';
+import ComposerReply from './reply';
+import ComposerSpoiler from './spoiler';
+import ComposerTextarea from './textarea';
+import ComposerUploadForm from './upload_form';
+import ComposerWarning from './warning';
+
+//  Utils.
+import { countableText } from 'flavours/glitch/util/counter';
+import { me } from 'flavours/glitch/util/initial_state';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { wrap } from 'flavours/glitch/util/redux_helpers';
+
+//  State mapping.
+function mapStateToProps (state) {
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    advancedOptions: state.getIn(['compose', 'advanced_options']),
+    amUnlocked: !state.getIn(['accounts', me, 'locked']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    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']),
+    progress: state.getIn(['compose', 'progress']),
+    replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null,
+    replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    sideArm: state.getIn(['local_settings', 'side_arm']),
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: state.getIn(['compose', 'spoiler']),
+    spoilerText: state.getIn(['compose', 'spoiler_text']),
+    suggestionToken: state.getIn(['compose', 'suggestion_token']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    text: state.getIn(['compose', 'text']),
+  };
+};
+
+//  Dispatch mapping.
+const mapDispatchToProps = {
+  onCancelReply: cancelReplyCompose,
+  onChangeAdvancedOption: changeComposeAdvancedOption,
+  onChangeDescription: changeUploadCompose,
+  onChangeSensitivity: changeComposeSensitivity,
+  onChangeSpoilerText: changeComposeSpoilerText,
+  onChangeSpoilerness: changeComposeSpoilerness,
+  onChangeText: changeCompose,
+  onChangeVisibility: changeComposeVisibility,
+  onClearSuggestions: clearComposeSuggestions,
+  onCloseModal: closeModal,
+  onFetchSuggestions: fetchComposeSuggestions,
+  onInsertEmoji: insertEmojiCompose,
+  onMount: mountCompose,
+  onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
+  onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
+  onSelectSuggestion: selectComposeSuggestion,
+  onSubmit: submitCompose,
+  onUndoUpload: undoUploadCompose,
+  onUnmount: unmountCompose,
+  onUpload: uploadCompose,
+};
+
+//  Handlers.
+const handlers = {
+
+  //  Changes the text value of the spoiler.
+  handleChangeSpoiler ({ target: { value } }) {
+    const { onChangeSpoilerText } = this.props;
+    if (onChangeSpoilerText) {
+      onChangeSpoilerText(value);
+    }
+  },
+
+  //  Inserts an emoji at the caret.
+  handleEmoji (data) {
+    const { textarea: { selectionStart } } = this;
+    const { onInsertEmoji } = this.props;
+    this.caretPos = selectionStart + data.native.length + 1;
+    if (onInsertEmoji) {
+      onInsertEmoji(selectionStart, data);
+    }
+  },
+
+  //  Handles the secondary submit button.
+  handleSecondarySubmit () {
+    const { handleSubmit } = this.handlers;
+    const {
+      onChangeVisibility,
+      sideArm,
+    } = this.props;
+    if (sideArm !== 'none' && onChangeVisibility) {
+      onChangeVisibility(sideArm);
+    }
+    handleSubmit();
+  },
+
+  //  Selects a suggestion from the autofill.
+  handleSelect (tokenStart, token, value) {
+    const { onSelectSuggestion } = this.props;
+    this.caretPos = null;
+    if (onSelectSuggestion) {
+      onSelectSuggestion(tokenStart, token, value);
+    }
+  },
+
+  //  Submits the status.
+  handleSubmit () {
+    const { textarea: { value } } = this;
+    const {
+      onChangeText,
+      onSubmit,
+      text,
+    } = this.props;
+
+    //  If something changes inside the textarea, then we update the
+    //  state before submitting.
+    if (onChangeText && text !== value) {
+      onChangeText(value);
+    }
+
+    //  Submits the status.
+    if (onSubmit) {
+      onSubmit();
+    }
+  },
+
+  //  Sets a reference to the textarea.
+  handleRefTextarea (textareaComponent) {
+    if (textareaComponent) {
+      this.textarea = textareaComponent.textarea;
+    }
+  },
+};
+
+//  The component.
+class Composer extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.caretPos = null;
+    this.textarea = null;
+  }
+
+  //  If this is the update where we've finished uploading,
+  //  save the last caret position so we can restore it below!
+  componentWillReceiveProps (nextProps) {
+    const { textarea } = this;
+    const { isUploading } = this.props;
+    if (textarea && isUploading && !nextProps.isUploading) {
+      this.caretPos = textarea.selectionStart;
+    }
+  }
+
+  //  Tells our state the composer has been mounted.
+  componentDidMount () {
+    const { onMount } = this.props;
+    if (onMount) {
+      onMount();
+    }
+  }
+
+  //  Tells our state the composer has been unmounted.
+  componentWillUnmount () {
+    const { onUnmount } = this.props;
+    if (onUnmount) {
+      onUnmount();
+    }
+  }
+
+  //  This statement does several things:
+  //  - If we're beginning a reply, and,
+  //      - Replying to zero or one users, places the cursor at the end
+  //        of the textbox.
+  //      - Replying to more than one user, selects any usernames past
+  //        the first; this provides a convenient shortcut to drop
+  //        everyone else from the conversation.
+  // - If we've just finished uploading an image, and have a saved
+  //   caret position, restores the cursor to that position after the
+  //   text changes.
+  componentDidUpdate (prevProps) {
+    const {
+      caretPos,
+      textarea,
+    } = this;
+    const {
+      focusDate,
+      isUploading,
+      isSubmitting,
+      preselectDate,
+      text,
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
+      switch (true) {
+      case preselectDate !== prevProps.preselectDate:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPos) && caretPos !== null:
+        selectionStart = selectionEnd = caretPos;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      if (textarea) {
+        textarea.setSelectionRange(selectionStart, selectionEnd);
+        textarea.focus();
+      }
+
+    //  Refocuses the textarea after submitting.
+    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    }
+  }
+
+  render () {
+    const {
+      handleChangeSpoiler,
+      handleEmoji,
+      handleSecondarySubmit,
+      handleSelect,
+      handleSubmit,
+      handleRefTextarea,
+    } = this.handlers;
+    const {
+      acceptContentTypes,
+      advancedOptions,
+      amUnlocked,
+      intl,
+      isSubmitting,
+      isUploading,
+      layout,
+      media,
+      onCancelReply,
+      onChangeAdvancedOption,
+      onChangeDescription,
+      onChangeSensitivity,
+      onChangeSpoilerness,
+      onChangeText,
+      onChangeVisibility,
+      onClearSuggestions,
+      onCloseModal,
+      onFetchSuggestions,
+      onOpenActionsModal,
+      onOpenDoodleModal,
+      onUndoUpload,
+      onUpload,
+      privacy,
+      progress,
+      replyAccount,
+      replyContent,
+      resetFileKey,
+      sensitive,
+      showSearch,
+      sideArm,
+      spoiler,
+      spoilerText,
+      suggestions,
+      text,
+    } = this.props;
+
+    return (
+      <div className='composer'>
+        <ComposerSpoiler
+          hidden={!spoiler}
+          intl={intl}
+          onChange={handleChangeSpoiler}
+          onSubmit={handleSubmit}
+          text={spoilerText}
+        />
+        {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
+        {replyContent ? (
+          <ComposerReply
+            account={replyAccount}
+            content={replyContent}
+            intl={intl}
+            onCancel={onCancelReply}
+          />
+        ) : null}
+        <ComposerTextarea
+          advancedOptions={advancedOptions}
+          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+          disabled={isSubmitting}
+          intl={intl}
+          onChange={onChangeText}
+          onPaste={onUpload}
+          onPickEmoji={handleEmoji}
+          onSubmit={handleSubmit}
+          onSuggestionsClearRequested={onClearSuggestions}
+          onSuggestionsFetchRequested={onFetchSuggestions}
+          onSuggestionSelected={handleSelect}
+          ref={handleRefTextarea}
+          suggestions={suggestions}
+          value={text}
+        />
+        {isUploading || media && media.size ? (
+          <ComposerUploadForm
+            intl={intl}
+            media={media}
+            onChangeDescription={onChangeDescription}
+            onRemove={onUndoUpload}
+            progress={progress}
+            uploading={isUploading}
+          />
+        ) : null}
+        <ComposerOptions
+          acceptContentTypes={acceptContentTypes}
+          advancedOptions={advancedOptions}
+          disabled={isSubmitting}
+          full={media ? media.size >= 4 || media.some(
+            item => item.get('type') === 'video'
+          ) : false}
+          hasMedia={media && !!media.size}
+          intl={intl}
+          onChangeAdvancedOption={onChangeAdvancedOption}
+          onChangeSensitivity={onChangeSensitivity}
+          onChangeVisibility={onChangeVisibility}
+          onDoodleOpen={onOpenDoodleModal}
+          onModalClose={onCloseModal}
+          onModalOpen={onOpenActionsModal}
+          onToggleSpoiler={onChangeSpoilerness}
+          onUpload={onUpload}
+          privacy={privacy}
+          resetFileKey={resetFileKey}
+          sensitive={sensitive}
+          spoiler={spoiler}
+        />
+        <ComposerPublisher
+          countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
+          disabled={isSubmitting || isUploading || !!text.length && !text.trim().length}
+          intl={intl}
+          onSecondarySubmit={handleSecondarySubmit}
+          onSubmit={handleSubmit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+Composer.propTypes = {
+  intl: PropTypes.object.isRequired,
+
+  //  State props.
+  acceptContentTypes: PropTypes.string,
+  advancedOptions: ImmutablePropTypes.map,
+  amUnlocked: PropTypes.bool,
+  focusDate: PropTypes.instanceOf(Date),
+  isSubmitting: PropTypes.bool,
+  isUploading: PropTypes.bool,
+  layout: PropTypes.string,
+  media: ImmutablePropTypes.list,
+  preselectDate: PropTypes.instanceOf(Date),
+  privacy: PropTypes.string,
+  progress: PropTypes.number,
+  replyAccount: PropTypes.string,
+  replyContent: PropTypes.string,
+  resetFileKey: PropTypes.number,
+  sideArm: PropTypes.string,
+  sensitive: PropTypes.bool,
+  showSearch: PropTypes.bool,
+  spoiler: PropTypes.bool,
+  spoilerText: PropTypes.string,
+  suggestionToken: PropTypes.string,
+  suggestions: ImmutablePropTypes.list,
+  text: PropTypes.string,
+
+  //  Dispatch props.
+  onCancelReply: PropTypes.func,
+  onChangeAdvancedOption: PropTypes.func,
+  onChangeDescription: PropTypes.func,
+  onChangeSensitivity: PropTypes.func,
+  onChangeSpoilerText: PropTypes.func,
+  onChangeSpoilerness: PropTypes.func,
+  onChangeText: PropTypes.func,
+  onChangeVisibility: PropTypes.func,
+  onClearSuggestions: PropTypes.func,
+  onCloseModal: PropTypes.func,
+  onFetchSuggestions: PropTypes.func,
+  onInsertEmoji: PropTypes.func,
+  onMount: PropTypes.func,
+  onOpenActionsModal: PropTypes.func,
+  onOpenDoodleModal: PropTypes.func,
+  onSelectSuggestion: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onUndoUpload: PropTypes.func,
+  onUnmount: PropTypes.func,
+  onUpload: PropTypes.func,
+};
+
+//  Connecting and export.
+export { Composer as WrappedComponent };
+export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
new file mode 100644
index 000000000..b3a472999
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
@@ -0,0 +1,138 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import ComposerOptionsDropdownContentItem from './item';
+
+//  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';
+
+//  Handlers.
+const handlers = {
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  handleDocumentClick ({ target }) {
+    const { node } = this;
+    const { onClose } = this.props;
+    if (onClose && node && !node.contains(target)) {
+      onClose();
+    }
+  },
+
+  //  Stores our node in `this.node`.
+  handleRef (node) {
+    this.node = node;
+  },
+};
+
+//  The spring to use with our motion.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.node = null;
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    const { handleDocumentClick } = this.handlers;
+    document.addEventListener('click', handleDocumentClick, false);
+    document.addEventListener('touchend', handleDocumentClick, withPassive);
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    const { handleDocumentClick } = this.handlers;
+    document.removeEventListener('click', handleDocumentClick, false);
+    document.removeEventListener('touchend', handleDocumentClick, withPassive);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleRef } = this.handlers;
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+      value,
+    } = 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 }) => (
+          <div
+            className='composer--options--dropdown--content'
+            ref={handleRef}
+            style={{
+              ...style,
+              opacity: opacity,
+              transform: `scale(${scaleX}, ${scaleY})`,
+            }}
+          >
+            {items ? items.map(
+              ({
+                name,
+                ...rest
+              }) => (
+                <ComposerOptionsDropdownContentItem
+                  active={name === value}
+                  key={name}
+                  name={name}
+                  onChange={onChange}
+                  onClose={onClose}
+                  options={rest}
+                />
+              )
+            ) : null}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdownContent.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,
+  onClose: PropTypes.func,
+  style: PropTypes.object,
+  value: PropTypes.string,
+};
+
+//  Default props.
+ComposerOptionsDropdownContent.defaultProps = { style: {} };
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
new file mode 100644
index 000000000..68a52083f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
@@ -0,0 +1,129 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Toggle from 'react-toggle';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  This function activates the dropdown item.
+  handleActivate (e) {
+    const {
+      name,
+      onChange,
+      onClose,
+      options: { on },
+    } = this.props;
+
+    //  If the escape key was pressed, we close the dropdown.
+    if (e.key === 'Escape' && onClose) {
+      onClose();
+
+    //  Otherwise, we both close the dropdown and change the value.
+    } else if (onChange && (!e.key || e.key === 'Enter')) {
+      e.preventDefault();  //  Prevents change in focus on click
+      if ((on === null || typeof on === 'undefined') && onClose) {
+        onClose();
+      }
+      onChange(name);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdownContentItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleActivate } = this.handlers;
+    const {
+      active,
+      options: {
+        icon,
+        meta,
+        on,
+        text,
+      },
+    } = this.props;
+    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,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onClick={handleActivate}
+        onKeyDown={handleActivate}
+        role='button'
+        tabIndex='0'
+      >
+        {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={handleActivate}
+              />
+            );
+          case !!icon:
+            return (
+              <Icon
+                className='icon'
+                fullwidth
+                icon={icon}
+              />
+            );
+          default:
+            return null;
+          }
+        }()}
+        {meta ? (
+          <div className='content'>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        ) :
+          <div className='content'>
+            <strong>{text}</strong>
+          </div>}
+      </div>
+    );
+  }
+
+};
+
+//  Props.
+ComposerOptionsDropdownContentItem.propTypes = {
+  active: PropTypes.bool,
+  name: PropTypes.string,
+  onChange: PropTypes.func,
+  onClose: PropTypes.func,
+  options: PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  }),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
new file mode 100644
index 000000000..d63d90a9f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
@@ -0,0 +1,225 @@
+//  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 ComposerOptionsDropdownContent from './content';
+
+//  Utils.
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  Closes the dropdown.
+  handleClose () {
+    this.setState({ open: false });
+  },
+
+  //  The enter key toggles the dropdown's open state, and the escape
+  //  key closes it.
+  handleKeyDown ({ key }) {
+    const {
+      handleClose,
+      handleToggle,
+    } = this.handlers;
+    switch (key) {
+    case 'Enter':
+      handleToggle();
+      break;
+    case 'Escape':
+      handleClose();
+      break;
+    }
+  },
+
+  //  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 });
+          },
+        })
+      ),
+    };
+  },
+
+  //  Toggles opening and closing the dropdown.
+  handleToggle () {
+    const { handleMakeModal } = this.handlers;
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    //  If this is a touch device, we open a modal instead of the
+    //  dropdown.
+    if (isUserTouching()) {
+
+      //  This gets the modal to open.
+      const modal = handleMakeModal();
+
+      //  If we can, we then open the modal.
+      if (modal && onModalOpen) {
+        onModalOpen(modal);
+        return;
+      }
+    }
+
+    //  Otherwise, we just set our state to open.
+    this.setState({ open: !open });
+  },
+
+  //  If our modal is open and our props update, we need to also update
+  //  the modal.
+  handleUpdate () {
+    const { handleMakeModal } = this.handlers;
+    const { onModalOpen } = this.props;
+    const { needsModalUpdate } = this.state;
+
+    //  Gets our modal object.
+    const modal = handleMakeModal();
+
+    //  Reopens the modal with the new object.
+    if (needsModalUpdate && modal && onModalOpen) {
+      onModalOpen(modal);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      needsModalUpdate: false,
+      open: false,
+    };
+  }
+
+  //  Updates our modal as necessary.
+  componentDidUpdate (prevProps) {
+    const { handleUpdate } = this.handlers;
+    const { items } = this.props;
+    const { needsModalUpdate } = this.state;
+    if (needsModalUpdate && items.find(
+      (item, i) => item.on !== prevProps.items[i].on
+    )) {
+      handleUpdate();
+      this.setState({ needsModalUpdate: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleClose,
+      handleKeyDown,
+      handleToggle,
+    } = this.handlers;
+    const {
+      active,
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+    } = this.props;
+    const { open } = this.state;
+    const computedClass = classNames('composer--options--dropdown', {
+      active,
+      open,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onKeyDown={handleKeyDown}
+      >
+        <IconButton
+          active={open || active}
+          className='value'
+          disabled={disabled}
+          icon={icon}
+          onClick={handleToggle}
+          size={18}
+          style={{
+            height: null,
+            lineHeight: '27px',
+          }}
+          title={title}
+        />
+        <Overlay
+          containerPadding={20}
+          placement='bottom'
+          show={open}
+          target={this}
+        >
+          <ComposerOptionsDropdownContent
+            items={items}
+            onChange={onChange}
+            onClose={handleClose}
+            value={value}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdown.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,
+  onChange: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  title: PropTypes.string,
+  value: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js
new file mode 100644
index 000000000..c129622bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/index.js
@@ -0,0 +1,344 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from 'flavours/glitch/components/text_icon_button';
+import Dropdown from './dropdown';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  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',
+  },
+  direct_long: {
+    defaultMessage: 'Post to mentioned users only',
+    id: 'privacy.direct.long',
+  },
+  direct_short: {
+    defaultMessage: 'Direct',
+    id: 'privacy.direct.short',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  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',
+  },
+  private_long: {
+    defaultMessage: 'Post to followers only',
+    id: 'privacy.private.long',
+  },
+  private_short: {
+    defaultMessage: 'Followers-only',
+    id: 'privacy.private.short',
+  },
+  public_long: {
+    defaultMessage: 'Post to public timelines',
+    id: 'privacy.public.long',
+  },
+  public_short: {
+    defaultMessage: 'Public',
+    id: 'privacy.public.short',
+  },
+  sensitive: {
+    defaultMessage: 'Mark media as sensitive',
+    id: 'compose_form.sensitive',
+  },
+  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: 'Do not show in public timelines',
+    id: 'privacy.unlisted.long',
+  },
+  unlisted_short: {
+    defaultMessage: 'Unlisted',
+    id: 'privacy.unlisted.short',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  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;
+  },
+};
+
+//  The component.
+export default class ComposerOptions extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.fileElement = null;
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleChangeFiles,
+      handleClickAttach,
+      handleRefFileElement,
+    } = this.handlers;
+    const {
+      acceptContentTypes,
+      advancedOptions,
+      disabled,
+      full,
+      hasMedia,
+      intl,
+      onChangeAdvancedOption,
+      onChangeSensitivity,
+      onChangeVisibility,
+      onModalClose,
+      onModalOpen,
+      onToggleSpoiler,
+      privacy,
+      resetFileKey,
+      sensitive,
+      spoiler,
+    } = 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-alt',
+        meta: <FormattedMessage {...messages.unlisted_long} />,
+        name: 'unlisted',
+        text: <FormattedMessage {...messages.unlisted_short} />,
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='composer--options'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || full}
+          key={resetFileKey}
+          onChange={handleChangeFiles}
+          ref={handleRefFileElement}
+          type='file'
+          {...hiddenComponent}
+        />
+        <Dropdown
+          disabled={disabled || full}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: <FormattedMessage {...messages.upload} />,
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: <FormattedMessage {...messages.doodle} />,
+            },
+          ]}
+          onChange={handleClickAttach}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.attach)}
+        />
+        <Motion
+          defaultStyle={{ scale: 0.87 }}
+          style={{
+            scale: spring(hasMedia ? 1 : 0.87, {
+              stiffness: 200,
+              damping: 3,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div
+              style={{
+                display: hasMedia ? null : 'none',
+                transform: `scale(${scale})`,
+              }}
+            >
+              <IconButton
+                active={sensitive}
+                className='sensitive'
+                disabled={spoiler}
+                icon={sensitive ? 'eye-slash' : 'eye'}
+                inverted
+                onClick={onChangeSensitivity}
+                size={18}
+                style={{
+                  height: null,
+                  lineHeight: null,
+                }}
+                title={intl.formatMessage(messages.sensitive)}
+              />
+            </div>
+          )}
+        </Motion>
+        <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}
+        />
+        <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>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptions.propTypes = {
+  acceptContentTypes: PropTypes.string,
+  advancedOptions: ImmutablePropTypes.map,
+  disabled: PropTypes.bool,
+  full: PropTypes.bool,
+  hasMedia: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChangeAdvancedOption: PropTypes.func,
+  onChangeSensitivity: PropTypes.func,
+  onChangeVisibility: PropTypes.func,
+  onDoodleOpen: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  onToggleSpoiler: PropTypes.func,
+  onUpload: PropTypes.func,
+  privacy: PropTypes.string,
+  resetFileKey: PropTypes.number,
+  sensitive: PropTypes.bool,
+  spoiler: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js
new file mode 100644
index 000000000..5ded26f80
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/publisher/index.js
@@ -0,0 +1,122 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import { length } from 'stringz';
+
+//  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',
+  },
+});
+
+//  The component.
+export default function ComposerPublisher ({
+  countText,
+  disabled,
+  intl,
+  onSecondarySubmit,
+  onSubmit,
+  privacy,
+  sideArm,
+}) {
+  const diff = maxChars - length(countText || '');
+  const computedClass = classNames('composer--publisher', {
+    disabled: disabled || diff < 0,
+    over: diff < 0,
+  });
+
+  //  The result.
+  return (
+    <div className={computedClass}>
+      <span className='count'>{diff}</span>
+      {sideArm && sideArm !== 'none' ? (
+        <Button
+          className='side_arm'
+          disabled={disabled || diff < 0}
+          onClick={onSecondarySubmit}
+          style={{ padding: null }}
+          text={
+            <span>
+              <Icon
+                icon={{
+                  public: 'globe',
+                  unlisted: 'unlock-alt',
+                  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
+                  icon={{
+                    direct: 'envelope',
+                    private: 'lock',
+                    public: 'globe',
+                    unlisted: 'unlock-alt',
+                  }[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={onSubmit}
+        disabled={disabled || diff < 0}
+      />
+    </div>
+  );
+}
+
+//  Props.
+ComposerPublisher.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']),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js
new file mode 100644
index 000000000..0b8ceddee
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/reply/index.js
@@ -0,0 +1,85 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import { defineMessages } from 'react-intl';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { isRtl } from 'flavours/glitch/util/rtl';
+
+//  Messages.
+const messages = defineMessages({
+  cancel: {
+    defaultMessage: 'Cancel',
+    id: 'reply_indicator.cancel',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a click on the "close" button.
+  handleClick () {
+    const { onCancel } = this.props;
+    if (onCancel) {
+      onCancel();
+    }
+  },
+};
+
+//  The component.
+export default class ComposerReply extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleClick } = this.handlers;
+    const {
+      account,
+      content,
+      intl,
+    } = this.props;
+
+    //  The result.
+    return (
+      <article className='composer--reply'>
+        <header>
+          <IconButton
+            className='cancel'
+            icon='times'
+            onClick={handleClick}
+            title={intl.formatMessage(messages.cancel)}
+          />
+          {account ? (
+            <AccountContainer
+              id={account}
+              small
+            />
+          ) : null}
+        </header>
+        <div
+          className='content'
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+          style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }}
+        />
+      </article>
+    );
+  }
+
+}
+
+ComposerReply.propTypes = {
+  account: PropTypes.string,
+  content: PropTypes.string,
+  intl: PropTypes.object.isRequired,
+  onCancel: PropTypes.func,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
new file mode 100644
index 000000000..a49b0e10f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
@@ -0,0 +1,92 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  Components.
+import Collapsable from 'flavours/glitch/components/collapsable';
+
+//  Utils.
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'Write your warning here',
+    id: 'compose_form.spoiler_placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a keypress.
+  handleKeyDown ({
+    ctrlKey,
+    keyCode,
+    metaKey,
+  }) {
+    const { onSubmit } = this.props;
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
+      onSubmit();
+    }
+  },
+};
+
+//  The component.
+export default class ComposerSpoiler extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleKeyDown } = this.handlers;
+    const {
+      hidden,
+      intl,
+      onChange,
+      text,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Collapsable
+        isVisible={!hidden}
+        fullHeight={50}
+      >
+        <label className='composer--spoiler'>
+          <span {...hiddenComponent}>
+            <FormattedMessage {...messages.placeholder} />
+          </span>
+          <input
+            id='glitch.composer.spoiler.input'
+            onChange={onChange}
+            onKeyDown={handleKeyDown}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            type='text'
+            value={text}
+          />
+        </label>
+      </Collapsable>
+    );
+  }
+
+}
+
+//  Props.
+ComposerSpoiler.propTypes = {
+  hidden: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  text: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js
new file mode 100644
index 000000000..049cdd5cd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js
@@ -0,0 +1,60 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+
+//  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],
+];
+
+//  The component.
+export default function ComposerTextareaIcons ({
+  advancedOptions,
+  intl,
+}) {
+
+  //  The result. We just map every active option to its icon.
+  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
+              fullwidth
+              icon={icon}
+            />
+          </span>
+        ) : null
+      ) : null}
+    </div>
+  );
+}
+
+//  Props.
+ComposerTextareaIcons.propTypes = {
+  advancedOptions: ImmutablePropTypes.map,
+  intl: PropTypes.object.isRequired,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js
new file mode 100644
index 000000000..6c2b8baa2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js
@@ -0,0 +1,305 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import Textarea from 'react-textarea-autosize';
+
+//  Components.
+import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import ComposerTextareaIcons from './icons';
+import ComposerTextareaSuggestions from './suggestions';
+
+//  Utils.
+import { isRtl } from 'flavours/glitch/util/rtl';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'What is on your mind?',
+    id: 'compose_form.placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  When blurring the textarea, suggestions are hidden.
+  handleBlur () {
+    this.setState({ suggestionsHidden: true });
+  },
+
+  //  When the contents of the textarea change, we have to pull up new
+  //  autosuggest suggestions if applicable, and also change the value
+  //  of the textarea in our store.
+  handleChange ({
+    target: {
+      selectionStart,
+      value,
+    },
+  }) {
+    const {
+      onChange,
+      onSuggestionsFetchRequested,
+      onSuggestionsClearRequested,
+    } = this.props;
+    const { lastToken } = this.state;
+
+    //  This gets the token at the caret location, if it begins with an
+    //  `@` (mentions) or `:` (shortcodes).
+    const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
+    const right = value.slice(selectionStart).search(/[\s\u200B]/);
+    const token = function () {
+      switch (true) {
+      case left < 0 || !/[@:]/.test(value[left]):
+        return null;
+      case right < 0:
+        return value.slice(left);
+      default:
+        return value.slice(left, right + selectionStart).trim().toLowerCase();
+      }
+    }();
+
+    //  We only request suggestions for tokens which are at least 3
+    //  characters long.
+    if (onSuggestionsFetchRequested && token && token.length >= 3) {
+      if (lastToken !== token) {
+        this.setState({
+          lastToken: token,
+          selectedSuggestion: 0,
+          tokenStart: left,
+        });
+        onSuggestionsFetchRequested(token);
+      }
+    } else {
+      this.setState({ lastToken: null });
+      if (onSuggestionsClearRequested) {
+        onSuggestionsClearRequested();
+      }
+    }
+
+    //  Updates the value of the textarea.
+    if (onChange) {
+      onChange(value);
+    }
+  },
+
+  //  Handles a click on an autosuggestion.
+  handleClickSuggestion (index) {
+    const { textarea } = this;
+    const {
+      onSuggestionSelected,
+      suggestions,
+    } = this.props;
+    const {
+      lastToken,
+      tokenStart,
+    } = this.state;
+    onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
+    textarea.focus();
+  },
+
+  //  Handles a keypress.  If the autosuggestions are visible, we need
+  //  to allow keypresses to navigate and sleect them.
+  handleKeyDown (e) {
+    const {
+      disabled,
+      onSubmit,
+      onSuggestionSelected,
+      suggestions,
+    } = this.props;
+    const {
+      lastToken,
+      suggestionsHidden,
+      selectedSuggestion,
+      tokenStart,
+    } = this.state;
+
+    //  Keypresses do nothing if the composer is disabled.
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      onSubmit();
+    }
+
+    //  Switches over the pressed key.
+    switch(e.key) {
+
+    //  On arrow down, we pick the next suggestion.
+    case 'ArrowDown':
+      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+      return;
+
+    //  On arrow up, we pick the previous suggestion.
+    case 'ArrowUp':
+      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+      return;
+
+    //  On enter or tab, we select the suggestion.
+    case 'Enter':
+    case 'Tab':
+      if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
+      }
+      return;
+    }
+  },
+
+  //  When the escape key is released, we either close the suggestions
+  //  window or focus the UI.
+  handleKeyUp ({ key }) {
+    const { suggestionsHidden } = this.state;
+    if (key === 'Escape') {
+      if (!suggestionsHidden) {
+        this.setState({ suggestionsHidden: true });
+      } else {
+        document.querySelector('.ui').parentElement.focus();
+      }
+    }
+  },
+
+  //  Handles the pasting of images into the composer.
+  handlePaste (e) {
+    const { onPaste } = this.props;
+    let d;
+    if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
+      onPaste(d);
+      e.preventDefault();
+    }
+  },
+
+  //  Saves a reference to the textarea.
+  handleRefTextarea (textarea) {
+    this.textarea = textarea;
+  },
+};
+
+//  The component.
+export default class ComposerTextarea extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      suggestionsHidden: false,
+      selectedSuggestion: 0,
+      lastToken: null,
+      tokenStart: 0,
+    };
+
+    //  Instance variables.
+    this.textarea = null;
+  }
+
+  //  When we receive new suggestions, we unhide the suggestions window
+  //  if we didn't have any suggestions before.
+  componentWillReceiveProps (nextProps) {
+    const { suggestions } = this.props;
+    const { suggestionsHidden } = this.state;
+    if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleBlur,
+      handleChange,
+      handleClickSuggestion,
+      handleKeyDown,
+      handleKeyUp,
+      handlePaste,
+      handleRefTextarea,
+    } = this.handlers;
+    const {
+      advancedOptions,
+      autoFocus,
+      disabled,
+      intl,
+      onPickEmoji,
+      suggestions,
+      value,
+    } = this.props;
+    const {
+      selectedSuggestion,
+      suggestionsHidden,
+    } = this.state;
+
+    //  The result.
+    return (
+      <div className='composer--textarea'>
+        <label>
+          <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
+          <ComposerTextareaIcons
+            advancedOptions={advancedOptions}
+            intl={intl}
+          />
+          <Textarea
+            aria-autocomplete='list'
+            autoFocus={autoFocus}
+            className='textarea'
+            disabled={disabled}
+            inputRef={handleRefTextarea}
+            onBlur={handleBlur}
+            onChange={handleChange}
+            onKeyDown={handleKeyDown}
+            onKeyUp={handleKeyUp}
+            onPaste={handlePaste}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
+          />
+        </label>
+        <EmojiPicker onPickEmoji={onPickEmoji} />
+        <ComposerTextareaSuggestions
+          hidden={suggestionsHidden}
+          onSuggestionClick={handleClickSuggestion}
+          suggestions={suggestions}
+          value={selectedSuggestion}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerTextarea.propTypes = {
+  advancedOptions: ImmutablePropTypes.map,
+  autoFocus: PropTypes.bool,
+  disabled: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onPaste: PropTypes.func,
+  onPickEmoji: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onSuggestionsClearRequested: PropTypes.func,
+  onSuggestionsFetchRequested: PropTypes.func,
+  onSuggestionSelected: PropTypes.func,
+  suggestions: ImmutablePropTypes.list,
+  value: PropTypes.string,
+};
+
+//  Default props.
+ComposerTextarea.defaultProps = { autoFocus: true };
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
new file mode 100644
index 000000000..dc72585f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
@@ -0,0 +1,43 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Components.
+import ComposerTextareaSuggestionsItem from './item';
+
+//  The component.
+export default function ComposerTextareaSuggestions ({
+  hidden,
+  onSuggestionClick,
+  suggestions,
+  value,
+}) {
+
+  //  The result.
+  return (
+    <div
+      className='composer--textarea--suggestions'
+      hidden={hidden || !suggestions || suggestions.isEmpty()}
+    >
+      {!hidden && suggestions ? suggestions.map(
+        (suggestion, index) => (
+          <ComposerTextareaSuggestionsItem
+            index={index}
+            key={typeof suggestion === 'object' ? suggestion.id : suggestion}
+            onClick={onSuggestionClick}
+            selected={index === value}
+            suggestion={suggestion}
+          />
+        )
+      ) : null}
+    </div>
+  );
+}
+
+ComposerTextareaSuggestions.propTypes = {
+  hidden: PropTypes.bool,
+  onSuggestionClick: PropTypes.func,
+  suggestions: ImmutablePropTypes.list,
+  value: PropTypes.number,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
new file mode 100644
index 000000000..f55640bcf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
@@ -0,0 +1,112 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+
+//  Utils.
+import { unicodeMapping } from 'flavours/glitch/util/emoji';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Gets our asset host from the environment, if available.
+const assetHost = ((process || {}).env || {}).CDN_HOST || '';
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a click on a suggestion.
+  handleClick (e) {
+    const {
+      index,
+      onClick,
+    } = this.props;
+    if (onClick) {
+      e.preventDefault();
+      e.stopPropagation();  //  Prevents following account links
+      onClick(index);
+    }
+  },
+
+  //  This prevents the focus from changing, which would mess with
+  //  our suggestion code.
+  handleMouseDown (e) {
+    e.preventDefault();
+  },
+};
+
+//  The component.
+export default class ComposerTextareaSuggestionsItem extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleMouseDown,
+      handleClick,
+    } = this.handlers;
+    const {
+      selected,
+      suggestion,
+    } = this.props;
+    const computedClass = classNames('composer--textarea--suggestions--item', { selected });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onMouseDown={handleMouseDown}
+        onClickCapture={handleClick}  //  Jumps in front of contents
+        role='button'
+        tabIndex='0'
+      >
+        { //  If the suggestion is an object, then we render an emoji.
+          //  Otherwise, we render an account.
+          typeof suggestion === 'object' ? function () {
+            const url = function () {
+              if (suggestion.custom) {
+                return suggestion.imageUrl;
+              } else {
+                const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
+                if (!mapping) {
+                  return null;
+                }
+                return `${assetHost}/emoji/${mapping.filename}.svg`;
+              }
+            }();
+            return url ? (
+              <div className='emoji'>
+                <img
+                  alt={suggestion.native || suggestion.colons}
+                  className='emojione'
+                  src={url}
+                />
+                {suggestion.colons}
+              </div>
+            ) : null;
+          }() : (
+            <AccountContainer
+              id={suggestion}
+              small
+            />
+          )
+        }
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerTextareaSuggestionsItem.propTypes = {
+  index: PropTypes.number,
+  onClick: PropTypes.func,
+  selected: PropTypes.bool,
+  suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
new file mode 100644
index 000000000..53b14acc7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
@@ -0,0 +1,53 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Components.
+import ComposerUploadFormItem from './item';
+import ComposerUploadFormProgress from './progress';
+
+//  The component.
+export default function ComposerUploadForm ({
+  intl,
+  media,
+  onChangeDescription,
+  onRemove,
+  progress,
+  uploading,
+}) {
+  const computedClass = classNames('composer--upload_form', { uploading });
+
+  //  The result.
+  return (
+    <div className={computedClass}>
+      {uploading ? <ComposerUploadFormProgress progress={progress} /> : null}
+      {media ? (
+        <div className='content'>
+          {media.map(item => (
+            <ComposerUploadFormItem
+              description={item.get('description')}
+              key={item.get('id')}
+              id={item.get('id')}
+              intl={intl}
+              preview={item.get('preview_url')}
+              onChangeDescription={onChangeDescription}
+              onRemove={onRemove}
+            />
+          ))}
+        </div>
+      ) : null}
+    </div>
+  );
+}
+
+//  Props.
+ComposerUploadForm.propTypes = {
+  intl: PropTypes.object.isRequired,
+  media: ImmutablePropTypes.list,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  progress: PropTypes.number,
+  uploading: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
new file mode 100644
index 000000000..ec67b8ef8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
@@ -0,0 +1,177 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  undo: {
+    defaultMessage: 'Undo',
+    id: 'upload_form.undo',
+  },
+  description: {
+    defaultMessage: 'Describe for the visually impaired',
+    id: 'upload_form.description',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  On blur, we save the description for the media item.
+  handleBlur () {
+    const {
+      id,
+      onChangeDescription,
+    } = this.props;
+    const { dirtyDescription } = this.state;
+    if (id && onChangeDescription && dirtyDescription !== null) {
+      this.setState({
+        dirtyDescription: null,
+        focused: false,
+      });
+      onChangeDescription(id, dirtyDescription);
+    }
+  },
+
+  //  When the value of our description changes, we store it in the
+  //  temp value `dirtyDescription` in our state.
+  handleChange ({ target: { value } }) {
+    this.setState({ dirtyDescription: value });
+  },
+
+  //  Records focus on the media item.
+  handleFocus () {
+    this.setState({ focused: true });
+  },
+
+  //  Records the start of a hover over the media item.
+  handleMouseEnter () {
+    this.setState({ hovered: true });
+  },
+
+  //  Records the end of a hover over the media item.
+  handleMouseLeave () {
+    this.setState({ hovered: false });
+  },
+
+  //  Removes the media item.
+  handleRemove () {
+    const {
+      id,
+      onRemove,
+    } = this.props;
+    if (id && onRemove) {
+      onRemove(id);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerUploadFormItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      hovered: false,
+      focused: false,
+      dirtyDescription: null,
+    };
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleBlur,
+      handleChange,
+      handleFocus,
+      handleMouseEnter,
+      handleMouseLeave,
+      handleRemove,
+    } = this.handlers;
+    const {
+      description,
+      intl,
+      preview,
+    } = this.props;
+    const {
+      focused,
+      hovered,
+      dirtyDescription,
+    } = this.state;
+    const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onMouseEnter={handleMouseEnter}
+        onMouseLeave={handleMouseLeave}
+      >
+        <Motion
+          defaultStyle={{ scale: 0.8 }}
+          style={{
+            scale: spring(1, {
+              stiffness: 180,
+              damping: 12,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div
+              style={{
+                transform: `scale(${scale})`,
+                backgroundImage: preview ? `url(${preview})` : null,
+              }}
+            >
+              <IconButton
+                className='close'
+                icon='times'
+                onClick={handleRemove}
+                size={36}
+                title={intl.formatMessage(messages.undo)}
+              />
+              <label>
+                <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
+                <input
+                  maxLength={420}
+                  onBlur={handleBlur}
+                  onChange={handleChange}
+                  onFocus={handleFocus}
+                  placeholder={intl.formatMessage(messages.description)}
+                  type='text'
+                  value={dirtyDescription || description || ''}
+                />
+              </label>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerUploadFormItem.propTypes = {
+  description: PropTypes.string,
+  id: PropTypes.string,
+  intl: PropTypes.object.isRequired,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  preview: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
new file mode 100644
index 000000000..8c4b0eea6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
@@ -0,0 +1,52 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+//  Messages.
+const messages = defineMessages({
+  upload: {
+    defaultMessage: 'Uploading...',
+    id: 'upload_progress.label',
+  },
+});
+
+//  The component.
+export default function ComposerUploadFormProgress ({ progress }) {
+
+  //  The result.
+  return (
+    <div className='composer--upload_form--progress'>
+      <Icon icon='upload' />
+      <div className='message'>
+        <FormattedMessage {...messages.upload} />
+        <div className='backdrop'>
+          <Motion
+            defaultStyle={{ width: 0 }}
+            style={{ width: spring(progress) }}
+          >
+            {({ width }) =>
+              (<div
+                className='tracker'
+                style={{ width: `${width}%` }}
+              />)
+            }
+          </Motion>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+//  Props.
+ComposerUploadFormProgress.propTypes = { progress: PropTypes.number };
diff --git a/app/javascript/flavours/glitch/features/composer/warning/index.js b/app/javascript/flavours/glitch/features/composer/warning/index.js
new file mode 100644
index 000000000..c225b50e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/warning/index.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  This is the spring used with our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+//  Messages.
+const messages = defineMessages({
+  disclaimer: {
+    defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.',
+    id: 'compose_form.lock_disclaimer',
+  },
+  locked: {
+    defaultMessage: 'locked',
+    id: 'compose_form.lock_disclaimer.lock',
+  },
+});
+
+//  The component.
+export default function ComposerWarning () {
+  return (
+    <Motion
+      defaultStyle={{
+        opacity: 0,
+        scaleX: 0.85,
+        scaleY: 0.75,
+      }}
+      style={{
+        opacity: motionSpring,
+        scaleX: motionSpring,
+        scaleY: motionSpring,
+      }}
+    >
+      {({ opacity, scaleX, scaleY }) => (
+        <div
+          className='composer--warning'
+          style={{
+            opacity: opacity,
+            transform: `scale(${scaleX}, ${scaleY})`,
+          }}
+        >
+          <FormattedMessage
+            {...messages.disclaimer}
+            values={{ locked: <a href='/settings/profile'><FormattedMessage {...messages.locked} /></a> }}
+          />
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+ComposerWarning.propTypes = {};
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..7292af264
--- /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 'flavours/glitch/features/community_timeline/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/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js
new file mode 100644
index 000000000..81096c0ec
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import {
+  refreshDirectTimeline,
+  expandDirectTimeline,
+} from 'flavours/glitch/actions/timelines';
+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';
+
+const messages = defineMessages({
+  title: { id: 'column.direct', defaultMessage: 'Direct messages' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class DirectTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECT', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshDirectTimeline());
+    this.disconnect = dispatch(connectDirectStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandDirectTimeline());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='envelope'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/drawer/account/index.js b/app/javascript/flavours/glitch/features/drawer/account/index.js
new file mode 100644
index 000000000..168d0c2cf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/account/index.js
@@ -0,0 +1,71 @@
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+
+//  Components.
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+
+//  Utils.
+import { hiddenComponent } from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  edit: {
+    defaultMessage: 'Edit profile',
+    id: 'navigation_bar.edit_profile',
+  },
+});
+
+//  The component.
+export default function DrawerAccount ({ account }) {
+
+  //  We need an account to render.
+  if (!account) {
+    return (
+      <div className='drawer--account'>
+        <a
+          className='edit'
+          href='/settings/profile'
+        >
+          <FormattedMessage {...messages.edit} />
+        </a>
+      </div>
+    );
+  }
+
+  //  The result.
+  return (
+    <div className='drawer--account'>
+      <Permalink
+        className='avatar'
+        href={account.get('url')}
+        to={`/accounts/${account.get('id')}`}
+      >
+        <span {...hiddenComponent}>{account.get('acct')}</span>
+        <Avatar
+          account={account}
+          size={40}
+        />
+      </Permalink>
+      <Permalink
+        className='acct'
+        href={account.get('url')}
+        to={`/accounts/${account.get('id')}`}
+      >
+        <strong>@{account.get('acct')}</strong>
+      </Permalink>
+      <a
+        className='edit'
+        href='/settings/profile'
+      ><FormattedMessage {...messages.edit} /></a>
+    </div>
+  );
+}
+
+//  Props.
+DrawerAccount.propTypes = { account: ImmutablePropTypes.map };
diff --git a/app/javascript/flavours/glitch/features/drawer/header/index.js b/app/javascript/flavours/glitch/features/drawer/header/index.js
new file mode 100644
index 000000000..6949cd028
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/header/index.js
@@ -0,0 +1,118 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+import { Link } from 'react-router-dom';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { conditionalRender } from 'flavours/glitch/util/react_helpers';
+
+//  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',
+  },
+});
+
+//  The component.
+export default function DrawerHeader ({
+  columns,
+  intl,
+  onSettingsClick,
+}) {
+
+  //  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 icon='asterisk' /></Link>
+      {renderForColumn('HOME', (
+        <Link
+          aria-label={intl.formatMessage(messages.home_timeline)}
+          title={intl.formatMessage(messages.home_timeline)}
+          to='/timelines/home'
+        ><Icon icon='home' /></Link>
+      ))}
+      {renderForColumn('NOTIFICATIONS', (
+        <Link
+          aria-label={intl.formatMessage(messages.notifications)}
+          title={intl.formatMessage(messages.notifications)}
+          to='/notifications'
+        ><Icon icon='bell' /></Link>
+      ))}
+      {renderForColumn('COMMUNITY', (
+        <Link
+          aria-label={intl.formatMessage(messages.community)}
+          title={intl.formatMessage(messages.community)}
+          to='/timelines/public/local'
+        ><Icon icon='users' /></Link>
+      ))}
+      {renderForColumn('PUBLIC', (
+        <Link
+          aria-label={intl.formatMessage(messages.public)}
+          title={intl.formatMessage(messages.public)}
+          to='/timelines/public'
+        ><Icon icon='globe' /></Link>
+      ))}
+      <a
+        aria-label={intl.formatMessage(messages.settings)}
+        onClick={onSettingsClick}
+        role='button'
+        title={intl.formatMessage(messages.settings)}
+        tabIndex='0'
+      ><Icon icon='cogs' /></a>
+      <a
+        aria-label={intl.formatMessage(messages.logout)}
+        data-method='delete'
+        href='/auth/sign_out'
+        title={intl.formatMessage(messages.logout)}
+      ><Icon icon='sign-out' /></a>
+    </nav>
+  );
+}
+
+//  Props.
+DrawerHeader.propTypes = {
+  columns: ImmutablePropTypes.list,
+  intl: PropTypes.object,
+  onSettingsClick: PropTypes.func,
+};
diff --git a/app/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js
new file mode 100644
index 000000000..e6a689575
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/index.js
@@ -0,0 +1,137 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import classNames from 'classnames';
+
+//  Actions.
+import { openModal } from 'flavours/glitch/actions/modal';
+import {
+  changeSearch,
+  clearSearch,
+  showSearch,
+  submitSearch,
+} from 'flavours/glitch/actions/search';
+import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
+
+//  Components.
+import Composer from 'flavours/glitch/features/composer';
+import DrawerAccount from './account';
+import DrawerHeader from './header';
+import DrawerResults from './results';
+import DrawerSearch from './search';
+
+//  Utils.
+import { me } from 'flavours/glitch/util/initial_state';
+import { wrap } from 'flavours/glitch/util/redux_helpers';
+
+//  State mapping.
+const mapStateToProps = state => ({
+  account: state.getIn(['accounts', me]),
+  columns: state.getIn(['settings', 'columns']),
+  elefriend: state.getIn(['compose', 'elefriend']),
+  results: state.getIn(['search', 'results']),
+  searchHidden: state.getIn(['search', 'hidden']),
+  searchValue: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted']),
+});
+
+//  Dispatch mapping.
+const mapDispatchToProps = {
+  onChange: changeSearch,
+  onClear: clearSearch,
+  onClickElefriend: cycleElefriendCompose,
+  onShow: showSearch,
+  onSubmit: submitSearch,
+  onOpenSettings: openModal.bind(null, 'SETTINGS', {}),
+};
+
+//  The component.
+class Drawer extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      account,
+      columns,
+      elefriend,
+      intl,
+      multiColumn,
+      onChange,
+      onClear,
+      onClickElefriend,
+      onOpenSettings,
+      onShow,
+      onSubmit,
+      results,
+      searchHidden,
+      searchValue,
+      submitted,
+    } = this.props;
+    const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
+
+    //  The result.
+    return (
+      <div className={computedClass}>
+        {multiColumn ? (
+          <DrawerHeader
+            columns={columns}
+            intl={intl}
+            onSettingsClick={onOpenSettings}
+          />
+        ) : null}
+        <DrawerSearch
+          intl={intl}
+          onChange={onChange}
+          onClear={onClear}
+          onShow={onShow}
+          onSubmit={onSubmit}
+          submitted={submitted}
+          value={searchValue}
+        />
+        <div className='contents'>
+          <DrawerAccount account={account} />
+          <Composer />
+          {multiColumn && <button className='mastodon' onClick={onClickElefriend} />}
+          <DrawerResults
+            results={results}
+            visible={submitted && !searchHidden}
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+Drawer.propTypes = {
+  intl: PropTypes.object.isRequired,
+  multiColumn: PropTypes.bool,
+
+  //  State props.
+  account: ImmutablePropTypes.map,
+  columns: ImmutablePropTypes.list,
+  results: ImmutablePropTypes.map,
+  elefriend: PropTypes.number,
+  searchHidden: PropTypes.bool,
+  searchValue: PropTypes.string,
+  submitted: PropTypes.bool,
+
+  //  Dispatch props.
+  onChange: PropTypes.func,
+  onClear: PropTypes.func,
+  onClickElefriend: PropTypes.func,
+  onShow: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onOpenSettings: PropTypes.func,
+};
+
+//  Connecting and export.
+export { Drawer as WrappedComponent };
+export default wrap(Drawer, mapStateToProps, mapDispatchToProps, true);
diff --git a/app/javascript/flavours/glitch/features/drawer/results/index.js b/app/javascript/flavours/glitch/features/drawer/results/index.js
new file mode 100644
index 000000000..f2a79eb59
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/results/index.js
@@ -0,0 +1,116 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+import { Link } from 'react-router-dom';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+//  Messages.
+const messages = defineMessages({
+  total: {
+    defaultMessage: '{count, number} {count, plural, one {result} other {results}}',
+    id: 'search_results.total',
+  },
+});
+
+//  The component.
+export default function DrawerResults ({
+  results,
+  visible,
+}) {
+  const accounts = results ? results.get('accounts') : null;
+  const statuses = results ? results.get('statuses') : null;
+  const hashtags = results ? results.get('hashtags') : null;
+
+  //  This gets the total number of items.
+  const count = [accounts, statuses, hashtags].reduce(function (size, item) {
+    if (item && item.size) {
+      return size + item.size;
+    }
+    return size;
+  }, 0);
+
+  //  The result.
+  return (
+    <Motion
+      defaultStyle={{ x: -100 }}
+      style={{
+        x: spring(visible ? 0 : -100, {
+          stiffness: 210,
+          damping: 20,
+        }),
+      }}
+    >
+      {({ x }) => (
+        <div
+          className='drawer--results'
+          style={{
+            transform: `translateX(${x}%)`,
+            visibility: x === -100 ? 'hidden' : 'visible',
+          }}
+        >
+          <header>
+            <FormattedMessage
+              {...messages.total}
+              values={{ count }}
+            />
+          </header>
+          {accounts && accounts.size ? (
+            <section>
+              {accounts.map(
+                accountId => (
+                  <AccountContainer
+                    id={accountId}
+                    key={accountId}
+                  />
+                )
+              )}
+            </section>
+          ) : null}
+          {statuses && statuses.size ? (
+            <section>
+              {statuses.map(
+                statusId => (
+                  <StatusContainer
+                    id={statusId}
+                    key={statusId}
+                  />
+                )
+              )}
+            </section>
+          ) : null}
+          {hashtags && hashtags.size ? (
+            <section>
+              {hashtags.map(
+                hashtag => (
+                  <Link
+                    className='hashtag'
+                    key={hashtag}
+                    to={`/timelines/tag/${hashtag}`}
+                  >#{hashtag}</Link>
+                )
+              )}
+            </section>
+          ) : null}
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+//  Props.
+DrawerResults.propTypes = {
+  results: ImmutablePropTypes.map,
+  visible: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/drawer/search/index.js b/app/javascript/flavours/glitch/features/drawer/search/index.js
new file mode 100644
index 000000000..8cbb0906c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/search/index.js
@@ -0,0 +1,152 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+import DrawerSearchPopout from './popout';
+
+//  Utils.
+import { focusRoot } from 'flavours/glitch/util/dom_helpers';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'Search',
+    id: 'search.placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  handleBlur () {
+    this.setState({ expanded: false });
+  },
+
+  handleChange ({ target: { value } }) {
+    const { onChange } = this.props;
+    if (onChange) {
+      onChange(value);
+    }
+  },
+
+  handleClear (e) {
+    const {
+      onClear,
+      submitted,
+      value,
+    } = this.props;
+    e.preventDefault();  //  Prevents focus change ??
+    if (onClear && (submitted || value && value.length)) {
+      onClear();
+    }
+  },
+
+  handleFocus () {
+    const { onShow } = this.props;
+    this.setState({ expanded: true });
+    if (onShow) {
+      onShow();
+    }
+  },
+
+  handleKeyUp (e) {
+    const { onSubmit } = this.props;
+    switch (e.key) {
+    case 'Enter':
+      if (onSubmit) {
+        onSubmit();
+      }
+      break;
+    case 'Escape':
+      focusRoot();
+    }
+  },
+};
+
+//  The component.
+export default class DrawerSearch extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = { expanded: false };
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleBlur,
+      handleChange,
+      handleClear,
+      handleFocus,
+      handleKeyUp,
+    } = this.handlers;
+    const {
+      intl,
+      submitted,
+      value,
+    } = this.props;
+    const { expanded } = this.state;
+    const active = value && value.length || submitted;
+    const computedClass = classNames('drawer--search', { active });
+
+    return (
+      <div className={computedClass}>
+        <label>
+          <span {...hiddenComponent}>
+            <FormattedMessage {...messages.placeholder} />
+          </span>
+          <input
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value || ''}
+            onChange={handleChange}
+            onKeyUp={handleKeyUp}
+            onFocus={handleFocus}
+            onBlur={handleBlur}
+          />
+        </label>
+        <div
+          aria-label={intl.formatMessage(messages.placeholder)}
+          className='icon'
+          onClick={handleClear}
+          role='button'
+          tabIndex='0'
+        >
+          <Icon icon='search' />
+          <Icon icon='times-circle' />
+        </div>
+        <Overlay
+          placement='bottom'
+          show={expanded && !active}
+          target={this}
+        ><DrawerSearchPopout /></Overlay>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+DrawerSearch.propTypes = {
+  value: PropTypes.string,
+  submitted: PropTypes.bool,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onClear: PropTypes.func,
+  onShow: PropTypes.func,
+  intl: PropTypes.object,
+};
diff --git a/app/javascript/flavours/glitch/features/drawer/search/popout/index.js b/app/javascript/flavours/glitch/features/drawer/search/popout/index.js
new file mode 100644
index 000000000..6219f46ca
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/search/popout/index.js
@@ -0,0 +1,104 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+//  Messages.
+const messages = defineMessages({
+  format: {
+    defaultMessage: 'Advanced search format',
+    id: 'search_popout.search_format',
+  },
+  hashtag: {
+    defaultMessage: 'hashtag',
+    id: 'search_popout.tips.hashtag',
+  },
+  status: {
+    defaultMessage: 'status',
+    id: 'search_popout.tips.status',
+  },
+  text: {
+    defaultMessage: 'Simple text returns matching display names, usernames and hashtags',
+    id: 'search_popout.tips.text',
+  },
+  user: {
+    defaultMessage: 'user',
+    id: 'search_popout.tips.user',
+  },
+});
+
+//  The spring used by our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+//  The component.
+export default function DrawerSearchPopout ({ style }) {
+
+  //  The result.
+  return (
+    <div
+      className='drawer--search--popout'
+      style={{
+        ...style,
+        position: 'absolute',
+        width: 285,
+      }}
+    >
+      <Motion
+        defaultStyle={{
+          opacity: 0,
+          scaleX: 0.85,
+          scaleY: 0.75,
+        }}
+        style={{
+          opacity: motionSpring,
+          scaleX: motionSpring,
+          scaleY: motionSpring,
+        }}
+      >
+        {({ opacity, scaleX, scaleY }) => (
+          <div
+            style={{
+              opacity: opacity,
+              transform: `scale(${scaleX}, ${scaleY})`,
+            }}
+          >
+            <h4><FormattedMessage {...messages.format} /></h4>
+            <ul>
+              <li>
+                <em>#example</em>
+                {' '}
+                <FormattedMessage {...messages.hashtag} />
+              </li>
+              <li>
+                <em>@username@domain</em>
+                {' '}
+                <FormattedMessage {...messages.user} />
+              </li>
+              <li>
+                <em>URL</em>
+                {' '}
+                <FormattedMessage {...messages.user} />
+              </li>
+              <li>
+                <em>URL</em>
+                {' '}
+                <FormattedMessage {...messages.status} />
+              </li>
+            </ul>
+            <FormattedMessage {...messages.text} />
+          </div>
+        )}
+      </Motion>
+    </div>
+  );
+}
+
+//  Props.
+DrawerSearchPopout.propTypes = { style: PropTypes.object };
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..4b1ef6c97
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -0,0 +1,456 @@
+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 } from 'flavours/glitch/util/emoji';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+  people: { id: 'emoji_button.people', defaultMessage: 'People' },
+  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+  activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+  travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+  objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+  symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
+});
+
+const 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.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+const categoriesSort = [
+  'recent',
+  'custom',
+  'people',
+  'nature',
+  'foods',
+  'activity',
+  'places',
+  'objects',
+  'symbols',
+  'flags',
+];
+
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  }
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  }
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    loading: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    onPick: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    placement: PropTypes.string,
+    arrowOffsetLeft: PropTypes.string,
+    arrowOffsetTop: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+    loading: true,
+    placement: 'bottom',
+    frequentlyUsedEmojis: [],
+  };
+
+  state = {
+    modifierOpen: false,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  getI18n = () => {
+    const { intl } = this.props;
+
+    return {
+      search: intl.formatMessage(messages.emoji_search),
+      notfound: intl.formatMessage(messages.emoji_not_found),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  }
+
+  handleClick = emoji => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+
+    this.props.onClose();
+    this.props.onPick(emoji);
+  }
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  }
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  }
+
+  handleModifierChange = modifier => {
+    this.props.onSkinTone(modifier);
+  }
+
+  render () {
+    const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
+    const title = intl.formatMessage(messages.emoji);
+    const { modifierOpen } = this.state;
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <EmojiPicker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis)}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
+          backgroundImageFn={backgroundImageFn}
+          emojiTooltip
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={skinTone}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class EmojiPickerDropdown extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
+  };
+
+  state = {
+    active: false,
+    loading: false,
+  };
+
+  setRef = (c) => {
+    this.dropdown = c;
+  }
+
+  onShowDropdown = () => {
+    this.setState({ active: true });
+
+    if (!EmojiPicker) {
+      this.setState({ loading: true });
+
+      EmojiPickerAsync().then(EmojiMart => {
+        EmojiPicker = EmojiMart.Picker;
+        Emoji       = EmojiMart.Emoji;
+
+        this.setState({ loading: false });
+      }).catch(() => {
+        this.setState({ loading: false });
+      });
+    }
+  }
+
+  onHideDropdown = () => {
+    this.setState({ active: false });
+  }
+
+  onToggle = (e) => {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+      if (this.state.active) {
+        this.onHideDropdown();
+      } else {
+        this.onShowDropdown();
+      }
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  }
+
+  setTargetRef = c => {
+    this.target = c;
+  }
+
+  findTarget = () => {
+    return this.target;
+  }
+
+  render () {
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { active, loading } = this.state;
+
+    return (
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
+          <img
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
+            alt='🙂'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />
+        </div>
+
+        <Overlay show={active} placement='bottom' target={this.findTarget}>
+          <EmojiPickerMenu
+            custom_emojis={this.props.custom_emojis}
+            loading={loading}
+            onClose={this.onHideDropdown}
+            onPick={onPickEmoji}
+            onSkinTone={onSkinTone}
+            skinTone={skinTone}
+            frequentlyUsedEmojis={frequentlyUsedEmojis}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js
new file mode 100644
index 000000000..301a5ae4f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/favourited_statuses/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 { 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 } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { debounce } from 'lodash';
+
+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']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Favourites extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    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;
+  }
+
+  handleScrollToBottom = debounce(() => {
+    this.props.dispatch(expandFavouritedStatuses());
+  }, 300, { leading: true })
+
+  render () {
+    const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='favourites'>
+        <ColumnHeader
+          icon='star'
+          title={intl.formatMessage(messages.heading)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+        />
+
+        <StatusList
+          trackScroll={!pinned}
+          statusIds={statusIds}
+          scrollKey={`favourited_statuses-${columnId}`}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onScrollToBottom={this.handleScrollToBottom}
+        />
+      </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..055a15ccb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/favourites/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { fetchFavourites } from 'flavours/glitch/actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Favourites extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavourites(this.props.params.statusId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchFavourites(nextProps.params.statusId));
+    }
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='favourites'>
+          <div className='scrollable'>
+            {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..dead0753f
--- /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' },
+});
+
+@injectIntl
+export default class AccountAuthorize extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onAuthorize: PropTypes.func.isRequired,
+    onReject: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { intl, account, onAuthorize, onReject } = this.props;
+    const content = { __html: account.get('note_emojified') };
+
+    return (
+      <div className='account-authorize__wrapper'>
+        <div className='account-authorize'>
+          <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
+            <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='account__header__content' dangerouslySetInnerHTML={content} />
+        </div>
+
+        <div className='account--panel'>
+          <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
+          <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..04ff3f111
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/follow_requests/index.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+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 } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class FollowRequests extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchFollowRequests());
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandFollowRequests());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column name='follow-requests'>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <ScrollContainer scrollKey='follow_requests'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountAuthorizeContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js
new file mode 100644
index 000000000..f0ef29ff6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followers/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowers,
+  expandFollowers,
+} from 'flavours/glitch/actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import LoadMore from 'flavours/glitch/components/load_more';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Followers extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowers(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
+    }
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+      this.props.dispatch(expandFollowers(this.props.params.accountId));
+    }
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.props.dispatch(expandFollowers(this.props.params.accountId));
+  }
+
+  render () {
+    const { accountIds, hasMore } = this.props;
+
+    let loadMore = null;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    if (hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='followers'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <div className='followers'>
+              <HeaderContainer accountId={this.props.params.accountId} />
+              {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+              {loadMore}
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js
new file mode 100644
index 000000000..f30f7b0d9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/following/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowing,
+  expandFollowing,
+} from 'flavours/glitch/actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import LoadMore from 'flavours/glitch/components/load_more';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Following extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowing(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
+    }
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+      this.props.dispatch(expandFollowing(this.props.params.accountId));
+    }
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.props.dispatch(expandFollowing(this.props.params.accountId));
+  }
+
+  render () {
+    const { accountIds, hasMore } = this.props;
+
+    let loadMore = null;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    if (hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='following'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <div className='following'>
+              <HeaderContainer accountId={this.props.params.accountId} />
+              {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+              {loadMore}
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..d01a1ba47
--- /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 />
+  </Column>
+);
+
+export default GenericNotFound;
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..0077f193b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -0,0 +1,166 @@
+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 } from 'flavours/glitch/util/initial_state';
+import { createSelector } from 'reselect';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+
+const messages = defineMessages({
+  heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+  notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+  navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
+  settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
+  community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+  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' },
+});
+
+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']),
+  });
+
+  return mapStateToProps;
+};
+
+@injectIntl
+@connect(makeMapStateToProps)
+export default class GettingStarted extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    myAccount: ImmutablePropTypes.map.isRequired,
+    columns: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+    lists: ImmutablePropTypes.list,
+  };
+
+  openSettings = () => {
+    this.props.dispatch(openModal('SETTINGS', {}));
+  }
+
+  openOnboardingModal = (e) => {
+    e.preventDefault();
+    this.props.dispatch(openModal('ONBOARDING'));
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchLists());
+  }
+
+  render () {
+    const { intl, myAccount, columns, multiColumn, lists } = 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)} 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 (myAccount.get('locked')) {
+      navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+    }
+
+    navItems.push(<ColumnLink key='6' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
+
+    listItems = listItems.concat([
+      <div key='7'>
+        <ColumnLink key='8' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
+        {lists.map(list =>
+          <ColumnLink key={(8 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+        )}
+      </div>,
+    ]);
+
+    return (
+      <Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
+        <div className='scrollable optionally-scrollable'>
+          <div className='getting-started__wrapper'>
+            <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
+            {navItems}
+            <ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} />
+            {listItems}
+            <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
+            <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
+            <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} />
+            <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
+          </div>
+
+          <div className='getting-started__footer'>
+            <div className='static-content getting-started'>
+              <p>
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.faq' defaultMessage='FAQ' />
+                </a>&nbsp;•&nbsp;
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' />
+                </a>&nbsp;•&nbsp;
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' />
+                </a>
+              </p>
+              <p>
+                <FormattedMessage
+                  id='getting_started.open_source_notice'
+                  defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
+                  values={{
+                    github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>,
+                    Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a>,
+                  }}
+                />
+              </p>
+            </div>
+          </div>
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..9cf7ddff9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
@@ -0,0 +1,60 @@
+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' },
+  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' },
+});
+
+@connect()
+@injectIntl
+export default class gettingStartedMisc extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  openOnboardingModal = (e) => {
+    e.preventDefault();
+    this.props.dispatch(openModal('ONBOARDING'));
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Column icon='ellipsis-h' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <div className='scrollable'>
+          <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
+          <ColumnLink key='19' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
+          <ColumnLink key='20' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
+          <ColumnLink key='21' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
+          <ColumnLink key='22' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
+          <ColumnLink key='23' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
+          <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
+          <ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
+        </div>
+      </Column>
+    );
+  }
+
+}
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..9f3c9bec7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import {
+  refreshHashtagTimeline,
+  expandHashtagTimeline,
+} 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';
+
+const mapStateToProps = (state, props) => ({
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+export default class HashtagTimeline extends React.PureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  _subscribe (dispatch, id) {
+    this.disconnect = dispatch(connectHashtagStream(id));
+  }
+
+  _unsubscribe () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(refreshHashtagTimeline(id));
+    this._subscribe(dispatch, id);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.id !== this.props.params.id) {
+      this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
+      this._unsubscribe();
+      this._subscribe(this.props.dispatch, nextProps.params.id);
+    }
+  }
+
+  componentWillUnmount () {
+    this._unsubscribe();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHashtagTimeline(this.props.params.id));
+  }
+
+  render () {
+    const { hasUnread, columnId, multiColumn } = this.props;
+    const { id } = this.props.params;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='hashtag'>
+        <ColumnHeader
+          icon='hashtag'
+          active={hasUnread}
+          title={id}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+        />
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`hashtag_timeline-${columnId}`}
+          timelineId={`hashtag:${id}`}
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..d7692513e
--- /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' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { settings, onChange, intl } = this.props;
+
+    return (
+      <div>
+        <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
+
+        <div className='column-settings__row'>
+          <SettingToggle prefix='home_timeline' settings={settings} 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..2dfec6bbe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/home_timeline/index.js
@@ -0,0 +1,90 @@
+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';
+
+const messages = defineMessages({
+  title: { id: 'column.home', defaultMessage: 'Home' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class HomeTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('HOME', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHomeTimeline());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='home'>
+        <ColumnHeader
+          icon='home'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`home_timeline-${columnId}`}
+          loadMore={this.handleLoadMore}
+          timelineId='home'
+          emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
new file mode 100644
index 000000000..8aed471f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+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' },
+});
+
+@injectIntl
+export default class KeyboardShortcuts extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Column 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>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>enter</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
+              </tr>
+              <tr>
+                <td><kbd>up</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
+              </tr>
+              <tr>
+                <td><kbd>down</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>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>esc</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
+              </tr>
+              <tr>
+                <td><kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.expand' defaultMessage='to expand a status with a content warning' /></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_editor/components/account.js b/app/javascript/flavours/glitch/features/list_editor/components/account.js
new file mode 100644
index 000000000..f48df759d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/account.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+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, injectIntl } from 'react-intl';
+import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
+
+const messages = defineMessages({
+  remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
+  add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
+});
+
+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)),
+});
+
+@connect(makeMapStateToProps, mapDispatchToProps)
+@injectIntl
+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/search.js b/app/javascript/flavours/glitch/features/list_editor/components/search.js
new file mode 100644
index 000000000..45c4d0f2e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/search.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
+});
+
+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)),
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+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}>
+          <i className={classNames('fa fa-search', { active: !hasValue })} />
+          <i aria-label={intl.formatMessage(messages.search)} className={classNames('fa fa-times-circle', { active: hasValue })} />
+        </div>
+      </div>
+    );
+  }
+
+}
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..e6df4755a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/index.js
@@ -0,0 +1,80 @@
+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 Account from './components/account';
+import Search from './components/search';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const mapStateToProps = state => ({
+  title: state.getIn(['listEditor', 'title']),
+  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()),
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default 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,
+    title: PropTypes.string.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 { title, accountIds, searchAccountIds, onClear } = this.props;
+    const showSearch = searchAccountIds.size > 0;
+
+    return (
+      <div className='modal-root__modal list-editor'>
+        <h4>{title}</h4>
+
+        <Search />
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner list-editor__accounts'>
+            {accountIds.map(accountId => <Account 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 => <Account 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..c6a89a920
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.js
@@ -0,0 +1,170 @@
+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 { refreshListTimeline, expandListTimeline } from 'flavours/glitch/actions/timelines';
+import { fetchList, deleteList } 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';
+
+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' },
+});
+
+const mapStateToProps = (state, props) => ({
+  list: state.getIn(['lists', props.params.id]),
+  hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default 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(refreshListTimeline(id));
+
+    this.disconnect = dispatch(connectListStream(id));
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    const { id } = this.props.params;
+    this.props.dispatch(expandListTimeline(id));
+  }
+
+  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');
+        }
+      },
+    }));
+  }
+
+  render () {
+    const { hasUnread, columnId, multiColumn, list } = this.props;
+    const { id } = this.props.params;
+    const pinned = !!columnId;
+    const title  = list ? list.get('title') : id;
+
+    if (typeof list === 'undefined') {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    } else if (list === false) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='list-ul'
+          active={hasUnread}
+          title={title}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <div className='column-header__links'>
+            <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}>
+              <i className='fa fa-pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
+            </button>
+
+            <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}>
+              <i className='fa fa-trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
+            </button>
+          </div>
+
+          <hr />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`list_timeline-${columnId}`}
+          timelineId={`list:${id}`}
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />}
+        />
+      </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..61fcbeaf9
--- /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)),
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default 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}
+          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..8b0470c92
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/lists/index.js
@@ -0,0 +1,76 @@
+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 } 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';
+
+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),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Lists extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    lists: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchLists());
+  }
+
+  render () {
+    const { intl, lists } = this.props;
+
+    if (!lists) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='bars' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <NewListForm />
+
+        <div className='scrollable'>
+          <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
+
+          {lists.map(list =>
+            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+          )}
+        </div>
+      </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..fc2167c0c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js
@@ -0,0 +1,71 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports
+import LocalSettingsNavigationItem from './item';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+  general: {  id: 'settings.general', defaultMessage: 'General' },
+  collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
+  media: { id: 'settings.media', defaultMessage: 'Media' },
+  preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
+  close: { id: 'settings.close', defaultMessage: 'Close' },
+});
+
+@injectIntl
+export default class LocalSettingsNavigation extends React.PureComponent {
+
+  static propTypes = {
+    index      : PropTypes.number,
+    intl       : PropTypes.object.isRequired,
+    onClose    : PropTypes.func.isRequired,
+    onNavigate : PropTypes.func.isRequired,
+  };
+
+  render () {
+
+    const { index, intl, onClose, onNavigate } = this.props;
+
+    return (
+      <nav className='glitch local-settings__navigation'>
+        <LocalSettingsNavigationItem
+          active={index === 0}
+          index={0}
+          onNavigate={onNavigate}
+          title={intl.formatMessage(messages.general)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 1}
+          index={1}
+          onNavigate={onNavigate}
+          title={intl.formatMessage(messages.collapsed)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 2}
+          index={2}
+          onNavigate={onNavigate}
+          title={intl.formatMessage(messages.media)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 3}
+          href='/settings/preferences'
+          index={3}
+          icon='cog'
+          title={intl.formatMessage(messages.preferences)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 4}
+          className='close'
+          index={4}
+          onNavigate={onClose}
+          title={intl.formatMessage(messages.close)}
+        />
+      </nav>
+    );
+  }
+
+}
diff --git a/app/javascript/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..b67d479e7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js
@@ -0,0 +1,66 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPage extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    className: PropTypes.string,
+    href: PropTypes.string,
+    icon: PropTypes.string,
+    index: PropTypes.number.isRequired,
+    onNavigate: PropTypes.func,
+    title: PropTypes.string,
+  };
+
+  handleClick = (e) => {
+    const { index, onNavigate } = this.props;
+    if (onNavigate) {
+      onNavigate(index);
+      e.preventDefault();
+    }
+  }
+
+  render () {
+    const { handleClick } = this;
+    const {
+      active,
+      className,
+      href,
+      icon,
+      onNavigate,
+      title,
+    } = this.props;
+
+    const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
+      active,
+    }, className);
+
+    const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : null;
+
+    if (href) return (
+      <a
+        href={href}
+        className={finalClassName}
+      >
+        {iconElem} {title}
+      </a>
+    );
+    else if (onNavigate) return (
+      <a
+        onClick={handleClick}
+        role='button'
+        tabIndex='0'
+        className={finalClassName}
+      >
+        {iconElem} {title}
+      </a>
+    );
+    else return null;
+  }
+
+}
diff --git a/app/javascript/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..62bf410c6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -0,0 +1,209 @@
+//  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_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
+  layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
+  side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
+});
+
+@injectIntl
+export default class LocalSettingsPage extends React.PureComponent {
+
+  static propTypes = {
+    index    : PropTypes.number,
+    intl     : PropTypes.object.isRequired,
+    onChange : PropTypes.func.isRequired,
+    settings : ImmutablePropTypes.map.isRequired,
+  };
+
+  pages = [
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page general'>
+        <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['layout']}
+          id='mastodon-settings--layout'
+          options={[
+            { value: 'auto', message: intl.formatMessage(messages.layout_auto) },
+            { value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
+            { value: 'single', message: intl.formatMessage(messages.layout_mobile) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.layout' defaultMessage='Layout:' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['stretch']}
+          id='mastodon-settings--stretch'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['navbar_under']}
+          id='mastodon-settings--navbar_under'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['side_arm']}
+            id='mastodon-settings--side_arm'
+            options={[
+              { value: 'none', message: intl.formatMessage(messages.side_arm_none) },
+              { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) },
+              { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) },
+              { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) },
+              { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) },
+            ]}
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' />
+          </LocalSettingsPageItem>
+        </section>
+      </div>
+    ),
+    ({ onChange, settings }) => (
+      <div className='glitch local-settings__page collapsed'>
+        <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['collapsed', 'enabled']}
+          id='mastodon-settings--collapsed-enabled'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'all']}
+            id='mastodon-settings--collapsed-auto-all'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'notifications']}
+            id='mastodon-settings--collapsed-auto-notifications'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'lengthy']}
+            id='mastodon-settings--collapsed-auto-lengthy'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'reblogs']}
+            id='mastodon-settings--collapsed-auto-reblogs'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'replies']}
+            id='mastodon-settings--collapsed-auto-replies'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'media']}
+            id='mastodon-settings--collapsed-auto-media'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
+          </LocalSettingsPageItem>
+        </section>
+        <section>
+          <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'backgrounds', 'user_backgrounds']}
+            id='mastodon-settings--collapsed-user-backgrouns'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+          >
+            <FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'backgrounds', 'preview_images']}
+            id='mastodon-settings--collapsed-preview-images'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+          >
+            <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
+          </LocalSettingsPageItem>
+        </section>
+      </div>
+    ),
+    ({ onChange, settings }) => (
+      <div className='glitch local-settings__page media'>
+        <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'letterbox']}
+          id='mastodon-settings--media-letterbox'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'fullwidth']}
+          id='mastodon-settings--media-fullwidth'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
+        </LocalSettingsPageItem>
+      </div>
+    ),
+  ];
+
+  render () {
+    const { pages } = this;
+    const { index, intl, onChange, settings } = this.props;
+    const CurrentPage = pages[index] || pages[0];
+
+    return <CurrentPage intl={intl} onChange={onChange} settings={settings} />;
+  }
+
+}
diff --git a/app/javascript/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..66e84dfe1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js
@@ -0,0 +1,87 @@
+//  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.element.isRequired,
+    dependsOn: PropTypes.array,
+    dependsOnNot: PropTypes.array,
+    id: PropTypes.string.isRequired,
+    item: PropTypes.array.isRequired,
+    onChange: PropTypes.func.isRequired,
+    options: PropTypes.arrayOf(PropTypes.shape({
+      value: PropTypes.string.isRequired,
+      message: PropTypes.string.isRequired,
+    })),
+    settings: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleChange = e => {
+    const { target } = e;
+    const { item, onChange, options } = this.props;
+    if (options && options.length > 0) onChange(item, target.value);
+    else onChange(item, target.checked);
+  }
+
+  render () {
+    const { handleChange } = this;
+    const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
+    let enabled = true;
+
+    if (dependsOn) {
+      for (let i = 0; i < dependsOn.length; i++) {
+        enabled = enabled && settings.getIn(dependsOn[i]);
+      }
+    }
+    if (dependsOnNot) {
+      for (let i = 0; i < dependsOnNot.length; i++) {
+        enabled = enabled && !settings.getIn(dependsOnNot[i]);
+      }
+    }
+
+    if (options && options.length > 0) {
+      const currentValue = settings.getIn(item);
+      const optionElems = options && options.length > 0 && options.map((opt) => (
+        <option
+          key={opt.value}
+          value={opt.value}
+        >
+          {opt.message}
+        </option>
+      ));
+      return (
+        <label className='glitch local-settings__page__item' htmlFor={id}>
+          <p>{children}</p>
+          <p>
+            <select
+              id={id}
+              disabled={!enabled}
+              onBlur={handleChange}
+              onChange={handleChange}
+              value={currentValue}
+            >
+              {optionElems}
+            </select>
+          </p>
+        </label>
+      );
+    } else return (
+      <label className='glitch local-settings__page__item' htmlFor={id}>
+        <input
+          id={id}
+          type='checkbox'
+          checked={settings.getIn(item)}
+          onChange={handleChange}
+          disabled={!enabled}
+        />
+        {children}
+      </label>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/mutes/index.js b/app/javascript/flavours/glitch/features/mutes/index.js
new file mode 100644
index 000000000..87517eec9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/mutes/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+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 { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'mutes', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Mutes extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchMutes());
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandMutes());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='mutes'>
+          <div className='scrollable mutes' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..22a10753f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default class ClearColumnButton extends React.Component {
+
+  static propTypes = {
+    onClick: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><i className='fa fa-eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button>
+    );
+  }
+
+}
diff --git a/app/javascript/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..d9638aaf3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -0,0 +1,85 @@
+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 alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
+    const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
+
+    const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+    const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
+    const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <ClearColumnButton onClick={onClear} />
+        </div>
+
+        <div role='group' aria-labelledby='notifications-follow'>
+          <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} 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-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>
+    );
+  }
+
+}
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..54506f67c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -0,0 +1,98 @@
+//  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';
+
+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 = (
+      <Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/accounts/${account.get('id')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      />
+    );
+
+    //  Renders.
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-follow focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-user-plus' />
+            </div>
+
+            <FormattedMessage
+              id='notification.follow'
+              defaultMessage='{name} followed you'
+              values={{ name: link }}
+            />
+          </div>
+
+          <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} />
+          <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..cc77426d3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -0,0 +1,93 @@
+//  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';
+
+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,
+  };
+
+  render () {
+    const {
+      hidden,
+      notification,
+      onMoveDown,
+      onMoveUp,
+      onMention,
+    } = 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 'mention':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          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}
+          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}
+          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..e56f9c628
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.js
@@ -0,0 +1,57 @@
+/**
+ * Notification overlay
+ */
+
+
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
+});
+
+@injectIntl
+export default class NotificationOverlay extends ImmutablePureComponent {
+
+  static propTypes = {
+    notification    : ImmutablePropTypes.map.isRequired,
+    onMarkForDelete : PropTypes.func.isRequired,
+    show            : PropTypes.bool.isRequired,
+    intl            : PropTypes.object.isRequired,
+  };
+
+  onToggleMark = () => {
+    const mark = !this.props.notification.get('markedForDelete');
+    const id = this.props.notification.get('id');
+    this.props.onMarkForDelete(id, mark);
+  }
+
+  render () {
+    const { notification, show, intl } = this.props;
+
+    const active = notification.get('markedForDelete');
+    const label = intl.formatMessage(messages.markForDeletion);
+
+    return show ? (
+      <div
+        aria-label={label}
+        role='checkbox'
+        aria-checked={active}
+        tabIndex={0}
+        className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
+        onClick={this.onToggleMark}
+      >
+        <div className='wrappy'>
+          <div className='ckbox' aria-hidden='true' title={label}>
+            {active ? (<i className='fa fa-check' />) : ''}
+          </div>
+        </div>
+      </div>
+    ) : null;
+  }
+
+}
diff --git a/app/javascript/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..ac2211e48
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class SettingToggle extends React.PureComponent {
+
+  static propTypes = {
+    prefix: PropTypes.string,
+    settings: ImmutablePropTypes.map.isRequired,
+    settingPath: PropTypes.array.isRequired,
+    label: PropTypes.node.isRequired,
+    meta: PropTypes.node,
+    onChange: PropTypes.func.isRequired,
+  }
+
+  onChange = ({ target }) => {
+    this.props.onChange(this.props.settingPath, target.checked);
+  }
+
+  render () {
+    const { prefix, settings, settingPath, label, meta } = this.props;
+    const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
+
+    return (
+      <div className='setting-toggle'>
+        <Toggle id={id} checked={settings.getIn(settingPath)} 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..9585ea556
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,39 @@
+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 { 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 {
+      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/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..12b0b5b83
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -0,0 +1,193 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import {
+  enterNotificationClearingMode,
+  expandNotifications,
+  scrollTopNotifications,
+} 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 { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+import { debounce } from 'lodash';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+
+const messages = defineMessages({
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+});
+
+const getNotifications = createSelector([
+  state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+  state => state.getIn(['notifications', 'items']),
+], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
+
+const mapStateToProps = state => ({
+  notifications: getNotifications(state),
+  localSettings:  state.get('local_settings'),
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0,
+  hasMore: !!state.getIn(['notifications', 'next']),
+  notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
+});
+
+/* glitch */
+const mapDispatchToProps = dispatch => ({
+  onEnterCleaningMode(yes) {
+    dispatch(enterNotificationClearingMode(yes));
+  },
+  dispatch,
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class Notifications extends React.PureComponent {
+
+  static propTypes = {
+    columnId: PropTypes.string,
+    notifications: ImmutablePropTypes.list.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+    isLoading: PropTypes.bool,
+    isUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    localSettings: ImmutablePropTypes.map,
+    notifCleaningActive: PropTypes.bool,
+    onEnterCleaningMode: PropTypes.func,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  handleScrollToBottom = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
+    this.props.dispatch(expandNotifications());
+  }, 300, { leading: true });
+
+  handleScrollToTop = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(true));
+  }, 100);
+
+  handleScroll = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
+  }, 100);
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('NOTIFICATIONS', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setColumnRef = c => {
+    this.column = c;
+  }
+
+  handleMoveUp = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  render () {
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
+    const pinned = !!columnId;
+    const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
+
+    let scrollableContent = null;
+
+    if (isLoading && this.scrollableContent) {
+      scrollableContent = this.scrollableContent;
+    } else if (notifications.size > 0 || hasMore) {
+      scrollableContent = notifications.map((item) => (
+        <NotificationContainer
+          key={item.get('id')}
+          notification={item}
+          accountId={item.get('account')}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
+      ));
+    } else {
+      scrollableContent = null;
+    }
+
+    this.scrollableContent = scrollableContent;
+
+    const scrollContainer = (
+      <ScrollableList
+        scrollKey={`notifications-${columnId}`}
+        trackScroll={!pinned}
+        isLoading={isLoading}
+        hasMore={hasMore}
+        emptyMessage={emptyMessage}
+        onScrollToBottom={this.handleScrollToBottom}
+        onScrollToTop={this.handleScrollToTop}
+        onScroll={this.handleScroll}
+        shouldUpdateScroll={shouldUpdateScroll}
+      >
+        {scrollableContent}
+      </ScrollableList>
+    );
+
+    return (
+      <Column
+        ref={this.setColumnRef}
+        name='notifications'
+        extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
+      >
+        <ColumnHeader
+          icon='bell'
+          active={isUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          localSettings={this.props.localSettings}
+          notifCleaning
+          notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text
+          onEnterCleaningMode={this.props.onEnterCleaningMode}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        {scrollContainer}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.js b/app/javascript/flavours/glitch/features/pinned_statuses/index.js
new file mode 100644
index 000000000..f56d70176
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchPinnedStatuses } from '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']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PinnedStatuses extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchPinnedStatuses());
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { intl, statusIds, hasMore } = this.props;
+
+    return (
+      <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
+        <ColumnBackButtonSlim />
+        <StatusList
+          statusIds={statusIds}
+          scrollKey='pinned_statuses'
+          hasMore={hasMore}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..f042adbe6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from 'flavours/glitch/features/community_timeline/components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'public']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (path, checked) {
+    dispatch(changeSetting(['public', ...path], 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..bbdd4612e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import {
+  refreshPublicTimeline,
+  expandPublicTimeline,
+} from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+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 => ({
+  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasUnread: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('PUBLIC', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshPublicTimeline());
+    this.disconnect = dispatch(connectPublicStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandPublicTimeline());
+  }
+
+  render () {
+    const { intl, columnId, hasUnread, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='federated'>
+        <ColumnHeader
+          icon='globe'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          timelineId='public'
+          loadMore={this.handleLoadMore}
+          trackScroll={!pinned}
+          scrollKey={`public_timeline-${columnId}`}
+          emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/reblogs/index.js b/app/javascript/flavours/glitch/features/reblogs/index.js
new file mode 100644
index 000000000..25b792b39
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/reblogs/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { fetchReblogs } from 'flavours/glitch/actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Reblogs extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchReblogs(this.props.params.statusId));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchReblogs(nextProps.params.statusId));
+    }
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='reblogs'>
+          <div className='scrollable reblogs'>
+            {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..cc9232201
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/components/status_check_box.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class StatusCheckBox extends React.PureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    checked: PropTypes.bool,
+    onToggle: PropTypes.func.isRequired,
+    disabled: PropTypes.bool,
+  };
+
+  render () {
+    const { status, checked, onToggle, disabled } = this.props;
+    const content = { __html: status.get('contentHtml') };
+
+    if (status.get('reblog')) {
+      return null;
+    }
+
+    return (
+      <div className='status-check-box'>
+        <div
+          className='status__content'
+          dangerouslySetInnerHTML={content}
+        />
+
+        <div className='status-check-box-toggle'>
+          <Toggle checked={checked} onChange={onToggle} disabled={disabled} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js
new file mode 100644
index 000000000..a77b59448
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import Composer from 'flavours/glitch/features/composer';
+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>
+        <Composer />
+        <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..0ad2cef80
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import {
+  refreshHashtagTimeline,
+  expandHashtagTimeline,
+} from 'flavours/glitch/actions/timelines';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+
+@connect()
+export default class HashtagTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    hashtag: PropTypes.string.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch, hashtag } = this.props;
+
+    dispatch(refreshHashtagTimeline(hashtag));
+
+    this.polling = setInterval(() => {
+      dispatch(refreshHashtagTimeline(hashtag));
+    }, 10000);
+  }
+
+  componentWillUnmount () {
+    if (typeof this.polling !== 'undefined') {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  }
+
+  render () {
+    const { hashtag } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='hashtag'
+          title={hashtag}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          trackScroll={false}
+          scrollKey='standalone_hashtag_timeline'
+          timelineId={`hashtag:${hashtag}`}
+          loadMore={this.handleLoadMore}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..717f6fcaf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import {
+  refreshPublicTimeline,
+  expandPublicTimeline,
+} from 'flavours/glitch/actions/timelines';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
+});
+
+@connect()
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshPublicTimeline());
+
+    this.polling = setInterval(() => {
+      dispatch(refreshPublicTimeline());
+    }, 3000);
+  }
+
+  componentWillUnmount () {
+    if (typeof this.polling !== 'undefined') {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandPublicTimeline());
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='globe'
+          title={intl.formatMessage(messages.title)}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          timelineId='public'
+          loadMore={this.handleLoadMore}
+          scrollKey='standalone_public_timeline'
+          trackScroll={false}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/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..573c3743f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -0,0 +1,154 @@
+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 } from 'flavours/glitch/util/initial_state';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  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' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReply: PropTypes.func.isRequired,
+    onReblog: PropTypes.func.isRequired,
+    onFavourite: PropTypes.func.isRequired,
+    onMute: PropTypes.func,
+    onMuteConversation: PropTypes.func,
+    onBlock: PropTypes.func,
+    onDelete: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onReport: PropTypes.func,
+    onPin: PropTypes.func,
+    onEmbed: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleReplyClick = () => {
+    this.props.onReply(this.props.status);
+  }
+
+  handleReblogClick = (e) => {
+    this.props.onReblog(this.props.status, e);
+  }
+
+  handleFavouriteClick = (e) => {
+    this.props.onFavourite(this.props.status, e);
+  }
+
+  handleDeleteClick = () => {
+    this.props.onDelete(this.props.status);
+  }
+
+  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.get('account'));
+  }
+
+  handleReport = () => {
+    this.props.onReport(this.props.status);
+  }
+
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
+  handleShare = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  }
+
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+    const mutingConversation = status.get('muted');
+
+    let menu = [];
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
+    if (me === status.getIn(['account', 'id'])) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
+      menu.push(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 });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.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 });
+    }
+
+    const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+      <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
+    );
+
+    let reblogIcon = 'retweet';
+    //if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
+    // else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+
+    let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
+
+    return (
+      <div className='detailed-status__action-bar'>
+        <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
+        <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        {shareButton}
+
+        <div className='detailed-status__action-bar-dropdown'>
+          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js
new file mode 100644
index 000000000..bb83374b9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/card.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import punycode from 'punycode';
+import classnames from 'classnames';
+
+const IDNA_PREFIX = 'xn--';
+
+const decodeIDNA = domain => {
+  return domain
+    .split('.')
+    .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+    .join('.');
+};
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+export default class Card extends React.PureComponent {
+
+  static propTypes = {
+    card: ImmutablePropTypes.map,
+    maxDescription: PropTypes.number,
+  };
+
+  static defaultProps = {
+    maxDescription: 50,
+  };
+
+  state = {
+    width: 0,
+  };
+
+  renderLink () {
+    const { card, maxDescription } = this.props;
+
+    let image    = '';
+    let provider = card.get('provider_name');
+
+    if (card.get('image')) {
+      image = (
+        <div className='status-card__image'>
+          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} />
+        </div>
+      );
+    }
+
+    if (provider.length < 1) {
+      provider = decodeIDNA(getHostname(card.get('url')));
+    }
+
+    const className = classnames('status-card', {
+      'horizontal': card.get('width') > card.get('height'),
+    });
+
+    return (
+      <a href={card.get('url')} className={className} target='_blank' rel='noopener'>
+        {image}
+
+        <div className='status-card__content'>
+          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
+          <p className='status-card__description'>{(card.get('description') || '').substring(0, maxDescription)}</p>
+          <span className='status-card__host'>{provider}</span>
+        </div>
+      </a>
+    );
+  }
+
+  renderPhoto () {
+    const { card } = this.props;
+
+    return (
+      <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'>
+        <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} />
+      </a>
+    );
+  }
+
+  setRef = c => {
+    if (c) {
+      this.setState({ width: c.offsetWidth });
+    }
+  }
+
+  renderVideo () {
+    const { card }  = this.props;
+    const content   = { __html: card.get('html') };
+    const { width } = this.state;
+    const ratio     = card.get('width') / card.get('height');
+    const height    = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
+
+    return (
+      <div
+        ref={this.setRef}
+        className='status-card-video'
+        dangerouslySetInnerHTML={content}
+        style={{ height }}
+      />
+    );
+  }
+
+  render () {
+    const { card } = this.props;
+
+    if (card === null) {
+      return null;
+    }
+
+    switch(card.get('type')) {
+    case 'link':
+      return this.renderLink();
+    case 'photo':
+      return this.renderPhoto();
+    case 'video':
+      return this.renderVideo();
+    case 'rich':
+    default:
+      return null;
+    }
+  }
+
+}
diff --git a/app/javascript/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..538aa3d28
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -0,0 +1,130 @@
+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 StatusGallery from 'flavours/glitch/components/media_gallery';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import { Link } from 'react-router-dom';
+import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from 'flavours/glitch/features/video';
+import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+
+export default class DetailedStatus extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    settings: ImmutablePropTypes.map.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
+    onOpenVideo: PropTypes.func.isRequired,
+  };
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+
+    e.stopPropagation();
+  }
+
+  // handleOpenVideo = startTime => {
+  //   this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  // }
+
+  render () {
+    const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+    const { expanded, setExpansion, settings } = this.props;
+
+    let media           = '';
+    let mediaIcon       = null;
+    let applicationLink = '';
+    let reblogLink = '';
+    let reblogIcon = 'retweet';
+
+    if (status.get('media_attachments').size > 0) {
+      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+        media = <AttachmentList media={status.get('media_attachments')} />;
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        media = (
+          <Video
+            sensitive={status.get('sensitive')}
+            media={status.getIn(['media_attachments', 0])}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            onOpenVideo={this.props.onOpenVideo}
+            autoplay
+          />
+        );
+        mediaIcon = 'video-camera';
+      } else {
+        media = (
+          <StatusGallery
+            sensitive={status.get('sensitive')}
+            media={status.get('media_attachments')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            onOpenMedia={this.props.onOpenMedia}
+          />
+        );
+        mediaIcon = 'picture-o';
+      }
+    } else media = <CardContainer statusId={status.get('id')} />;
+
+    if (status.get('application')) {
+      applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
+    }
+
+    if (status.get('visibility') === 'direct') {
+      reblogIcon = 'envelope';
+    } else if (status.get('visibility') === 'private') {
+      reblogIcon = 'lock';
+    }
+
+    if (status.get('visibility') === 'private') {
+      reblogLink = <i className={`fa fa-${reblogIcon}`} />;
+    } else {
+      reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+        <i className={`fa fa-${reblogIcon}`} />
+        <span className='detailed-status__reblogs'>
+          <FormattedNumber value={status.get('reblogs_count')} />
+        </span>
+      </Link>);
+    }
+
+    return (
+      <div className='detailed-status' 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')} />
+        </a>
+
+        <StatusContent
+          status={status}
+          media={media}
+          mediaIcon={mediaIcon}
+          expanded={expanded}
+          setExpansion={setExpansion}
+        />
+
+        <div className='detailed-status__meta'>
+          <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
+            <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+          </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+            <i className='fa fa-star' />
+            <span className='detailed-status__favorites'>
+              <FormattedNumber value={status.get('favourites_count')} />
+            </span>
+          </Link> · <VisibilityIcon visibility={status.get('visibility')} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/containers/card_container.js b/app/javascript/flavours/glitch/features/status/containers/card_container.js
new file mode 100644
index 000000000..a97404de1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/containers/card_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+  card: state.getIn(['cards', statusId], null),
+});
+
+export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
new file mode 100644
index 000000000..682c3625f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -0,0 +1,394 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from '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,
+  reblog,
+  unreblog,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import {
+  replyCompose,
+  mentionCompose,
+} from 'flavours/glitch/actions/compose';
+import { blockAccount } from 'flavours/glitch/actions/accounts';
+import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+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 StatusContainer from 'flavours/glitch/containers/status_container';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } 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';
+
+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?' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props.params.statusId),
+    settings: state.get('local_settings'),
+    ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
+    descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
+  });
+
+  return mapStateToProps;
+};
+
+@injectIntl
+@connect(makeMapStateToProps)
+export default class Status extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    status: ImmutablePropTypes.map,
+    settings: ImmutablePropTypes.map.isRequired,
+    ancestorsIds: ImmutablePropTypes.list,
+    descendantsIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    fullscreen: false,
+    isExpanded: null,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchStatus(this.props.params.statusId));
+  }
+
+  componentDidMount () {
+    attachFullscreenListener(this.onFullScreenChange);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this._scrolledIntoView = false;
+      this.props.dispatch(fetchStatus(nextProps.params.statusId));
+    }
+  }
+
+  handleExpandedToggle = () => {
+    if (this.props.status.get('spoiler_text')) {
+      this.setExpansion(this.state.isExpanded ? null : true);
+    }
+  };
+
+  handleModalFavourite = (status) => {
+    this.props.dispatch(favourite(status));
+  }
+
+  handleFavouriteClick = (status, e) => {
+    if (status.get('favourited')) {
+      this.props.dispatch(unfavourite(status));
+    } else {
+      if (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) => {
+    this.props.dispatch(replyCompose(status, this.context.router.history));
+  }
+
+  handleModalReblog = (status) => {
+    this.props.dispatch(reblog(status));
+  }
+
+  handleReblogClick = (status, e) => {
+    if (status.get('reblogged')) {
+      this.props.dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !boostModal) {
+        this.handleModalReblog(status);
+      } else {
+        this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
+      }
+    }
+  }
+
+  handleDeleteClick = (status) => {
+    const { dispatch, intl } = this.props;
+
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id')));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(messages.deleteMessage),
+        confirm: intl.formatMessage(messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+      }));
+    }
+  }
+
+  handleMentionClick = (account, router) => {
+    this.props.dispatch(mentionCompose(account, router));
+  }
+
+  handleOpenMedia = (media, index) => {
+    this.props.dispatch(openModal('MEDIA', { media, index }));
+  }
+
+  handleOpenVideo = (media, time) => {
+    this.props.dispatch(openModal('VIDEO', { media, time }));
+  }
+
+  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')));
+    }
+  }
+
+  handleBlockClick = (account) => {
+    const { dispatch, intl } = this.props;
+
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockConfirm),
+      onConfirm: () => dispatch(blockAccount(account.get('id'))),
+    }));
+  }
+
+  handleReport = (status) => {
+    this.props.dispatch(initReport(status.get('account'), status));
+  }
+
+  handleEmbed = (status) => {
+    this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.handleMoveUp(this.props.status.get('id'));
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.handleMoveDown(this.props.status.get('id'));
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.handleReplyClick(this.props.status);
+  }
+
+  handleHotkeyFavourite = () => {
+    this.handleFavouriteClick(this.props.status);
+  }
+
+  handleHotkeyBoost = () => {
+    this.handleReblogClick(this.props.status);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.handleMentionClick(this.props.status);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+  }
+
+  handleMoveUp = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size - 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index);
+      } else {
+        this._selectChild(index - 1);
+      }
+    }
+  }
+
+  handleMoveDown = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size + 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index + 2);
+      } else {
+        this._selectChild(index + 1);
+      }
+    }
+  }
+
+  _selectChild (index) {
+    const element = this.node.querySelectorAll('.focusable')[index];
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  renderChildren (list) {
+    return list.map(id => (
+      <StatusContainer
+        key={id}
+        id={id}
+        onMoveUp={this.handleMoveUp}
+        onMoveDown={this.handleMoveDown}
+      />
+    ));
+  }
+
+  setExpansion = value => {
+    this.setState({ isExpanded: value ? true : null });
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidUpdate () {
+    if (this._scrolledIntoView) {
+      return;
+    }
+
+    const { status, ancestorsIds } = this.props;
+
+    if (status && ancestorsIds && ancestorsIds.size > 0) {
+      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+      if (element) {
+        element.scrollIntoView(true);
+        this._scrolledIntoView = true;
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    detachFullscreenListener(this.onFullScreenChange);
+  }
+
+  onFullScreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  render () {
+    let ancestors, descendants;
+    const { setExpansion } = this;
+    const { status, settings, ancestorsIds, descendantsIds } = this.props;
+    const { fullscreen, isExpanded } = this.state;
+
+    if (status === null) {
+      return (
+        <Column>
+          <ColumnBackButton />
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (ancestorsIds && ancestorsIds.size > 0) {
+      ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
+    }
+
+    if (descendantsIds && descendantsIds.size > 0) {
+      descendants = <div>{this.renderChildren(descendantsIds)}</div>;
+    }
+
+    const handlers = {
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      openProfile: this.handleHotkeyOpenProfile,
+      toggleSpoiler: this.handleExpandedToggle,
+    };
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='thread'>
+          <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
+            {ancestors}
+
+            <HotKeys handlers={handlers}>
+              <div className='focusable' tabIndex='0'>
+                <DetailedStatus
+                  status={status}
+                  settings={settings}
+                  onOpenVideo={this.handleOpenVideo}
+                  onOpenMedia={this.handleOpenMedia}
+                  expanded={isExpanded}
+                  setExpansion={setExpansion}
+                />
+
+                <ActionBar
+                  status={status}
+                  onReply={this.handleReplyClick}
+                  onFavourite={this.handleFavouriteClick}
+                  onReblog={this.handleReblogClick}
+                  onDelete={this.handleDeleteClick}
+                  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..c8b040f95
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
@@ -0,0 +1,125 @@
+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,
+      onClick: PropTypes.func,
+      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'
+                  fullwidth
+                  icon={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'>
+              <RelativeTimestamp timestamp={this.props.status.get('created_at')} />
+            </a>
+          </div>
+
+          <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'>
+            <div className='status__avatar'>
+              <Avatar account={this.props.status.get('account')} size={48} />
+            </div>
+
+            <DisplayName account={this.props.status.get('account')} />
+          </a>
+        </div>
+
+        <StatusContent status={this.props.status} />
+      </div>
+    );
+
+    return (
+      <div className='modal-root__modal actions-modal'>
+        {status}
+
+        <ul>
+          {this.props.actions.map(this.renderAction)}
+        </ul>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..9652bcb2d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from '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 ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+});
+
+@injectIntl
+export default class BoostModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReblog: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleReblog = () => {
+    this.props.onReblog(this.props.status);
+    this.props.onClose();
+  }
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    return (
+      <div className='modal-root__modal boost-modal'>
+        <div className='boost-modal__container'>
+          <div className='status light'>
+            <div className='boost-modal__status-header'>
+              <div className='boost-modal__status-time'>
+                <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+              </div>
+
+              <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  <Avatar account={status.get('account')} size={48} />
+                </div>
+
+                <DisplayName account={status.get('account')} />
+              </a>
+            </div>
+
+            <StatusContent status={status} />
+          </div>
+        </div>
+
+        <div className='boost-modal__action-bar'>
+          <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div>
+          <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle.js b/app/javascript/flavours/glitch/features/ui/components/bundle.js
new file mode 100644
index 000000000..fc88e0c70
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const emptyComponent = () => null;
+const noop = () => { };
+
+class Bundle extends React.Component {
+
+  static propTypes = {
+    fetchComponent: PropTypes.func.isRequired,
+    loading: PropTypes.func,
+    error: PropTypes.func,
+    children: PropTypes.func.isRequired,
+    renderDelay: PropTypes.number,
+    onFetch: PropTypes.func,
+    onFetchSuccess: PropTypes.func,
+    onFetchFail: PropTypes.func,
+  }
+
+  static defaultProps = {
+    loading: emptyComponent,
+    error: emptyComponent,
+    renderDelay: 0,
+    onFetch: noop,
+    onFetchSuccess: noop,
+    onFetchFail: noop,
+  }
+
+  static cache = {}
+
+  state = {
+    mod: undefined,
+    forceRender: false,
+  }
+
+  componentWillMount() {
+    this.load(this.props);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.fetchComponent !== this.props.fetchComponent) {
+      this.load(nextProps);
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.timeout) {
+      clearTimeout(this.timeout);
+    }
+  }
+
+  load = (props) => {
+    const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
+
+    onFetch();
+
+    if (Bundle.cache[fetchComponent.name]) {
+      const mod = Bundle.cache[fetchComponent.name];
+
+      this.setState({ mod: mod.default });
+      onFetchSuccess();
+      return Promise.resolve();
+    }
+
+    this.setState({ mod: undefined });
+
+    if (renderDelay !== 0) {
+      this.timestamp = new Date();
+      this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
+    }
+
+    return fetchComponent()
+      .then((mod) => {
+        Bundle.cache[fetchComponent.name] = mod;
+        this.setState({ mod: mod.default });
+        onFetchSuccess();
+      })
+      .catch((error) => {
+        this.setState({ mod: null });
+        onFetchFail(error);
+      });
+  }
+
+  render() {
+    const { loading: Loading, error: Error, children, renderDelay } = this.props;
+    const { mod, forceRender } = this.state;
+    const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
+
+    if (mod === undefined) {
+      return (elapsed >= renderDelay || forceRender) ? <Loading /> : null;
+    }
+
+    if (mod === null) {
+      return <Error onRetry={this.load} />;
+    }
+
+    return children(mod);
+  }
+
+}
+
+export default Bundle;
diff --git a/app/javascript/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..af195ea9c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_header.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ColumnHeader extends React.PureComponent {
+
+  static propTypes = {
+    icon: PropTypes.string,
+    type: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func,
+    columnHeaderId: PropTypes.string,
+  };
+
+  handleClick = () => {
+    this.props.onClick();
+  }
+
+  render () {
+    const { type, active, columnHeaderId } = this.props;
+
+    let icon = '';
+
+    if (this.props.icon) {
+      icon = <i className={`fa fa-fw fa-${this.props.icon} column-header__icon`} />;
+    }
+
+    return (
+      <div role='heading' tabIndex='0' className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
+        {icon}
+        {type}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..b845d1895
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_link.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+
+const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
+  if (href) {
+    return (
+      <a href={href} className='column-link' data-method={method}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </a>
+    );
+  } else if (to) {
+    return (
+      <Link to={to} className='column-link'>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </Link>
+    );
+  } else {
+    return (
+      <a onClick={onClick} className='column-link' role='button' tabIndex='0' data-method={method}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </a>
+    );
+  }
+};
+
+ColumnLink.propTypes = {
+  icon: PropTypes.string.isRequired,
+  text: PropTypes.string.isRequired,
+  to: PropTypes.string,
+  onClick: PropTypes.func,
+  href: PropTypes.string,
+  method: PropTypes.string,
+};
+
+export default ColumnLink;
diff --git a/app/javascript/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..ba2d0824e
--- /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} />
+        <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..e4556899d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -0,0 +1,179 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import ReactSwipeableViews from 'react-swipeable-views';
+import { links, getIndex, getLink } from './tabs_bar';
+
+import BundleContainer from '../containers/bundle_container';
+import ColumnLoading from './column_loading';
+import DrawerLoading from './drawer_loading';
+import BundleColumnError from './bundle_column_error';
+import { Drawer, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
+
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollRight } from 'flavours/glitch/util/scroll';
+
+const componentMap = {
+  'COMPOSE': Drawer,
+  'HOME': HomeTimeline,
+  'NOTIFICATIONS': Notifications,
+  'PUBLIC': PublicTimeline,
+  'COMMUNITY': CommunityTimeline,
+  'HASHTAG': HashtagTimeline,
+  'DIRECT': DirectTimeline,
+  'FAVOURITES': FavouritedStatuses,
+  'LIST': ListTimeline,
+};
+
+@component => injectIntl(component, { withRef: true })
+export default class ColumnsArea extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    columns: ImmutablePropTypes.list.isRequired,
+    singleColumn: PropTypes.bool,
+    children: PropTypes.node,
+  };
+
+  state = {
+    shouldAnimate: false,
+  }
+
+  componentWillReceiveProps() {
+    this.setState({ shouldAnimate: false });
+  }
+
+  componentDidMount() {
+    if (!this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+    }
+
+    this.lastIndex   = getIndex(this.context.router.history.location.pathname);
+    this.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');
+  }
+
+  handleAnimationEnd = () => {
+    if (typeof this.pendingIndex === 'number') {
+      this.context.router.history.push(getLink(this.pendingIndex));
+      this.pendingIndex = null;
+    }
+  }
+
+  handleWheel = () => {
+    if (typeof this._interruptScrollAnimation !== 'function') {
+      return;
+    }
+
+    this._interruptScrollAnimation();
+  }
+
+  setRef = (node) => {
+    this.node = node;
+  }
+
+  renderView = (link, index) => {
+    const columnIndex = getIndex(this.context.router.history.location.pathname);
+    const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
+    const icon = link.props['data-preview-icon'];
+
+    const view = (index === columnIndex) ?
+      React.cloneElement(this.props.children) :
+      <ColumnLoading title={title} icon={icon} />;
+
+    return (
+      <div className='columns-area' key={index}>
+        {view}
+      </div>
+    );
+  }
+
+  renderLoading = columnId => () => {
+    return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
+  }
+
+  renderError = (props) => {
+    return <BundleColumnError {...props} />;
+  }
+
+  render () {
+    const { columns, children, singleColumn } = this.props;
+    const { shouldAnimate } = this.state;
+
+    const columnIndex = getIndex(this.context.router.history.location.pathname);
+    this.pendingIndex = null;
+
+    if (singleColumn) {
+      return columnIndex !== -1 ? (
+        <ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
+          {links.map(this.renderView)}
+        </ReactSwipeableViews>
+      ) : <div className='columns-area'>{children}</div>;
+    }
+
+    return (
+      <div className='columns-area' ref={this.setRef}>
+        {columns.map(column => {
+          const params = column.get('params', null) === null ? null : column.get('params').toJS();
+
+          return (
+            <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
+              {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />}
+            </BundleContainer>
+          );
+        })}
+
+        {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..d4d1e587e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+
+@injectIntl
+export default class ConfirmationModal extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+    confirm: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm();
+  }
+
+  handleCancel = () => {
+    this.props.onClose();
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { message, confirm } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+          {message}
+        </div>
+
+        <div className='confirmation-modal__action-bar'>
+          <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'>
+            <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+          </Button>
+          <Button text={confirm} onClick={this.handleClick} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..9c74451b3
--- /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 });
+}
+
+const DOODLE_SIZES = {
+  normal: [500, 500, 'Square 500'],
+  tootbanner: [702, 330, 'Tootbanner'],
+  s640x480: [640, 480, '640×480 - 480p'],
+  s800x600: [800, 600, '800×600 - SVGA'],
+  s720x480: [720, 405, '720x405 - 16:9'],
+};
+
+
+const mapStateToProps = state => ({
+  options: state.getIn(['compose', 'doodle']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  /** Set options in the redux store */
+  setOpt: (opts) => dispatch(doodleSet(opts)),
+  /** Submit doodle for upload */
+  submit: (file) => dispatch(uploadCompose([file])),
+});
+
+/**
+ * Doodling dialog with drawing canvas
+ *
+ * Keyboard shortcuts:
+ * - Delete: Clear screen, fill with background color
+ * - Backspace, Ctrl+Z: Undo one step
+ * - Ctrl held while drawing: Use background color
+ * - Shift held while clicking screen: Use fill tool
+ *
+ * Palette:
+ * - Left mouse button: pick foreground
+ * - Ctrl + left mouse button: pick background
+ * - Right mouse button: pick background
+ */
+@connect(mapStateToProps, mapDispatchToProps)
+export default class DoodleModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    options: ImmutablePropTypes.map,
+    onClose: PropTypes.func.isRequired,
+    setOpt: PropTypes.func.isRequired,
+    submit: PropTypes.func.isRequired,
+  };
+
+  //region Option getters/setters
+
+  /** Foreground color */
+  get fg () {
+    return this.props.options.get('fg');
+  }
+  set fg (value) {
+    this.props.setOpt({ fg: value });
+  }
+
+  /** Background color */
+  get bg () {
+    return this.props.options.get('bg');
+  }
+  set bg (value) {
+    this.props.setOpt({ bg: value });
+  }
+
+  /** Swap Fg and Bg for drawing */
+  get swapped () {
+    return this.props.options.get('swapped');
+  }
+  set swapped (value) {
+    this.props.setOpt({ swapped: value });
+  }
+
+  /** Mode - 'draw' or 'fill' */
+  get mode () {
+    return this.props.options.get('mode');
+  }
+  set mode (value) {
+    this.props.setOpt({ mode: value });
+  }
+
+  /** Base line weight */
+  get weight () {
+    return this.props.options.get('weight');
+  }
+  set weight (value) {
+    this.props.setOpt({ weight: value });
+  }
+
+  /** Drawing opacity */
+  get opacity () {
+    return this.props.options.get('opacity');
+  }
+  set opacity (value) {
+    this.props.setOpt({ opacity: value });
+  }
+
+  /** Adaptive stroke - change width with speed */
+  get adaptiveStroke () {
+    return this.props.options.get('adaptiveStroke');
+  }
+  set adaptiveStroke (value) {
+    this.props.setOpt({ adaptiveStroke: value });
+  }
+
+  /** Smoothing (for mouse drawing) */
+  get smoothing () {
+    return this.props.options.get('smoothing');
+  }
+  set smoothing (value) {
+    this.props.setOpt({ smoothing: value });
+  }
+
+  /** Size preset */
+  get size () {
+    return this.props.options.get('size');
+  }
+  set size (value) {
+    this.props.setOpt({ size: value });
+  }
+
+  //endregion
+
+  /** Key up handler */
+  handleKeyUp = (e) => {
+    if (e.target.nodeName === 'INPUT') return;
+
+    if (e.key === 'Delete') {
+      e.preventDefault();
+      this.handleClearBtn();
+      return;
+    }
+
+    if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
+      e.preventDefault();
+      this.undo();
+    }
+
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = false;
+      this.swapped = false;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = false;
+      this.mode = 'draw';
+    }
+  };
+
+  /** Key down handler */
+  handleKeyDown = (e) => {
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = true;
+      this.swapped = true;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = true;
+      this.mode = 'fill';
+    }
+  };
+
+  /**
+   * Component installed in the DOM, do some initial set-up
+   */
+  componentDidMount () {
+    this.controlHeld = false;
+    this.shiftHeld = false;
+    this.swapped = false;
+    window.addEventListener('keyup', this.handleKeyUp, false);
+    window.addEventListener('keydown', this.handleKeyDown, false);
+  };
+
+  /**
+   * Tear component down
+   */
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp, false);
+    window.removeEventListener('keydown', this.handleKeyDown, false);
+    if (this.sketcher) this.sketcher.destroy();
+  }
+
+  /**
+   * Set reference to the canvas element.
+   * This is called during component init
+   *
+   * @param elem - canvas element
+   */
+  setCanvasRef = (elem) => {
+    this.canvas = elem;
+    if (elem) {
+      elem.addEventListener('dirty', () => {
+        this.saveUndo();
+        this.sketcher._dirty = false;
+      });
+
+      elem.addEventListener('click', () => {
+        // sketcher bug - does not fire dirty on fill
+        if (this.mode === 'fill') {
+          this.saveUndo();
+        }
+      });
+
+      // prevent context menu
+      elem.addEventListener('contextmenu', (e) => {
+        e.preventDefault();
+      });
+
+      elem.addEventListener('mousedown', (e) => {
+        if (e.button === 2) {
+          this.swapped = true;
+        }
+      });
+
+      elem.addEventListener('mouseup', (e) => {
+        if (e.button === 2) {
+          this.swapped = this.controlHeld;
+        }
+      });
+
+      this.initSketcher(elem);
+      this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
+    }
+  };
+
+  /**
+   * Set up the sketcher instance
+   *
+   * @param canvas - canvas element. Null if we're just resizing
+   */
+  initSketcher (canvas = null) {
+    const sizepreset = DOODLE_SIZES[this.size];
+
+    if (this.sketcher) this.sketcher.destroy();
+    this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
+
+    if (canvas) {
+      this.ctx = this.sketcher.context;
+      this.updateSketcherSettings();
+    }
+
+    this.clearScreen();
+  }
+
+  /**
+   * Done button handler
+   */
+  onDoneButton = () => {
+    const dataUrl = this.sketcher.toImage();
+    const file = dataURLtoFile(dataUrl, 'doodle.png');
+    this.props.submit(file);
+    this.props.onClose(); // close dialog
+  };
+
+  /**
+   * Cancel button handler
+   */
+  onCancelButton = () => {
+    if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
+      return;
+    }
+
+    this.props.onClose(); // close dialog
+  };
+
+  /**
+   * Update sketcher options based on state
+   */
+  updateSketcherSettings () {
+    if (!this.sketcher) return;
+
+    if (this.oldSize !== this.size) this.initSketcher();
+
+    this.sketcher.color = (this.swapped ? this.bg : this.fg);
+    this.sketcher.opacity = this.opacity;
+    this.sketcher.weight = this.weight;
+    this.sketcher.mode = this.mode;
+    this.sketcher.smoothing = this.smoothing;
+    this.sketcher.adaptiveStroke = this.adaptiveStroke;
+
+    this.oldSize = this.size;
+  }
+
+  /**
+   * Fill screen with background color
+   */
+  clearScreen = () => {
+    this.ctx.fillStyle = this.bg;
+    this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
+    this.undos = [];
+
+    this.doSaveUndo();
+  };
+
+  /**
+   * Undo one step
+   */
+  undo = () => {
+    if (this.undos.length > 1) {
+      this.undos.pop();
+      const buf = this.undos.pop();
+
+      this.sketcher.clear();
+      this.ctx.putImageData(buf, 0, 0);
+      this.doSaveUndo();
+    }
+  };
+
+  /**
+   * Save canvas content into the undo buffer immediately
+   */
+  doSaveUndo = () => {
+    this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
+  };
+
+  /**
+   * Called on each canvas change.
+   * Saves canvas content to the undo buffer after some period of inactivity.
+   */
+  saveUndo = debounce(() => {
+    this.doSaveUndo();
+  }, 100);
+
+  /**
+   * Palette left click.
+   * Selects Fg color (or Bg, if Control/Meta is held)
+   *
+   * @param e - event
+   */
+  onPaletteClick = (e) => {
+    const c = e.target.dataset.color;
+
+    if (this.controlHeld) {
+      this.bg = c;
+    } else {
+      this.fg = c;
+    }
+
+    e.target.blur();
+    e.preventDefault();
+  };
+
+  /**
+   * Palette right click.
+   * Selects Bg color
+   *
+   * @param e - event
+   */
+  onPaletteRClick = (e) => {
+    this.bg = e.target.dataset.color;
+    e.target.blur();
+    e.preventDefault();
+  };
+
+  /**
+   * Handle click on the Draw mode button
+   *
+   * @param e - event
+   */
+  setModeDraw = (e) => {
+    this.mode = 'draw';
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on the Fill mode button
+   *
+   * @param e - event
+   */
+  setModeFill = (e) => {
+    this.mode = 'fill';
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on Smooth checkbox
+   *
+   * @param e - event
+   */
+  tglSmooth = (e) => {
+    this.smoothing = !this.smoothing;
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on Adaptive checkbox
+   *
+   * @param e - event
+   */
+  tglAdaptive = (e) => {
+    this.adaptiveStroke = !this.adaptiveStroke;
+    e.target.blur();
+  };
+
+  /**
+   * Handle change of the Weight input field
+   *
+   * @param e - event
+   */
+  setWeight = (e) => {
+    this.weight = +e.target.value || 1;
+  };
+
+  /**
+   * Set size - clalback from the select box
+   *
+   * @param e - event
+   */
+  changeSize = (e) => {
+    let newSize = e.target.value;
+    if (newSize === this.oldSize) return;
+
+    if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
+      return;
+    }
+
+    this.size = newSize;
+  };
+
+  handleClearBtn = () => {
+    if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
+      return;
+    }
+
+    this.clearScreen();
+  };
+
+  /**
+   * Render the component
+   */
+  render () {
+    this.updateSketcherSettings();
+
+    return (
+      <div className='modal-root__modal doodle-modal'>
+        <div className='doodle-modal__container'>
+          <canvas ref={this.setCanvasRef} />
+        </div>
+
+        <div className='doodle-modal__action-bar'>
+          <div className='doodle-toolbar'>
+            <Button text='Done' onClick={this.onDoneButton} />
+            <Button text='Cancel' onClick={this.onCancelButton} />
+          </div>
+          <div className='filler' />
+          <div className='doodle-toolbar with-inputs'>
+            <div>
+              <label htmlFor='dd_smoothing'>Smoothing</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_adaptive'>Adaptive</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_weight'>Weight</label>
+              <span className='val'>
+                <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
+              </span>
+            </div>
+            <div>
+              <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
+                { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
+                  <option key={k} value={k}>{val[2]}</option>
+                )) }
+              </select>
+            </div>
+          </div>
+          <div className='doodle-toolbar'>
+            <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
+            <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
+            <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
+            <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
+          </div>
+          <div className='doodle-palette'>
+            {
+              palReordered.map((c, i) =>
+                c === null ?
+                  <br key={i} /> :
+                  <button
+                    key={i}
+                    style={{ backgroundColor: c[0] }}
+                    onClick={this.onPaletteClick}
+                    onContextMenu={this.onPaletteRClick}
+                    data-color={c[0]}
+                    title={c[1]}
+                    className={classNames({
+                      'foreground': this.fg === c[0],
+                      'background': this.bg === c[0],
+                    })}
+                  />
+              )
+            }
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..f3553f4a9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import api from 'flavours/glitch/util/api';
+
+@injectIntl
+export default class EmbedModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    url: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  }
+
+  state = {
+    loading: false,
+    oembed: null,
+  };
+
+  componentDidMount () {
+    const { url } = this.props;
+
+    this.setState({ loading: true });
+
+    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;
+    });
+  }
+
+  setIframeRef = c =>  {
+    this.iframe = c;
+  }
+
+  handleTextareaClick = (e) => {
+    e.target.select();
+  }
+
+  render () {
+    const { oembed } = this.state;
+
+    return (
+      <div className='modal-root__modal embed-modal'>
+        <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4>
+
+        <div className='embed-modal__container'>
+          <p className='hint'>
+            <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
+          </p>
+
+          <input
+            type='text'
+            className='embed-modal__html'
+            readOnly
+            value={oembed && oembed.html || ''}
+            onClick={this.handleTextareaClick}
+          />
+
+          <p className='hint'>
+            <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
+          </p>
+
+          <iframe
+            className='embed-modal__iframe'
+            frameBorder='0'
+            ref={this.setIframeRef}
+            title='preview'
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..70722411d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from '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 ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+});
+
+@injectIntl
+export default 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();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    return (
+      <div className='modal-root__modal 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 + <i className='fa fa-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/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js
new file mode 100644
index 000000000..aad594380
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class ImageLoader extends React.PureComponent {
+
+  static propTypes = {
+    alt: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    previewSrc: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+  }
+
+  static defaultProps = {
+    alt: '',
+    width: null,
+    height: null,
+  };
+
+  state = {
+    loading: true,
+    error: false,
+  }
+
+  removers = [];
+
+  get canvasContext() {
+    if (!this.canvas) {
+      return null;
+    }
+    this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
+    return this._canvasContext;
+  }
+
+  componentDidMount () {
+    this.loadImage(this.props);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.src !== nextProps.src) {
+      this.loadImage(nextProps);
+    }
+  }
+
+  loadImage (props) {
+    this.removeEventListeners();
+    this.setState({ loading: true, error: false });
+    Promise.all([
+      this.loadPreviewCanvas(props),
+      this.hasSize() && this.loadOriginalImage(props),
+    ].filter(Boolean))
+      .then(() => {
+        this.setState({ loading: false, error: false });
+        this.clearPreviewCanvas();
+      })
+      .catch(() => this.setState({ loading: false, error: true }));
+  }
+
+  loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
+    const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      this.canvasContext.drawImage(image, 0, 0, width, height);
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
+    image.src = previewSrc;
+    this.removers.push(removeEventListeners);
+  })
+
+  clearPreviewCanvas () {
+    const { width, height } = this.canvas;
+    this.canvasContext.clearRect(0, 0, width, height);
+  }
+
+  loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
+    const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
+    image.src = src;
+    this.removers.push(removeEventListeners);
+  });
+
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
+  }
+
+  hasSize () {
+    const { width, height } = this.props;
+    return typeof width === 'number' && typeof height === 'number';
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  render () {
+    const { alt, src, width, height } = this.props;
+    const { loading } = this.state;
+
+    const className = classNames('image-loader', {
+      'image-loader--loading': loading,
+      'image-loader--amorphous': !this.hasSize(),
+    });
+
+    return (
+      <div className={className}>
+        <canvas
+          className='image-loader__preview-canvas'
+          width={width}
+          height={height}
+          ref={this.setCanvasRef}
+          style={{ opacity: loading ? 1 : 0 }}
+        />
+
+        {!loading && (
+          <img
+            alt={alt}
+            className='image-loader__img'
+            src={src}
+            width={width}
+            height={height}
+          />
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..e56147c5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImageLoader from './image_loader';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+@injectIntl
+export default class MediaModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.list.isRequired,
+    index: PropTypes.number.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: null,
+  };
+
+  handleSwipe = (index) => {
+    this.setState({ index: index % this.props.media.size });
+  }
+
+  handleNextClick = () => {
+    this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
+  }
+
+  handlePrevClick = () => {
+    this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
+  }
+
+  handleChangeIndex = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
+    this.setState({ index: index % this.props.media.size });
+  }
+
+  handleKeyUp = (e) => {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      break;
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  }
+
+  render () {
+    const { media, intl, onClose } = this.props;
+
+    const index = this.getIndex();
+    let pagination = [];
+
+    const leftNav  = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>;
+    const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>;
+
+    if (media.size > 1) {
+      pagination = media.map((item, i) => {
+        const classes = ['media-modal__button'];
+        if (i === index) {
+          classes.push('media-modal__button--active');
+        }
+        return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
+      });
+    }
+
+    const content = media.map((image) => {
+      const width  = image.getIn(['meta', 'original', 'width']) || null;
+      const height = image.getIn(['meta', 'original', 'height']) || null;
+
+      if (image.get('type') === 'image') {
+        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />;
+      } else if (image.get('type') === 'gifv') {
+        return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />;
+      }
+
+      return null;
+    }).toArray();
+
+    const containerStyle = {
+      alignItems: 'center', // center vertically
+    };
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        {leftNav}
+
+        <div className='media-modal__content'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}>
+            {content}
+          </ReactSwipeableViews>
+        </div>
+        <ul className='media-modal__pagination'>
+          {pagination}
+        </ul>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..e12ee1761
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -0,0 +1,135 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import BundleContainer from '../containers/bundle_container';
+import BundleModalError from './bundle_modal_error';
+import ModalLoading from './modal_loading';
+import ActionsModal from './actions_modal';
+import MediaModal from './media_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import FavouriteModal from './favourite_modal';
+import DoodleModal from './doodle_modal';
+import ConfirmationModal from './confirmation_modal';
+import {
+  OnboardingModal,
+  MuteModal,
+  ReportModal,
+  SettingsModal,
+  EmbedModal,
+  ListEditor,
+} from 'flavours/glitch/util/async-components';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': () => Promise.resolve({ default: MediaModal }),
+  'ONBOARDING': OnboardingModal,
+  'VIDEO': () => Promise.resolve({ default: VideoModal }),
+  'BOOST': () => Promise.resolve({ default: BoostModal }),
+  'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }),
+  'DOODLE': () => Promise.resolve({ default: DoodleModal }),
+  'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+  'MUTE': MuteModal,
+  'REPORT': ReportModal,
+  'SETTINGS': SettingsModal,
+  'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+  'EMBED': EmbedModal,
+  'LIST_EDITOR': ListEditor,
+};
+
+export default class ModalRoot extends React.PureComponent {
+
+  static propTypes = {
+    type: PropTypes.string,
+    props: PropTypes.object,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  state = {
+    revealed: false,
+  };
+
+  handleKeyUp = (e) => {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!this.props.type && !this.props.props.noEsc) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (!!nextProps.type && !this.props.type) {
+      this.activeElement = document.activeElement;
+
+      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    } else if (!nextProps.type) {
+      this.setState({ revealed: false });
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (!this.props.type && !!prevProps.type) {
+      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+      this.activeElement.focus();
+      this.activeElement = null;
+    }
+    if (this.props.type) {
+      requestAnimationFrame(() => {
+        this.setState({ revealed: true });
+      });
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  getSiblings = () => {
+    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
+  renderLoading = modalId => () => {
+    return ['MEDIA', 'VIDEO', 'BOOST', '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 { revealed } = this.state;
+    const visible = !!type;
+
+    if (!visible) {
+      return (
+        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
+      );
+    }
+
+    return (
+      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
+        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
+          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
+          <div role='dialog' className='modal-root__container'>
+            {
+              visible ?
+                (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+                  {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+                </BundleContainer>) :
+                null
+            }
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..0202b1ab1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Button from '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 {
+    isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+    account: state.getIn(['mutes', 'new', 'account']),
+    notifications: state.getIn(['mutes', 'new', 'notifications']),
+  };
+};
+
+const mapDispatchToProps = dispatch => {
+  return {
+    onConfirm(account, notifications) {
+      dispatch(muteAccount(account.get('id'), notifications));
+    },
+
+    onClose() {
+      dispatch(closeModal());
+    },
+
+    onToggleNotifications() {
+      dispatch(toggleHideNotifications());
+    },
+  };
+};
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class MuteModal extends React.PureComponent {
+
+  static propTypes = {
+    isSubmitting: PropTypes.bool.isRequired,
+    account: PropTypes.object.isRequired,
+    notifications: PropTypes.bool.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    onToggleNotifications: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm(this.props.account, this.props.notifications);
+  }
+
+  handleCancel = () => {
+    this.props.onClose();
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  toggleNotifications = () => {
+    this.props.onToggleNotifications();
+  }
+
+  render () {
+    const { account, notifications } = this.props;
+
+    return (
+      <div className='modal-root__modal mute-modal'>
+        <div className='mute-modal__container'>
+          <p>
+            <FormattedMessage
+              id='confirmations.mute.message'
+              defaultMessage='Are you sure you want to mute {name}?'
+              values={{ name: <strong>@{account.get('acct')}</strong> }}
+            />
+          </p>
+          <div>
+            <label htmlFor='mute-modal__hide-notifications-checkbox'>
+              <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
+              {' '}
+              <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} />
+            </label>
+          </div>
+        </div>
+
+        <div className='mute-modal__action-bar'>
+          <Button onClick={this.handleCancel} className='mute-modal__cancel-button'>
+            <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+          </Button>
+          <Button onClick={this.handleClick} ref={this.setRef}>
+            <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..4c910daec
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
@@ -0,0 +1,301 @@
+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 { WrappedComponent as RawComposer } from 'flavours/glitch/features/composer';
+import DrawerAccount from 'flavours/glitch/features/drawer/account';
+import DrawerSearch from 'flavours/glitch/features/drawer/search';
+import ColumnHeader from './column_header';
+import { me } from 'flavours/glitch/util/initial_state';
+
+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} />
+        <RawComposer
+          intl={intl}
+          privacy='public'
+          text='Awoo! #introductions'
+        />
+      </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'>
+      <DrawerSearch intl={intl} />
+
+      <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://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
+      <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p>
+    </div>
+  );
+};
+
+PageSix.propTypes = {
+  admin: ImmutablePropTypes.map,
+  domain: PropTypes.string.isRequired,
+};
+
+const mapStateToProps = state => ({
+  myAccount: state.getIn(['accounts', me]),
+  admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class OnboardingModal extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    myAccount: ImmutablePropTypes.map.isRequired,
+    domain: PropTypes.string.isRequired,
+    admin: ImmutablePropTypes.map,
+  };
+
+  state = {
+    currentIndex: 0,
+  };
+
+  componentWillMount() {
+    const { myAccount, admin, domain, intl } = this.props;
+    this.pages = [
+      <PageOne acct={myAccount.get('acct')} domain={domain} />,
+      <PageTwo myAccount={myAccount} 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..b4dc1e3d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { changeReportComment, submitReport } from 'flavours/glitch/actions/reports';
+import { refreshAccountTimeline } 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';
+
+const messages = defineMessages({
+  placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
+  submit: { id: 'report.submit', defaultMessage: 'Submit' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = state => {
+    const accountId = state.getIn(['reports', 'new', 'account_id']);
+
+    return {
+      isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+      account: getAccount(state, accountId),
+      comment: state.getIn(['reports', 'new', 'comment']),
+      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+    };
+  };
+
+  return mapStateToProps;
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class ReportModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    isSubmitting: PropTypes.bool,
+    account: ImmutablePropTypes.map,
+    statusIds: ImmutablePropTypes.orderedSet.isRequired,
+    comment: PropTypes.string.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleCommentChange = (e) => {
+    this.props.dispatch(changeReportComment(e.target.value));
+  }
+
+  handleSubmit = () => {
+    this.props.dispatch(submitReport());
+  }
+
+  componentDidMount () {
+    this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.account !== nextProps.account && nextProps.account) {
+      this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id')));
+    }
+  }
+
+  render () {
+    const { account, comment, intl, statusIds, isSubmitting } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    return (
+      <div className='modal-root__modal report-modal'>
+        <div className='report-modal__target'>
+          <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
+        </div>
+
+        <div className='report-modal__container'>
+          <div className='report-modal__statuses'>
+            <div>
+              {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
+            </div>
+          </div>
+
+          <div className='report-modal__comment'>
+            <textarea
+              className='setting-text light'
+              placeholder={intl.formatMessage(messages.placeholder)}
+              value={comment}
+              onChange={this.handleCommentChange}
+              disabled={isSubmitting}
+            />
+          </div>
+        </div>
+
+        <div className='report-modal__action-bar'>
+          <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/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..89b455dd8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { NavLink } from 'react-router-dom';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { debounce } from 'lodash';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+
+export const links = [
+  <NavLink className='tabs-bar__link primary' to='/statuses/new' data-preview-title-id='tabs_bar.compose' data-preview-icon='pencil' ><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
+
+  <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+
+  <NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='asterisk' ><i className='fa fa-fw fa-asterisk' /></NavLink>,
+];
+
+export function getIndex (path) {
+  return links.findIndex(link => link.props.to === path);
+}
+
+export function getLink (index) {
+  return links[index].props.to;
+}
+
+@injectIntl
+export default class TabsBar extends React.Component {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  }
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
+  handleClick = (e) => {
+    // Only apply optimization for touch devices, which we assume are slower
+    // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
+    if (isUserTouching()) {
+      e.preventDefault();
+      e.persist();
+
+      requestAnimationFrame(() => {
+        const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
+        const currentTab = tabs.find(tab => tab.classList.contains('active'));
+        const nextTab = tabs.find(tab => tab.contains(e.target));
+        const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)];
+
+
+        if (currentTab !== nextTab) {
+          if (currentTab) {
+            currentTab.classList.remove('active');
+          }
+
+          const listener = debounce(() => {
+            nextTab.removeEventListener('transitionend', listener);
+            this.context.router.history.push(to);
+          }, 50);
+
+          nextTab.addEventListener('transitionend', listener);
+          nextTab.classList.add('active');
+        }
+      });
+    }
+
+  }
+
+  render () {
+    const { intl: { formatMessage } } = this.props;
+
+    return (
+      <nav className='tabs-bar' ref={this.setRef}>
+        {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
+      </nav>
+    );
+  }
+
+}
diff --git a/app/javascript/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..4412fd0f7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
@@ -0,0 +1,34 @@
+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';
+
+export default class VideoModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    time: PropTypes.number,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    const { media, time, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div>
+          <Video
+            preview={media.get('preview_url')}
+            src={media.get('url')}
+            startTime={time}
+            onCloseVideo={onClose}
+            detailed
+            description={media.get('description')}
+          />
+        </div>
+      </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..95f95618b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import ColumnsArea from '../components/columns_area';
+
+const mapStateToProps = state => ({
+  columns: state.getIn(['settings', 'columns']),
+});
+
+export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/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..4bb90fb68
--- /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) => ({
+  loading: state.get('loadingBar'),
+});
+
+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..88d482bcf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
@@ -0,0 +1,18 @@
+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 => ({
+  notifications: getAlerts(state),
+});
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: alert => {
+      dispatch(dismissAlert(alert));
+    },
+  };
+};
+
+export default 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..eca85b8e6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -0,0 +1,77 @@
+import { connect } from 'react-redux';
+import StatusList from 'flavours/glitch/components/status_list';
+import { scrollTopTimeline } 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 makeGetStatusIds = () => createSelector([
+  (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
+  (state)           => state.get('statuses'),
+], (columnSettings, statusIds, statuses) => {
+  const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
+  let regex      = null;
+
+  try {
+    regex = rawRegex && new RegExp(rawRegex, 'i');
+  } catch (e) {
+    // Bad regex, don't affect filters
+  }
+
+  return statusIds.filter(id => {
+    const statusForId = statuses.get(id);
+    let showStatus    = true;
+
+    if (columnSettings.getIn(['shows', 'reblog']) === false) {
+      showStatus = showStatus && statusForId.get('reblog') === null;
+    }
+
+    if (columnSettings.getIn(['shows', 'reply']) === false) {
+      showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+    }
+
+    if (columnSettings.getIn(['shows', 'direct']) === false) {
+      showStatus = showStatus && statusForId.get('visibility') !== 'direct';
+    }
+
+    if (showStatus && regex && statusForId.get('account') !== me) {
+      const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
+      showStatus = !regex.test(searchIndex);
+    }
+
+    return showStatus;
+  });
+});
+
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, { timelineId }) => ({
+    statusIds: getStatusIds(state, { type: timelineId }),
+    isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
+    hasMore: !!state.getIn(['timelines', timelineId, 'next']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
+
+  onScrollToBottom: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, false));
+    loadMore();
+  }, 300, { leading: true }),
+
+  onScrollToTop: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, true));
+  }, 100),
+
+  onScroll: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, false));
+  }, 100),
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
new file mode 100644
index 000000000..fae705deb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -0,0 +1,458 @@
+import React from 'react';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import TabsBar from './components/tabs_bar';
+import ModalContainer from './containers/modal_container';
+import { connect } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { debounce } from 'lodash';
+import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose';
+import { refreshHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { refreshNotifications } from 'flavours/glitch/actions/notifications';
+import { clearHeight } from 'flavours/glitch/actions/height_cache';
+import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
+import UploadArea from './components/upload_area';
+import ColumnsAreaContainer from './containers/columns_area_container';
+import classNames from 'classnames';
+import {
+  Drawer,
+  Status,
+  GettingStarted,
+  KeyboardShortcuts,
+  PublicTimeline,
+  CommunityTimeline,
+  AccountTimeline,
+  AccountGallery,
+  HomeTimeline,
+  Followers,
+  Following,
+  Reblogs,
+  Favourites,
+  DirectTimeline,
+  HashtagTimeline,
+  Notifications,
+  FollowRequests,
+  GenericNotFound,
+  FavouritedStatuses,
+  ListTimeline,
+  Blocks,
+  Mutes,
+  PinnedStatuses,
+  Lists,
+  GettingStartedMisc,
+} from 'flavours/glitch/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import { me } from 'flavours/glitch/util/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+
+// Dummy import, to make sure that <Status /> ends up in the application bundle.
+// Without this it ends up in ~8 very commonly used bundles.
+import '../../../glitch/components/status';
+
+const messages = defineMessages({
+  beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
+});
+
+const mapStateToProps = state => ({
+  hasComposingText: state.getIn(['compose', 'text']) !== '',
+  layout: state.getIn(['local_settings', 'layout']),
+  isWide: state.getIn(['local_settings', 'stretch']),
+  navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+});
+
+const keyMap = {
+  help: '?',
+  new: 'n',
+  search: 's',
+  forceNew: 'option+n',
+  focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+  reply: 'r',
+  favourite: 'f',
+  boost: 'b',
+  mention: 'm',
+  open: ['enter', 'o'],
+  openProfile: 'p',
+  moveDown: ['down', 'j'],
+  moveUp: ['up', 'k'],
+  back: 'backspace',
+  goToHome: 'g h',
+  goToNotifications: 'g n',
+  goToLocal: 'g l',
+  goToFederated: 'g t',
+  goToDirect: 'g d',
+  goToStart: 'g s',
+  goToFavourites: 'g f',
+  goToPinned: 'g p',
+  goToProfile: 'g u',
+  goToBlocked: 'g b',
+  goToMuted: 'g m',
+  toggleSpoiler: 'x',
+};
+
+@connect(mapStateToProps)
+@injectIntl
+@withRouter
+export default class UI extends React.Component {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    children: PropTypes.node,
+    layout: PropTypes.string,
+    isWide: PropTypes.bool,
+    systemFontUi: PropTypes.bool,
+    navbarUnder: PropTypes.bool,
+    isComposing: PropTypes.bool,
+    hasComposingText: PropTypes.bool,
+    location: PropTypes.object,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    width: window.innerWidth,
+    draggingOver: false,
+  };
+
+  handleBeforeUnload = (e) => {
+    const { intl, hasComposingText } = this.props;
+
+    if (hasComposingText) {
+      // Setting returnValue to any string causes confirmation dialog.
+      // Many browsers no longer display this text to users,
+      // but we set user-friendly message for other browsers, e.g. Edge.
+      e.returnValue = intl.formatMessage(messages.beforeUnload);
+    }
+  }
+
+  handleResize = debounce(() => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.dispatch(clearHeight());
+
+    this.setState({ width: window.innerWidth });
+  }, 500, {
+    trailing: true,
+  });
+
+  handleDragEnter = (e) => {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+      this.setState({ draggingOver: true });
+    }
+  }
+
+  handleDragOver = (e) => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
+
+    }
+
+    return false;
+  }
+
+  handleDrop = (e) => {
+    e.preventDefault();
+
+    this.setState({ draggingOver: false });
+
+    if (e.dataTransfer && e.dataTransfer.files.length === 1) {
+      this.props.dispatch(uploadCompose(e.dataTransfer.files));
+    }
+  }
+
+  handleDragLeave = (e) => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
+    this.setState({ draggingOver: false });
+  }
+
+  closeUploadModal = () => {
+    this.setState({ draggingOver: false });
+  }
+
+  handleServiceWorkerPostMessage = ({ data }) => {
+    if (data.type === 'navigate') {
+      this.context.router.history.push(data.path);
+    } else {
+      console.warn('Unknown message type:', data.type);
+    }
+  }
+
+  componentWillMount () {
+    window.addEventListener('beforeunload', this.handleBeforeUnload, false);
+    window.addEventListener('resize', this.handleResize, { passive: true });
+    document.addEventListener('dragenter', this.handleDragEnter, false);
+    document.addEventListener('dragover', this.handleDragOver, false);
+    document.addEventListener('drop', this.handleDrop, false);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
+    document.addEventListener('dragend', this.handleDragEnd, false);
+
+    if ('serviceWorker' in  navigator) {
+      navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
+    }
+
+    this.props.dispatch(refreshHomeTimeline());
+    this.props.dispatch(refreshNotifications());
+  }
+
+  componentDidMount () {
+    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+    };
+  }
+
+  shouldComponentUpdate (nextProps) {
+    if (nextProps.navbarUnder !== this.props.navbarUnder) {
+      // Avoid expensive update just to toggle a class
+      this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
+
+      return false;
+    }
+
+    // Why isn't this working?!?
+    // return super.shouldComponentUpdate(nextProps, nextState);
+    return true;
+  }
+
+  componentDidUpdate (prevProps) {
+    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+      this.columnsAreaNode.handleChildrenContentChange();
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('beforeunload', this.handleBeforeUnload);
+    window.removeEventListener('resize', this.handleResize);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+    document.removeEventListener('dragend', this.handleDragEnd);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  setColumnsAreaRef = c => {
+    this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
+  }
+
+  handleHotkeyNew = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeySearch = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.search__input');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeyForceNew = e => {
+    this.handleHotkeyNew(e);
+    this.props.dispatch(resetCompose());
+  }
+
+  handleHotkeyFocusColumn = e => {
+    const index  = (e.key * 1) + 1; // First child is drawer, skip that
+    const column = this.node.querySelector(`.column:nth-child(${index})`);
+
+    if (column) {
+      const status = column.querySelector('.focusable');
+
+      if (status) {
+        status.focus();
+      }
+    }
+  }
+
+  handleHotkeyBack = () => {
+    if (window.history && window.history.length === 1) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  setHotkeysRef = c => {
+    this.hotkeys = c;
+  }
+
+  handleHotkeyToggleHelp = () => {
+    if (this.props.location.pathname === '/keyboard-shortcuts') {
+      this.context.router.history.goBack();
+    } else {
+      this.context.router.history.push('/keyboard-shortcuts');
+    }
+  }
+
+  handleHotkeyGoToHome = () => {
+    this.context.router.history.push('/timelines/home');
+  }
+
+  handleHotkeyGoToNotifications = () => {
+    this.context.router.history.push('/notifications');
+  }
+
+  handleHotkeyGoToLocal = () => {
+    this.context.router.history.push('/timelines/public/local');
+  }
+
+  handleHotkeyGoToFederated = () => {
+    this.context.router.history.push('/timelines/public');
+  }
+
+  handleHotkeyGoToDirect = () => {
+    this.context.router.history.push('/timelines/direct');
+  }
+
+  handleHotkeyGoToStart = () => {
+    this.context.router.history.push('/getting-started');
+  }
+
+  handleHotkeyGoToFavourites = () => {
+    this.context.router.history.push('/favourites');
+  }
+
+  handleHotkeyGoToPinned = () => {
+    this.context.router.history.push('/pinned');
+  }
+
+  handleHotkeyGoToProfile = () => {
+    this.context.router.history.push(`/accounts/${me}`);
+  }
+
+  handleHotkeyGoToBlocked = () => {
+    this.context.router.history.push('/blocks');
+  }
+
+  handleHotkeyGoToMuted = () => {
+    this.context.router.history.push('/mutes');
+  }
+
+  render () {
+    const { width, draggingOver } = this.state;
+    const { children, layout, isWide, navbarUnder } = this.props;
+
+    const columnsClass = layout => {
+      switch (layout) {
+      case 'single':
+        return 'single-column';
+      case 'multiple':
+        return 'multi-columns';
+      default:
+        return 'auto-columns';
+      }
+    };
+
+    const className = classNames('ui', columnsClass(layout), {
+      'wide': isWide,
+      'system-font': this.props.systemFontUi,
+      'navbar-under': navbarUnder,
+    });
+
+    const handlers = {
+      help: this.handleHotkeyToggleHelp,
+      new: this.handleHotkeyNew,
+      search: this.handleHotkeySearch,
+      forceNew: this.handleHotkeyForceNew,
+      focusColumn: this.handleHotkeyFocusColumn,
+      back: this.handleHotkeyBack,
+      goToHome: this.handleHotkeyGoToHome,
+      goToNotifications: this.handleHotkeyGoToNotifications,
+      goToLocal: this.handleHotkeyGoToLocal,
+      goToFederated: this.handleHotkeyGoToFederated,
+      goToDirect: this.handleHotkeyGoToDirect,
+      goToStart: this.handleHotkeyGoToStart,
+      goToFavourites: this.handleHotkeyGoToFavourites,
+      goToPinned: this.handleHotkeyGoToPinned,
+      goToProfile: this.handleHotkeyGoToProfile,
+      goToBlocked: this.handleHotkeyGoToBlocked,
+      goToMuted: this.handleHotkeyGoToMuted,
+    };
+
+    return (
+      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
+        <div className={className} ref={this.setRef}>
+          {navbarUnder ? null : (<TabsBar />)}
+
+          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
+            <WrappedSwitch>
+              <Redirect from='/' to='/getting-started' exact />
+              <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+              <WrappedRoute path='/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' 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='/pinned' component={PinnedStatuses} content={children} />
+
+              <WrappedRoute path='/statuses/new' component={Drawer} content={children} />
+              <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
+              <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
+              <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
+
+              <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
+              <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
+              <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
+              <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
+
+              <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
+              <WrappedRoute path='/blocks' component={Blocks} content={children} />
+              <WrappedRoute path='/mutes' component={Mutes} content={children} />
+              <WrappedRoute path='/lists' component={Lists} content={children} />
+              <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
+
+              <WrappedRoute component={GenericNotFound} content={children} />
+            </WrappedSwitch>
+          </ColumnsAreaContainer>
+
+          <NotificationsContainer />
+          {navbarUnder ? (<TabsBar />) : null}
+          <LoadingBarContainer className='loading-bar' />
+          <ModalContainer />
+          <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
new file mode 100644
index 000000000..ef19a85ec
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -0,0 +1,318 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+  hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+  expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+  close: { id: 'video.close', defaultMessage: 'Close video' },
+  fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+  exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+const 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}`;
+};
+
+const findElementPosition = el => {
+  let box;
+
+  if (el.getBoundingClientRect && el.parentNode) {
+    box = el.getBoundingClientRect();
+  }
+
+  if (!box) {
+    return {
+      left: 0,
+      top: 0,
+    };
+  }
+
+  const docEl = document.documentElement;
+  const body  = document.body;
+
+  const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+  const scrollLeft = window.pageXOffset || body.scrollLeft;
+  const left       = (box.left + scrollLeft) - clientLeft;
+
+  const clientTop = docEl.clientTop || body.clientTop || 0;
+  const scrollTop = window.pageYOffset || body.scrollTop;
+  const top       = (box.top + scrollTop) - clientTop;
+
+  return {
+    left: Math.round(left),
+    top: Math.round(top),
+  };
+};
+
+const getPointerPosition = (el, event) => {
+  const position = {};
+  const box = findElementPosition(el);
+  const boxW = el.offsetWidth;
+  const boxH = el.offsetHeight;
+  const boxY = box.top;
+  const boxX = box.left;
+
+  let pageY = event.pageY;
+  let pageX = event.pageX;
+
+  if (event.changedTouches) {
+    pageX = event.changedTouches[0].pageX;
+    pageY = event.changedTouches[0].pageY;
+  }
+
+  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+  return position;
+};
+
+@injectIntl
+export default class Video extends React.PureComponent {
+
+  static propTypes = {
+    preview: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    sensitive: PropTypes.bool,
+    startTime: PropTypes.number,
+    onOpenVideo: PropTypes.func,
+    onCloseVideo: PropTypes.func,
+    letterbox: PropTypes.bool,
+    fullwidth: PropTypes.bool,
+    detailed: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    currentTime: 0,
+    duration: 0,
+    paused: true,
+    dragging: false,
+    fullscreen: false,
+    hovered: false,
+    muted: false,
+    revealed: !this.props.sensitive,
+  };
+
+  setPlayerRef = c => {
+    this.player = c;
+  }
+
+  setVideoRef = c => {
+    this.video = c;
+  }
+
+  setSeekRef = c => {
+    this.seek = c;
+  }
+
+  handlePlay = () => {
+    this.setState({ paused: false });
+  }
+
+  handlePause = () => {
+    this.setState({ paused: true });
+  }
+
+  handleTimeUpdate = () => {
+    this.setState({
+      currentTime: Math.floor(this.video.currentTime),
+      duration: Math.floor(this.video.duration),
+    });
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove, true);
+    document.addEventListener('mouseup', this.handleMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseMove, true);
+    document.addEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: true });
+    this.video.pause();
+    this.handleMouseMove(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove, true);
+    document.removeEventListener('mouseup', this.handleMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseMove, true);
+    document.removeEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: false });
+    this.video.play();
+  }
+
+  handleMouseMove = throttle(e => {
+    const { x } = getPointerPosition(this.seek, e);
+    const currentTime = Math.floor(this.video.duration * x);
+
+    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);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+  }
+
+  handleFullscreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  toggleMute = () => {
+    this.video.muted = !this.video.muted;
+    this.setState({ muted: this.video.muted });
+  }
+
+  toggleReveal = () => {
+    if (this.state.revealed) {
+      this.video.pause();
+    }
+
+    this.setState({ revealed: !this.state.revealed });
+  }
+
+  handleLoadedData = () => {
+    if (this.props.startTime) {
+      this.video.currentTime = this.props.startTime;
+      this.video.play();
+    }
+  }
+
+  handleProgress = () => {
+    if (this.video.buffered.length > 0) {
+      this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+    }
+  }
+
+  handleOpenVideo = () => {
+    this.video.pause();
+    this.props.onOpenVideo(this.video.currentTime);
+  }
+
+  handleCloseVideo = () => {
+    this.video.pause();
+    this.props.onCloseVideo();
+  }
+
+  render () {
+    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed } = this.props;
+    const { currentTime, duration, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const progress = (currentTime / duration) * 100;
+
+    return (
+      <div className={classNames('video-player', { inactive: !revealed, detailed, inline: width && height && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <video
+          ref={this.setVideoRef}
+          src={src}
+          poster={preview}
+          preload={startTime ? 'auto' : 'none'}
+          loop
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          width={width}
+          height={height}
+          onClick={this.togglePlay}
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onTimeUpdate={this.handleTimeUpdate}
+          onLoadedData={this.handleLoadedData}
+          onProgress={this.handleProgress}
+        />
+
+        <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
+          <span className='video-player__spoiler__title'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </button>
+
+        <div className={classNames('video-player__controls', { active: paused || hovered })}>
+          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+            <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
+            <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
+
+            <span
+              className={classNames('video-player__seek__handle', { active: dragging })}
+              tabIndex='0'
+              style={{ left: `${progress}%` }}
+            />
+          </div>
+
+          <div className='video-player__buttons-bar'>
+            <div className='video-player__buttons left'>
+              <button aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button>
+              <button aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
+
+              {!onCloseVideo && <button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>}
+
+              {(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>
+              }
+            </div>
+
+            <div className='video-player__buttons right'>
+              {(!fullscreen && onOpenVideo) && <button aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
+              {onCloseVideo && <button aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>}
+              <button aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/images/glitch-preview.jpg b/app/javascript/flavours/glitch/images/glitch-preview.jpg
new file mode 100644
index 000000000..fc5c42043
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/glitch-preview.jpg
Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-0.png b/app/javascript/flavours/glitch/images/mbstobon-ui-0.png
new file mode 100644
index 000000000..25e1707c9
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/mbstobon-ui-0.png
Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-1.png b/app/javascript/flavours/glitch/images/mbstobon-ui-1.png
new file mode 100644
index 000000000..64cf3cbf3
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/mbstobon-ui-1.png
Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-2.png b/app/javascript/flavours/glitch/images/mbstobon-ui-2.png
new file mode 100644
index 000000000..b767a9122
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/mbstobon-ui-2.png
Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-3.png b/app/javascript/flavours/glitch/images/mbstobon-ui-3.png
new file mode 100644
index 000000000..a1fb642a0
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/mbstobon-ui-3.png
Binary files differdiff --git a/app/javascript/flavours/glitch/images/wave-drawer-glitched.png b/app/javascript/flavours/glitch/images/wave-drawer-glitched.png
new file mode 100644
index 000000000..2290663db
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/wave-drawer-glitched.png
Binary files differdiff --git a/app/javascript/flavours/glitch/images/wave-drawer.png b/app/javascript/flavours/glitch/images/wave-drawer.png
new file mode 100644
index 000000000..ca9f9e1d8
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/wave-drawer.png
Binary files differdiff --git a/app/javascript/flavours/glitch/locales/ar.js b/app/javascript/flavours/glitch/locales/ar.js
new file mode 100644
index 000000000..1081147d5
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ar.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/ar.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/bg.js b/app/javascript/flavours/glitch/locales/bg.js
new file mode 100644
index 000000000..979039376
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/bg.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/bg.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/ca.js b/app/javascript/flavours/glitch/locales/ca.js
new file mode 100644
index 000000000..baf76bd6f
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ca.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/ca.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/de.js b/app/javascript/flavours/glitch/locales/de.js
new file mode 100644
index 000000000..ce6453623
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/de.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/de.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/en.js b/app/javascript/flavours/glitch/locales/en.js
new file mode 100644
index 000000000..fb3763ced
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/en.js
@@ -0,0 +1,66 @@
+import inherited from 'mastodon/locales/en.json';
+
+const messages = {
+  'getting_started.open_source_notice': 'Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.',
+  'layout.auto': 'Auto',
+  'layout.current_is': 'Your current layout is:',
+  'layout.desktop': 'Desktop',
+  'layout.mobile': 'Mobile',
+  'navigation_bar.app_settings': 'App settings',
+  'getting_started.onboarding': 'Show me around',
+  'onboarding.page_one.federation': '{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.',
+  'onboarding.page_one.welcome': 'Welcome to {domain}!',
+  'onboarding.page_six.github': '{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.',
+  'settings.auto_collapse': 'Automatic collapsing',
+  'settings.auto_collapse_all': 'Everything',
+  'settings.auto_collapse_lengthy': 'Lengthy toots',
+  'settings.auto_collapse_media': 'Toots with media',
+  'settings.auto_collapse_notifications': 'Notifications',
+  'settings.auto_collapse_reblogs': 'Boosts',
+  'settings.auto_collapse_replies': 'Replies',
+  'settings.close': 'Close',
+  'settings.collapsed_statuses': 'Collapsed toots',
+  'settings.enable_collapsed': 'Enable collapsed toots',
+  'settings.general': 'General',
+  'settings.image_backgrounds': 'Image backgrounds',
+  'settings.image_backgrounds_media': 'Preview collapsed toot media',
+  'settings.image_backgrounds_users': 'Give collapsed toots an image background',
+  'settings.media': 'Media',
+  'settings.media_letterbox': 'Letterbox media',
+  'settings.media_fullwidth': 'Full-width media previews',
+  'settings.preferences': 'User preferences',
+  'settings.wide_view': 'Wide view (Desktop mode only)',
+  'settings.navbar_under': 'Navbar at the bottom (Mobile only)',
+  'status.collapse': 'Collapse',
+  'status.uncollapse': 'Uncollapse',
+
+  'media_gallery.sensitive': 'Sensitive',
+
+  'favourite_modal.combo': 'You can press {combo} to skip this next time',
+
+  'home.column_settings.show_direct': 'Show DMs',
+
+  'notification.markForDeletion': 'Mark for deletion',
+  'notifications.clear': 'Clear all my notifications',
+  'notifications.marked_clear_confirmation': 'Are you sure you want to permanently clear all selected notifications?',
+  'notifications.marked_clear': 'Clear selected notifications',
+
+  'notification_purge.btn_all': 'Select\nall',
+  'notification_purge.btn_none': 'Select\nnone',
+  'notification_purge.btn_invert': 'Invert\nselection',
+  'notification_purge.btn_apply': 'Clear\nselected',
+
+  'compose.attach.upload': 'Upload a file',
+  'compose.attach.doodle': 'Draw something',
+  'compose.attach': 'Attach...',
+
+  'advanced_options.local-only.short': 'Local-only',
+  'advanced_options.local-only.long': 'Do not post to other instances',
+  'advanced_options.local-only.tooltip': 'This post is local-only',
+  'advanced_options.icon_title': 'Advanced options',
+  'advanced_options.threaded_mode.short': 'Threaded mode',
+  'advanced_options.threaded_mode.long': 'Automatically opens a reply on posting',
+  'advanced_options.threaded_mode.tooltip': 'Threaded mode enabled',
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/eo.js b/app/javascript/flavours/glitch/locales/eo.js
new file mode 100644
index 000000000..04192f506
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/eo.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/eo.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/es.js b/app/javascript/flavours/glitch/locales/es.js
new file mode 100644
index 000000000..456df3c47
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/es.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/es.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/fa.js b/app/javascript/flavours/glitch/locales/fa.js
new file mode 100644
index 000000000..d82461a1a
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fa.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/fa.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/fi.js b/app/javascript/flavours/glitch/locales/fi.js
new file mode 100644
index 000000000..11c3cd082
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fi.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/fi.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/fr.js b/app/javascript/flavours/glitch/locales/fr.js
new file mode 100644
index 000000000..8562f5594
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fr.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/fr.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/he.js b/app/javascript/flavours/glitch/locales/he.js
new file mode 100644
index 000000000..99516ee0c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/he.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/he.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/hr.js b/app/javascript/flavours/glitch/locales/hr.js
new file mode 100644
index 000000000..dbf9b4b9f
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/hr.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/hr.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/hu.js b/app/javascript/flavours/glitch/locales/hu.js
new file mode 100644
index 000000000..1f0849af3
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/hu.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/hu.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/id.js b/app/javascript/flavours/glitch/locales/id.js
new file mode 100644
index 000000000..07e5f7e56
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/id.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/id.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/io.js b/app/javascript/flavours/glitch/locales/io.js
new file mode 100644
index 000000000..74ea6fae6
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/io.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/io.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/it.js b/app/javascript/flavours/glitch/locales/it.js
new file mode 100644
index 000000000..90f543093
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/it.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/it.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/ja.js b/app/javascript/flavours/glitch/locales/ja.js
new file mode 100644
index 000000000..38f11d3f7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ja.js
@@ -0,0 +1,67 @@
+import inherited from 'mastodon/locales/ja.json';
+
+const messages = {
+  'getting_started.open_source_notice': 'Glitchsocは{Mastodon}によるフリーなオープンソースソフトウェアです。誰でもGitHub({github})から開発に參加したり、問題を報告したりできます。',
+  'layout.auto': '自動',
+  'layout.current_is': 'あなたの現在のレイアウト:',
+  'layout.desktop': 'Desktop',
+  'layout.mobile': 'Mobile',
+  'navigation_bar.app_settings': 'アプリ設定',
+  'getting_started.onboarding': '解説を表示',
+  'onboarding.page_one.federation': '{domain}はMastodonのインスタンスです。Mastodonとは、独立したサーバが連携して作るソーシャルネットワークです。これらのサーバーをインスタンスと呼びます。',
+  'onboarding.page_one.welcome': '{domain}へようこそ!',
+  'onboarding.page_six.github': '{domain}はGlitchsocを使用しています。Glitchsocは{Mastodon}のフレンドリーな{fork}で、どんなMastodonアプリやインスタンスとも互換性があります。Glitchsocは完全に無料で、オープンソースです。{github}でバグ報告や機能要望あるいは貢獻をすることが可能です。',
+  'settings.auto_collapse': '自動折りたたみ',
+  'settings.auto_collapse_all': 'すべて',
+  'settings.auto_collapse_lengthy': '長いトゥート',
+  'settings.auto_collapse_media': 'メディア付きトゥート',
+  'settings.auto_collapse_notifications': '通知',
+  'settings.auto_collapse_reblogs': 'ブースト',
+  'settings.auto_collapse_replies': '返信',
+  'settings.close': '閉じる',
+  'settings.collapsed_statuses': 'トゥート',
+  'settings.enable_collapsed': 'トゥート折りたたみを有効にする',
+  'settings.general': '一般',
+  'settings.image_backgrounds': '画像背景',
+  'settings.image_backgrounds_media': '折りたまれたメディア付きトゥートをプレビュー',
+  'settings.image_backgrounds_users': '折りたまれたトゥートの背景を変更する',
+  'settings.media': 'メディア',
+  'settings.media_letterbox': 'メディアをレターボックス式で表示',
+  'settings.media_fullwidth': '全幅メディアプレビュー',
+  'settings.preferences': 'ユーザー設定',
+  'settings.wide_view': 'ワイドビュー(Desktopレイアウトのみ)',
+  'settings.navbar_under': 'ナビを画面下部に移動させる(Mobileレイアウトのみ)',
+  'settings.compose_box_opts': 'コンポーズボックス設定',
+  'settings.side_arm': 'セカンダリートゥートボタン',
+  'settings.layout': 'レイアウト',
+  'status.collapse': '折りたたむ',
+  'status.uncollapse': '折りたたみを解除',
+
+  'favourite_modal.combo': '次からは {combo} を押せば、これをスキップできます。',
+
+  'home.column_settings.show_direct': 'DMを表示',
+
+  'notification.markForDeletion': '選択',
+  'notifications.clear': '通知を全てクリアする',
+  'notifications.marked_clear_confirmation': '削除した全ての通知を完全に削除してもよろしいですか?',
+  'notifications.marked_clear': '選択した通知を削除する',
+
+  'notification_purge.btn_all': 'すべて\n選択',
+  'notification_purge.btn_none': '選択\n解除',
+  'notification_purge.btn_invert': '選択を\n反転',
+  'notification_purge.btn_apply': '選択したものを\n削除',
+
+  'compose.attach.upload': 'ファイルをアップロード',
+  'compose.attach.doodle': '落書きをする',
+  'compose.attach': 'アタッチ...',
+
+  'advanced_options.local-only.short': 'ローカル限定',
+  'advanced_options.local-only.long': '他のインスタンスには投稿されません',
+  'advanced_options.local-only.tooltip': 'この投稿はローカル限定投稿です',
+  'advanced_options.icon_title': '高度な設定',
+  'advanced_options.threaded_mode.short': 'スレッドモード',
+  'advanced_options.threaded_mode.long': '投稿時に自動的に返信するように設定します',
+  'advanced_options.threaded_mode.tooltip': 'スレッドモードを有効にする',
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/ko.js b/app/javascript/flavours/glitch/locales/ko.js
new file mode 100644
index 000000000..3b55f89b9
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ko.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/ko.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/nl.js b/app/javascript/flavours/glitch/locales/nl.js
new file mode 100644
index 000000000..17c371c58
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/nl.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/nl.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/no.js b/app/javascript/flavours/glitch/locales/no.js
new file mode 100644
index 000000000..794b1da25
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/no.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/no.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/oc.js b/app/javascript/flavours/glitch/locales/oc.js
new file mode 100644
index 000000000..8f161fd8c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/oc.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/oc.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/pl.js b/app/javascript/flavours/glitch/locales/pl.js
new file mode 100644
index 000000000..527fe1d2d
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/pl.js
@@ -0,0 +1,66 @@
+import inherited from 'mastodon/locales/pl.json';
+
+const messages = {
+  'getting_started.open_source_notice': 'Glitchsoc jest wolnym i otwartoźródłowym forkiem oprogramowania {Mastodon}. Możesz współtworzyć projekt lub zgłaszać błędy na GitHubie pod adresem {github}.',
+  'layout.auto': 'Automatyczny',
+  'layout.current_is': 'Twój obecny układ to:',
+  'layout.desktop': 'Desktopowy',
+  'layout.mobile': 'Mobilny',
+  'navigation_bar.app_settings': 'Ustawienia aplikacji',
+  'getting_started.onboarding': 'Rozejrzyj się',
+  'onboarding.page_one.federation': '{domain} jest \'instancją\' Mastodona. Mastodon to sieć działających niezależnie serwerów tworzących jedną sieć społecznościową. Te serwery nazywane są instancjami.',
+  'onboarding.page_one.welcome': 'Witamy na {domain}!',
+  'onboarding.page_six.github': '{domain} jest oparty na Glitchsoc. Glitchsoc jest {forkiem} {Mastodon}a kompatybilnym z każdym klientem i aplikacją Mastodona. Glitchsoc jest całkowicie wolnym i otwartoźródłowym oprogramowaniem. Możesz zgłaszać błędy i sugestie funkcji oraz współtworzyć projekt na {github}.',
+  'settings.auto_collapse': 'Automatyczne zwijanie',
+  'settings.auto_collapse_all': 'Wszystko',
+  'settings.auto_collapse_lengthy': 'Długie wpisy',
+  'settings.auto_collapse_media': 'Wpisy z zawartością multimedialną',
+  'settings.auto_collapse_notifications': 'Powiadomienia',
+  'settings.auto_collapse_reblogs': 'Podbicia',
+  'settings.auto_collapse_replies': 'Odpowiedzi',
+  'settings.close': 'Zamknij',
+  'settings.collapsed_statuses': 'Zwijanie wpisów',
+  'settings.enable_collapsed': 'Włącz zwijanie wpisów',
+  'settings.general': 'Ogólne',
+  'settings.image_backgrounds': 'Obrazy w tle',
+  'settings.image_backgrounds_media': 'Wyświetlaj zawartość multimedialną zwiniętych wpisów',
+  'settings.image_backgrounds_users': 'Nadaj tło zwiniętym wpisom',
+  'settings.media': 'Zawartość multimedialna',
+  'settings.media_letterbox': 'Letterbox media',
+  'settings.media_fullwidth': 'Podgląd zawartości multimedialnej o pełnej szerokości',
+  'settings.preferences': 'Preferencje użytkownika',
+  'settings.wide_view': 'Szeroki widok (tylko w trybie desktopowym)',
+  'settings.navbar_under': 'Pasek nawigacji na dole (tylko w trybie mobilnym)',
+  'status.collapse': 'Zwiń',
+  'status.uncollapse': 'Rozwiń',
+
+  'media_gallery.sensitive': 'Zawartość wrażliwa',
+
+  'favourite_modal.combo': 'Możesz nacisnąć {combo}, aby pominąć to następnym razem',
+
+  'home.column_settings.show_direct': 'Pokaż wiadomości bezpośrednie',
+
+  'notification.markForDeletion': 'Oznacz do usunięcia',
+  'notifications.clear': 'Wyczyść wszystkie powiadomienia',
+  'notifications.marked_clear_confirmation': 'Czy na pewno chcesz bezpowrtonie usunąć wszystkie powiadomienia?',
+  'notifications.marked_clear': 'Usuń zaznaczone powiadomienia',
+
+  'notification_purge.btn_all': 'Zaznacz\nwszystkie',
+  'notification_purge.btn_none': 'Odznacz\nwszystkie',
+  'notification_purge.btn_invert': 'Odwróć\nzaznaczenie',
+  'notification_purge.btn_apply': 'Usuń\nzaznaczone',
+
+  'compose.attach.upload': 'Wyślij plik',
+  'compose.attach.doodle': 'Narysuj coś',
+  'compose.attach': 'Załącz coś',
+
+  'advanced_options.local-only.short': 'Tylko lokalnie',
+  'advanced_options.local-only.long': 'Nie wysyłaj na inne instancje',
+  'advanced_options.local-only.tooltip': 'Ten wpis jest widoczny tylko lokalnie',
+  'advanced_options.icon_title': 'Ustawienia zaawansowane',
+  'advanced_options.threaded_mode.short': 'Tryb wątków',
+  'advanced_options.threaded_mode.long': 'Przechodzi do tworzenia odpowiedzi po publikacji wpisu',
+  'advanced_options.threaded_mode.tooltip': 'Włączono tryb wątków',
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/pt-BR.js b/app/javascript/flavours/glitch/locales/pt-BR.js
new file mode 100644
index 000000000..6fed635f8
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/pt-BR.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/pt-BR.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/pt.js b/app/javascript/flavours/glitch/locales/pt.js
new file mode 100644
index 000000000..0156f55ff
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/pt.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/pt.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/ru.js b/app/javascript/flavours/glitch/locales/ru.js
new file mode 100644
index 000000000..0e9f1de71
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ru.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/ru.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/sv.js b/app/javascript/flavours/glitch/locales/sv.js
new file mode 100644
index 000000000..b62c353fe
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sv.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/sv.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/th.js b/app/javascript/flavours/glitch/locales/th.js
new file mode 100644
index 000000000..e939f8631
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/th.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/th.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/tr.js b/app/javascript/flavours/glitch/locales/tr.js
new file mode 100644
index 000000000..c2b740617
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/tr.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/tr.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/uk.js b/app/javascript/flavours/glitch/locales/uk.js
new file mode 100644
index 000000000..ab6d9a7dc
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/uk.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/uk.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/zh-CN.js b/app/javascript/flavours/glitch/locales/zh-CN.js
new file mode 100644
index 000000000..944588e02
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/zh-CN.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/zh-CN.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/zh-HK.js b/app/javascript/flavours/glitch/locales/zh-HK.js
new file mode 100644
index 000000000..b71c81f2b
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/zh-HK.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/zh-HK.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/zh-TW.js b/app/javascript/flavours/glitch/locales/zh-TW.js
new file mode 100644
index 000000000..de2b7769c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/zh-TW.js
@@ -0,0 +1,7 @@
+import inherited from 'mastodon/locales/zh-TW.json';
+
+const messages = {
+  //  No translations available.
+};
+
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/middleware/errors.js b/app/javascript/flavours/glitch/middleware/errors.js
new file mode 100644
index 000000000..f3dfc8b06
--- /dev/null
+++ b/app/javascript/flavours/glitch/middleware/errors.js
@@ -0,0 +1,31 @@
+import { showAlert } from 'flavours/glitch/actions/alerts';
+
+const defaultFailSuffix = 'FAIL';
+
+export default function errorsMiddleware() {
+  return ({ dispatch }) => next => action => {
+    if (action.type && !action.skipAlert) {
+      const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
+
+      if (action.type.match(isFail)) {
+        if (action.error.response) {
+          const { data, status, statusText } = action.error.response;
+
+          let message = statusText;
+          let title   = `${status}`;
+
+          if (data.error) {
+            message = data.error;
+          }
+
+          dispatch(showAlert(title, message));
+        } else {
+          console.error(action.error);
+          dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
+        }
+      }
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/javascript/flavours/glitch/middleware/loading_bar.js b/app/javascript/flavours/glitch/middleware/loading_bar.js
new file mode 100644
index 000000000..a98f1bb2b
--- /dev/null
+++ b/app/javascript/flavours/glitch/middleware/loading_bar.js
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+  const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.type && !action.skipLoading) {
+      const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+      const isPending = new RegExp(`${PENDING}$`, 'g');
+      const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+      const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+      if (action.type.match(isPending)) {
+        dispatch(showLoading());
+      } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+        dispatch(hideLoading());
+      }
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/javascript/flavours/glitch/middleware/sounds.js b/app/javascript/flavours/glitch/middleware/sounds.js
new file mode 100644
index 000000000..3d1e3eaba
--- /dev/null
+++ b/app/javascript/flavours/glitch/middleware/sounds.js
@@ -0,0 +1,46 @@
+const createAudio = sources => {
+  const audio = new Audio();
+  sources.forEach(({ type, src }) => {
+    const source = document.createElement('source');
+    source.type = type;
+    source.src = src;
+    audio.appendChild(source);
+  });
+  return audio;
+};
+
+const play = audio => {
+  if (!audio.paused) {
+    audio.pause();
+    if (typeof audio.fastSeek === 'function') {
+      audio.fastSeek(0);
+    } else {
+      audio.seek(0);
+    }
+  }
+
+  audio.play();
+};
+
+export default function soundsMiddleware() {
+  const soundCache = {
+    boop: createAudio([
+      {
+        src: '/sounds/boop.ogg',
+        type: 'audio/ogg',
+      },
+      {
+        src: '/sounds/boop.mp3',
+        type: 'audio/mpeg',
+      },
+    ]),
+  };
+
+  return () => next => action => {
+    if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+      play(soundCache[action.meta.sound]);
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/javascript/flavours/glitch/names.yml b/app/javascript/flavours/glitch/names.yml
new file mode 100644
index 000000000..0801c4565
--- /dev/null
+++ b/app/javascript/flavours/glitch/names.yml
@@ -0,0 +1,15 @@
+en:
+  flavours:
+    glitch:
+      description: The default flavour for GlitchSoc instances.
+      name: Glitch Edition
+  skins:
+    glitch:
+      default: Default
+pl:
+  flavours:
+    glitch:
+      description: Domyślny motyw instancji GlitchSoc.
+  skins:
+    glitch:
+      default: Domyślny
diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js
new file mode 100644
index 000000000..bc0a4887b
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/about.js
@@ -0,0 +1,22 @@
+import loadPolyfills from 'flavours/glitch/util/load_polyfills';
+
+function loaded() {
+  const TimelineContainer = require('flavours/glitch/containers/timeline_container').default;
+  const React             = require('react');
+  const ReactDOM          = require('react-dom');
+  const mountNode         = document.getElementById('mastodon-timeline');
+
+  if (mountNode !== null) {
+    const props = JSON.parse(mountNode.getAttribute('data-props'));
+    ReactDOM.render(<TimelineContainer {...props} />, mountNode);
+  }
+}
+
+function main() {
+  const ready = require('flavours/glitch/util/ready').default;
+  ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+  console.error(error);
+});
diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js
new file mode 100644
index 000000000..8dd4372bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/common.js
@@ -0,0 +1,4 @@
+import 'flavours/glitch/styles/index.scss';
+
+//  This ensures that webpack compiles our images.
+require.context('../images', true);
diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js
new file mode 100644
index 000000000..b8f7b7d8e
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/home.js
@@ -0,0 +1,7 @@
+import loadPolyfills from 'flavours/glitch/util/load_polyfills';
+
+loadPolyfills().then(() => {
+  require('flavours/glitch/util/main').default();
+}).catch(e => {
+  console.error(e);
+});
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
new file mode 100644
index 000000000..9ea82b53a
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -0,0 +1,75 @@
+import loadPolyfills from 'flavours/glitch/util/load_polyfills';
+import ready from 'flavours/glitch/util/ready';
+
+function main() {
+  const IntlRelativeFormat = require('intl-relativeformat').default;
+  const emojify = require('flavours/glitch/util/emoji').default;
+  const { getLocale } = require('locales');
+  const { localeData } = getLocale();
+  const VideoContainer = require('flavours/glitch/containers/video_container').default;
+  const MediaGalleryContainer = require('flavours/glitch/containers/media_gallery_container').default;
+  const CardContainer = require('flavours/glitch/containers/card_container').default;
+  const React = require('react');
+  const ReactDOM = require('react-dom');
+
+  localeData.forEach(IntlRelativeFormat.__addLocaleData);
+
+  ready(() => {
+    const locale = document.documentElement.lang;
+
+    const dateTimeFormat = new Intl.DateTimeFormat(locale, {
+      year: 'numeric',
+      month: 'long',
+      day: 'numeric',
+      hour: 'numeric',
+      minute: 'numeric',
+    });
+
+    const relativeFormat = new IntlRelativeFormat(locale);
+
+    [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
+      content.innerHTML = emojify(content.innerHTML);
+    });
+
+    [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
+      const datetime = new Date(content.getAttribute('datetime'));
+      const formattedDate = dateTimeFormat.format(datetime);
+
+      content.title = formattedDate;
+      content.textContent = formattedDate;
+    });
+
+    [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
+      const datetime = new Date(content.getAttribute('datetime'));
+
+      content.title = dateTimeFormat.format(datetime);
+      content.textContent = relativeFormat.format(datetime);
+    });
+
+    [].forEach.call(document.querySelectorAll('.logo-button'), (content) => {
+      content.addEventListener('click', (e) => {
+        e.preventDefault();
+        window.open(e.target.href, 'mastodon-intent', 'width=400,height=400,resizable=no,menubar=no,status=no,scrollbars=yes');
+      });
+    });
+
+    [].forEach.call(document.querySelectorAll('[data-component="Video"]'), (content) => {
+      const props = JSON.parse(content.getAttribute('data-props'));
+      ReactDOM.render(<VideoContainer locale={locale} {...props} />, content);
+    });
+
+    [].forEach.call(document.querySelectorAll('[data-component="MediaGallery"]'), (content) => {
+      const props = JSON.parse(content.getAttribute('data-props'));
+      ReactDOM.render(<MediaGalleryContainer locale={locale} {...props} />, content);
+    });
+
+    [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
+      const props = JSON.parse(content.getAttribute('data-props'));
+      ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
+    });
+  });
+}
+
+loadPolyfills().then(main).catch(error => {
+  console.error(error);
+});
diff --git a/app/javascript/flavours/glitch/packs/share.js b/app/javascript/flavours/glitch/packs/share.js
new file mode 100644
index 000000000..9f2aa2553
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/share.js
@@ -0,0 +1,22 @@
+import loadPolyfills from 'flavours/glitch/util/load_polyfills';
+
+function loaded() {
+  const ComposeContainer = require('flavours/glitch/containers/compose_container').default;
+  const React = require('react');
+  const ReactDOM = require('react-dom');
+  const mountNode = document.getElementById('mastodon-compose');
+
+  if (mountNode !== null) {
+    const props = JSON.parse(mountNode.getAttribute('data-props'));
+    ReactDOM.render(<ComposeContainer {...props} />, mountNode);
+  }
+}
+
+function main() {
+  const ready = require('flavours/glitch/util/ready').default;
+  ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+  console.error(error);
+});
diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js
new file mode 100644
index 000000000..9ca05881a
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts.js
@@ -0,0 +1,141 @@
+import {
+  ACCOUNT_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/favourites';
+import {
+  LIST_ACCOUNTS_FETCH_SUCCESS,
+  LIST_EDITOR_SUGGESTIONS_READY,
+} from 'flavours/glitch/actions/lists';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import emojify from 'flavours/glitch/util/emoji';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const normalizeAccount = (state, account) => {
+  account = { ...account };
+
+  delete account.followers_count;
+  delete account.following_count;
+  delete account.statuses_count;
+
+  const displayName = account.display_name.length === 0 ? account.username : account.display_name;
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
+  account.note_emojified = emojify(account.note);
+
+  return state.set(account.id, fromJS(account));
+};
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+  state = normalizeAccount(state, status.account);
+
+  if (status.reblog && status.reblog.account) {
+    state = normalizeAccount(state, status.reblog.account);
+  }
+
+  return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeAccountFromStatus(state, status);
+  });
+
+  return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function accounts(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts'));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+  case BLOCKS_FETCH_SUCCESS:
+  case BLOCKS_EXPAND_SUCCESS:
+  case MUTES_FETCH_SUCCESS:
+  case MUTES_EXPAND_SUCCESS:
+  case LIST_ACCOUNTS_FETCH_SUCCESS:
+  case LIST_EDITOR_SUGGESTIONS_READY:
+    return action.accounts ? normalizeAccounts(state, action.accounts) : state;
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/accounts_counters.js b/app/javascript/flavours/glitch/reducers/accounts_counters.js
new file mode 100644
index 000000000..0fd985a08
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts_counters.js
@@ -0,0 +1,144 @@
+import {
+  ACCOUNT_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/favourites';
+import {
+  LIST_ACCOUNTS_FETCH_SUCCESS,
+  LIST_EDITOR_SUGGESTIONS_READY,
+} from 'flavours/glitch/actions/lists';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeAccount = (state, account) => state.set(account.id, fromJS({
+  followers_count: account.followers_count,
+  following_count: account.following_count,
+  statuses_count: account.statuses_count,
+}));
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+  state = normalizeAccount(state, status.account);
+
+  if (status.reblog && status.reblog.account) {
+    state = normalizeAccount(state, status.reblog.account);
+  }
+
+  return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeAccountFromStatus(state, status);
+  });
+
+  return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function accountsCounters(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts').map(item => fromJS({
+      followers_count: item.get('followers_count'),
+      following_count: item.get('following_count'),
+      statuses_count: item.get('statuses_count'),
+    })));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+  case BLOCKS_FETCH_SUCCESS:
+  case BLOCKS_EXPAND_SUCCESS:
+  case MUTES_FETCH_SUCCESS:
+  case MUTES_EXPAND_SUCCESS:
+  case LIST_ACCOUNTS_FETCH_SUCCESS:
+  case LIST_EDITOR_SUGGESTIONS_READY:
+    return action.accounts ? normalizeAccounts(state, action.accounts) : state;
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    if (action.alreadyFollowing) {
+      return state;
+    }
+    return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/alerts.js b/app/javascript/flavours/glitch/reducers/alerts.js
new file mode 100644
index 000000000..50f8d30f7
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/alerts.js
@@ -0,0 +1,25 @@
+import {
+  ALERT_SHOW,
+  ALERT_DISMISS,
+  ALERT_CLEAR,
+} from 'flavours/glitch/actions/alerts';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableList([]);
+
+export default function alerts(state = initialState, action) {
+  switch(action.type) {
+  case ALERT_SHOW:
+    return state.push(ImmutableMap({
+      key: state.size > 0 ? state.last().get('key') + 1 : 0,
+      title: action.title,
+      message: action.message,
+    }));
+  case ALERT_DISMISS:
+    return state.filterNot(item => item.get('key') === action.alert.key);
+  case ALERT_CLEAR:
+    return state.clear();
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/cards.js b/app/javascript/flavours/glitch/reducers/cards.js
new file mode 100644
index 000000000..92ecfd086
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/cards.js
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from 'flavours/glitch/actions/cards';
+
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function cards(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.set(action.id, fromJS(action.card));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
new file mode 100644
index 000000000..722670cf1
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -0,0 +1,360 @@
+import {
+  COMPOSE_MOUNT,
+  COMPOSE_UNMOUNT,
+  COMPOSE_CHANGE,
+  COMPOSE_CYCLE_ELEFRIEND,
+  COMPOSE_REPLY,
+  COMPOSE_REPLY_CANCEL,
+  COMPOSE_MENTION,
+  COMPOSE_SUBMIT_REQUEST,
+  COMPOSE_SUBMIT_SUCCESS,
+  COMPOSE_SUBMIT_FAIL,
+  COMPOSE_UPLOAD_REQUEST,
+  COMPOSE_UPLOAD_SUCCESS,
+  COMPOSE_UPLOAD_FAIL,
+  COMPOSE_UPLOAD_UNDO,
+  COMPOSE_UPLOAD_PROGRESS,
+  COMPOSE_SUGGESTIONS_CLEAR,
+  COMPOSE_SUGGESTIONS_READY,
+  COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_ADVANCED_OPTIONS_CHANGE,
+  COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
+  COMPOSE_VISIBILITY_CHANGE,
+  COMPOSE_EMOJI_INSERT,
+  COMPOSE_UPLOAD_CHANGE_REQUEST,
+  COMPOSE_UPLOAD_CHANGE_SUCCESS,
+  COMPOSE_UPLOAD_CHANGE_FAIL,
+  COMPOSE_DOODLE_SET,
+  COMPOSE_RESET,
+} from 'flavours/glitch/actions/compose';
+import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
+import uuid from 'flavours/glitch/util/uuid';
+import { me } from 'flavours/glitch/util/initial_state';
+import { overwrite } from 'flavours/glitch/util/js_helpers';
+
+const totalElefriends = 3;
+
+// ~4% chance you'll end up with an unexpected friend
+// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z
+const glitchProbability = 1 - 0.0420215528;
+
+const initialState = ImmutableMap({
+  mounted: false,
+  advanced_options: ImmutableMap({
+    do_not_federate: false,
+    threaded_mode: false,
+  }),
+  sensitive: false,
+  elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
+  spoiler: false,
+  spoiler_text: '',
+  privacy: null,
+  text: '',
+  focusDate: null,
+  preselectDate: null,
+  in_reply_to: null,
+  is_submitting: false,
+  is_uploading: false,
+  progress: 0,
+  media_attachments: ImmutableList(),
+  suggestion_token: null,
+  suggestions: ImmutableList(),
+  default_advanced_options: ImmutableMap({
+    do_not_federate: false,
+    threaded_mode: null,  //  Do not reset
+  }),
+  default_privacy: 'public',
+  default_sensitive: false,
+  resetFileKey: Math.floor((Math.random() * 0x10000)),
+  idempotencyKey: null,
+  doodle: ImmutableMap({
+    fg: 'rgb(  0,    0,    0)',
+    bg: 'rgb(255,  255,  255)',
+    swapped: false,
+    mode: 'draw',
+    size: 'normal',
+    weight: 2,
+    opacity: 1,
+    adaptiveStroke: true,
+    smoothing: false,
+  }),
+});
+
+function statusToTextMentions(state, status) {
+  let set = ImmutableOrderedSet([]);
+
+  if (status.getIn(['account', 'id']) !== me) {
+    set = set.add(`@${status.getIn(['account', 'acct'])} `);
+  }
+
+  return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
+};
+
+function apiStatusToTextMentions (state, status) {
+  let set = ImmutableOrderedSet([]);
+
+  if (status.account.id !== me) {
+    set = set.add(`@${status.account.acct} `);
+  }
+
+  return set.union(status.mentions.filter(
+    mention => mention.id !== me
+  ).map(
+    mention => `@${mention.acct} `
+  )).join('');
+}
+
+function clearAll(state) {
+  return state.withMutations(map => {
+    map.set('text', '');
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
+    map.set('is_submitting', false);
+    map.set('in_reply_to', null);
+    map.update(
+      'advanced_options',
+      map => map.mergeWith(overwrite, state.get('default_advanced_options'))
+    );
+    map.set('privacy', state.get('default_privacy'));
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+function continueThread (state, status) {
+  return state.withMutations(function (map) {
+    map.set('text', apiStatusToTextMentions(state, status));
+    if (status.spoiler_text) {
+      map.set('spoiler', true);
+      map.set('spoiler_text', status.spoiler_text);
+    } else {
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+    }
+    map.set('is_submitting', false);
+    map.set('in_reply_to', status.id);
+    map.update(
+      'advanced_options',
+      map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(status.content) }))
+    );
+    map.set('privacy', status.visibility);
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('idempotencyKey', uuid());
+    map.set('focusDate', new Date());
+    map.set('preselectDate', new Date());
+  });
+}
+
+function appendMedia(state, media) {
+  const prevSize = state.get('media_attachments').size;
+
+  return state.withMutations(map => {
+    map.update('media_attachments', list => list.push(media));
+    map.set('is_uploading', false);
+    map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
+    map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`);
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+
+    if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
+      map.set('sensitive', true);
+    }
+  });
+};
+
+function removeMedia(state, mediaId) {
+  const media    = state.get('media_attachments').find(item => item.get('id') === mediaId);
+  const prevSize = state.get('media_attachments').size;
+
+  return state.withMutations(map => {
+    map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId));
+    map.update('text', text => text.replace(media.get('text_url'), '').trim());
+    map.set('idempotencyKey', uuid());
+
+    if (prevSize === 1) {
+      map.set('sensitive', false);
+    }
+  });
+};
+
+const insertSuggestion = (state, position, token, completion) => {
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.update('suggestions', ImmutableList(), list => list.clear());
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const insertEmoji = (state, position, emojiData) => {
+  const emoji = emojiData.native;
+
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const privacyPreference = (a, b) => {
+  if (a === 'direct' || b === 'direct') {
+    return 'direct';
+  } else if (a === 'private' || b === 'private') {
+    return 'private';
+  } else if (a === 'unlisted' || b === 'unlisted') {
+    return 'unlisted';
+  } else {
+    return 'public';
+  }
+};
+
+const hydrate = (state, hydratedState) => {
+  state = clearAll(state.merge(hydratedState));
+
+  if (hydratedState.has('text')) {
+    state = state.set('text', hydratedState.get('text'));
+  }
+
+  return state;
+};
+
+export default function compose(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return hydrate(state, action.state.get('compose'));
+  case COMPOSE_MOUNT:
+    return state.set('mounted', true);
+  case COMPOSE_UNMOUNT:
+    return state.set('mounted', false);
+  case COMPOSE_ADVANCED_OPTIONS_CHANGE:
+    return state
+      .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value)))
+      .set('idempotencyKey', uuid());
+  case COMPOSE_SENSITIVITY_CHANGE:
+    return state.withMutations(map => {
+      if (!state.get('spoiler')) {
+        map.set('sensitive', !state.get('sensitive'));
+      }
+
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SPOILERNESS_CHANGE:
+    return state.withMutations(map => {
+      map.set('spoiler_text', '');
+      map.set('spoiler', !state.get('spoiler'));
+      map.set('idempotencyKey', uuid());
+
+      if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
+        map.set('sensitive', true);
+      }
+    });
+  case COMPOSE_SPOILER_TEXT_CHANGE:
+    return state
+      .set('spoiler_text', action.text)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_VISIBILITY_CHANGE:
+    return state
+      .set('privacy', action.value)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_CHANGE:
+    return state
+      .set('text', action.text)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_CYCLE_ELEFRIEND:
+    return state
+      .set('elefriend', (state.get('elefriend') + 1) % totalElefriends);
+  case COMPOSE_REPLY:
+    return state.withMutations(map => {
+      map.set('in_reply_to', action.status.get('id'));
+      map.set('text', statusToTextMentions(state, action.status));
+      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+      map.update(
+        'advanced_options',
+        map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) }))
+      );
+      map.set('focusDate', new Date());
+      map.set('preselectDate', new Date());
+      map.set('idempotencyKey', uuid());
+
+      if (action.status.get('spoiler_text').length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.status.get('spoiler_text'));
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+    });
+  case COMPOSE_REPLY_CANCEL:
+  case COMPOSE_RESET:
+    return state.withMutations(map => {
+      map.set('in_reply_to', null);
+      map.set('text', '');
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+      map.set('privacy', state.get('default_privacy'));
+      map.update(
+        'advanced_options',
+        map => map.mergeWith(overwrite, state.get('default_advanced_options'))
+      );
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+  case COMPOSE_UPLOAD_CHANGE_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+  case COMPOSE_UPLOAD_CHANGE_FAIL:
+    return state.set('is_submitting', false);
+  case COMPOSE_UPLOAD_REQUEST:
+    return state.set('is_uploading', true);
+  case COMPOSE_UPLOAD_SUCCESS:
+    return appendMedia(state, fromJS(action.media));
+  case COMPOSE_UPLOAD_FAIL:
+    return state.set('is_uploading', false);
+  case COMPOSE_UPLOAD_UNDO:
+    return removeMedia(state, action.media_id);
+  case COMPOSE_UPLOAD_PROGRESS:
+    return state.set('progress', Math.round((action.loaded / action.total) * 100));
+  case COMPOSE_MENTION:
+    return state
+      .update('text', text => `${text}@${action.account.get('acct')} `)
+      .set('focusDate', new Date())
+      .set('idempotencyKey', uuid());
+  case COMPOSE_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
+  case COMPOSE_SUGGESTIONS_READY:
+    return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
+  case COMPOSE_SUGGESTION_SELECT:
+    return insertSuggestion(state, action.position, action.token, action.completion);
+  case TIMELINE_DELETE:
+    if (action.id === state.get('in_reply_to')) {
+      return state.set('in_reply_to', null);
+    } else {
+      return state;
+    }
+  case COMPOSE_EMOJI_INSERT:
+    return insertEmoji(state, action.position, action.emoji);
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state
+      .set('is_submitting', false)
+      .update('media_attachments', list => list.map(item => {
+        if (item.get('id') === action.media.id) {
+          return item.set('description', action.media.description);
+        }
+
+        return item;
+      }));
+  case COMPOSE_DOODLE_SET:
+    return state.mergeIn(['doodle'], action.options);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js
new file mode 100644
index 000000000..53e93a589
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/contexts.js
@@ -0,0 +1,61 @@
+import { CONTEXT_FETCH_SUCCESS } from 'flavours/glitch/actions/statuses';
+import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+  ancestors: ImmutableMap(),
+  descendants: ImmutableMap(),
+});
+
+const normalizeContext = (state, id, ancestors, descendants) => {
+  const ancestorsIds   = ImmutableList(ancestors.map(ancestor => ancestor.id));
+  const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
+
+  return state.withMutations(map => {
+    map.setIn(['ancestors', id], ancestorsIds);
+    map.setIn(['descendants', id], descendantsIds);
+  });
+};
+
+const deleteFromContexts = (state, id) => {
+  state.getIn(['descendants', id], ImmutableList()).forEach(descendantId => {
+    state = state.updateIn(['ancestors', descendantId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
+  });
+
+  state.getIn(['ancestors', id], ImmutableList()).forEach(ancestorId => {
+    state = state.updateIn(['descendants', ancestorId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
+  });
+
+  state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
+
+  return state;
+};
+
+const updateContext = (state, status, references) => {
+  return state.update('descendants', map => {
+    references.forEach(parentId => {
+      map = map.update(parentId, ImmutableList(), list => {
+        if (list.includes(status.id)) {
+          return list;
+        }
+
+        return list.push(status.id);
+      });
+    });
+
+    return map;
+  });
+};
+
+export default function contexts(state = initialState, action) {
+  switch(action.type) {
+  case CONTEXT_FETCH_SUCCESS:
+    return normalizeContext(state, action.id, action.ancestors, action.descendants);
+  case TIMELINE_DELETE:
+    return deleteFromContexts(state, action.id);
+  case TIMELINE_CONTEXT_UPDATE:
+    return updateContext(state, action.status, action.references);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/custom_emojis.js b/app/javascript/flavours/glitch/reducers/custom_emojis.js
new file mode 100644
index 000000000..592cea8dc
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/custom_emojis.js
@@ -0,0 +1,16 @@
+import { List as ImmutableList } from 'immutable';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
+import { buildCustomEmojis } from 'flavours/glitch/util/emoji';
+
+const initialState = ImmutableList();
+
+export default function custom_emojis(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
+    return action.state.get('custom_emojis');
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/height_cache.js b/app/javascript/flavours/glitch/reducers/height_cache.js
new file mode 100644
index 000000000..8b05e0b19
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/height_cache.js
@@ -0,0 +1,23 @@
+import { Map as ImmutableMap } from 'immutable';
+import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from 'flavours/glitch/actions/height_cache';
+
+const initialState = ImmutableMap();
+
+const setHeight = (state, key, id, height) => {
+  return state.update(key, ImmutableMap(), map => map.set(id, height));
+};
+
+const clearHeights = () => {
+  return ImmutableMap();
+};
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case HEIGHT_CACHE_SET:
+    return setHeight(state, action.key, action.id, action.height);
+  case HEIGHT_CACHE_CLEAR:
+    return clearHeights();
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
new file mode 100644
index 000000000..e9c8d7c1d
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -0,0 +1,58 @@
+import { combineReducers } from 'redux-immutable';
+import timelines from './timelines';
+import meta from './meta';
+import alerts from './alerts';
+import { loadingBarReducer } from 'react-redux-loading-bar';
+import modal from './modal';
+import user_lists from './user_lists';
+import accounts from './accounts';
+import accounts_counters from './accounts_counters';
+import statuses from './statuses';
+import relationships from './relationships';
+import settings from './settings';
+import local_settings from './local_settings';
+import push_notifications from './push_notifications';
+import status_lists from './status_lists';
+import cards from './cards';
+import mutes from './mutes';
+import reports from './reports';
+import contexts from './contexts';
+import compose from './compose';
+import search from './search';
+import media_attachments from './media_attachments';
+import notifications from './notifications';
+import height_cache from './height_cache';
+import custom_emojis from './custom_emojis';
+import lists from './lists';
+import listEditor from './list_editor';
+
+const reducers = {
+  timelines,
+  meta,
+  alerts,
+  loadingBar: loadingBarReducer,
+  modal,
+  user_lists,
+  status_lists,
+  accounts,
+  accounts_counters,
+  statuses,
+  relationships,
+  settings,
+  local_settings,
+  push_notifications,
+  cards,
+  mutes,
+  reports,
+  contexts,
+  compose,
+  search,
+  media_attachments,
+  notifications,
+  height_cache,
+  custom_emojis,
+  lists,
+  listEditor,
+};
+
+export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/list_editor.js b/app/javascript/flavours/glitch/reducers/list_editor.js
new file mode 100644
index 000000000..02a0dabb1
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/list_editor.js
@@ -0,0 +1,89 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  LIST_CREATE_REQUEST,
+  LIST_CREATE_FAIL,
+  LIST_CREATE_SUCCESS,
+  LIST_UPDATE_REQUEST,
+  LIST_UPDATE_FAIL,
+  LIST_UPDATE_SUCCESS,
+  LIST_EDITOR_RESET,
+  LIST_EDITOR_SETUP,
+  LIST_EDITOR_TITLE_CHANGE,
+  LIST_ACCOUNTS_FETCH_REQUEST,
+  LIST_ACCOUNTS_FETCH_SUCCESS,
+  LIST_ACCOUNTS_FETCH_FAIL,
+  LIST_EDITOR_SUGGESTIONS_READY,
+  LIST_EDITOR_SUGGESTIONS_CLEAR,
+  LIST_EDITOR_SUGGESTIONS_CHANGE,
+  LIST_EDITOR_ADD_SUCCESS,
+  LIST_EDITOR_REMOVE_SUCCESS,
+} from '../actions/lists';
+
+const initialState = ImmutableMap({
+  listId: null,
+  isSubmitting: false,
+  title: '',
+
+  accounts: ImmutableMap({
+    items: ImmutableList(),
+    loaded: false,
+    isLoading: false,
+  }),
+
+  suggestions: ImmutableMap({
+    value: '',
+    items: ImmutableList(),
+  }),
+});
+
+export default function listEditorReducer(state = initialState, action) {
+  switch(action.type) {
+  case LIST_EDITOR_RESET:
+    return initialState;
+  case LIST_EDITOR_SETUP:
+    return state.withMutations(map => {
+      map.set('listId', action.list.get('id'));
+      map.set('title', action.list.get('title'));
+      map.set('isSubmitting', false);
+    });
+  case LIST_EDITOR_TITLE_CHANGE:
+    return state.set('title', action.value);
+  case LIST_CREATE_REQUEST:
+  case LIST_UPDATE_REQUEST:
+    return state.set('isSubmitting', true);
+  case LIST_CREATE_FAIL:
+  case LIST_UPDATE_FAIL:
+    return state.set('isSubmitting', false);
+  case LIST_CREATE_SUCCESS:
+  case LIST_UPDATE_SUCCESS:
+    return state.withMutations(map => {
+      map.set('isSubmitting', false);
+      map.set('listId', action.list.id);
+    });
+  case LIST_ACCOUNTS_FETCH_REQUEST:
+    return state.setIn(['accounts', 'isLoading'], true);
+  case LIST_ACCOUNTS_FETCH_FAIL:
+    return state.setIn(['accounts', 'isLoading'], false);
+  case LIST_ACCOUNTS_FETCH_SUCCESS:
+    return state.update('accounts', accounts => accounts.withMutations(map => {
+      map.set('isLoading', false);
+      map.set('loaded', true);
+      map.set('items', ImmutableList(action.accounts.map(item => item.id)));
+    }));
+  case LIST_EDITOR_SUGGESTIONS_CHANGE:
+    return state.setIn(['suggestions', 'value'], action.value);
+  case LIST_EDITOR_SUGGESTIONS_READY:
+    return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
+  case LIST_EDITOR_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', suggestions => suggestions.withMutations(map => {
+      map.set('items', ImmutableList());
+      map.set('value', '');
+    }));
+  case LIST_EDITOR_ADD_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId));
+  case LIST_EDITOR_REMOVE_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/lists.js b/app/javascript/flavours/glitch/reducers/lists.js
new file mode 100644
index 000000000..f30ffbcbd
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/lists.js
@@ -0,0 +1,37 @@
+import {
+  LIST_FETCH_SUCCESS,
+  LIST_FETCH_FAIL,
+  LISTS_FETCH_SUCCESS,
+  LIST_CREATE_SUCCESS,
+  LIST_UPDATE_SUCCESS,
+  LIST_DELETE_SUCCESS,
+} from '../actions/lists';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const normalizeList = (state, list) => state.set(list.id, fromJS(list));
+
+const normalizeLists = (state, lists) => {
+  lists.forEach(list => {
+    state = normalizeList(state, list);
+  });
+
+  return state;
+};
+
+export default function lists(state = initialState, action) {
+  switch(action.type) {
+  case LIST_FETCH_SUCCESS:
+  case LIST_CREATE_SUCCESS:
+  case LIST_UPDATE_SUCCESS:
+    return normalizeList(state, action.list);
+  case LISTS_FETCH_SUCCESS:
+    return normalizeLists(state, action.lists);
+  case LIST_DELETE_SUCCESS:
+  case LIST_FETCH_FAIL:
+    return state.set(action.id, false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
new file mode 100644
index 000000000..69d98741b
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -0,0 +1,45 @@
+//  Package imports.
+import { Map as ImmutableMap } from 'immutable';
+
+//  Our imports.
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { LOCAL_SETTING_CHANGE } from 'flavours/glitch/actions/local_settings';
+
+const initialState = ImmutableMap({
+  layout    : 'auto',
+  stretch   : true,
+  navbar_under : false,
+  side_arm  : 'none',
+  collapsed : ImmutableMap({
+    enabled     : true,
+    auto        : ImmutableMap({
+      all              : false,
+      notifications    : true,
+      lengthy          : true,
+      reblogs          : false,
+      replies          : false,
+      media            : false,
+    }),
+    backgrounds : ImmutableMap({
+      user_backgrounds : false,
+      preview_images   : false,
+    }),
+  }),
+  media     : ImmutableMap({
+    letterbox   : true,
+    fullwidth   : true,
+  }),
+});
+
+const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
+
+export default function localSettings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return hydrate(state, action.state.get('local_settings'));
+  case LOCAL_SETTING_CHANGE:
+    return state.setIn(action.key, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/media_attachments.js b/app/javascript/flavours/glitch/reducers/media_attachments.js
new file mode 100644
index 000000000..6e6058576
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/media_attachments.js
@@ -0,0 +1,15 @@
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+  accept_content_types: [],
+});
+
+export default function meta(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('media_attachments'));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/meta.js b/app/javascript/flavours/glitch/reducers/meta.js
new file mode 100644
index 000000000..a98dc436a
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/meta.js
@@ -0,0 +1,16 @@
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+  streaming_api_base_url: null,
+  access_token: null,
+});
+
+export default function meta(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('meta'));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js
new file mode 100644
index 000000000..80bc11dda
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/modal.js
@@ -0,0 +1,17 @@
+import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
+
+const initialState = {
+  modalType: null,
+  modalProps: {},
+};
+
+export default function modal(state = initialState, action) {
+  switch(action.type) {
+  case MODAL_OPEN:
+    return { modalType: action.modalType, modalProps: action.modalProps };
+  case MODAL_CLOSE:
+    return initialState;
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js
new file mode 100644
index 000000000..8f52a7704
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -0,0 +1,29 @@
+import Immutable from 'immutable';
+
+import {
+  MUTES_INIT_MODAL,
+  MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+} from 'flavours/glitch/actions/mutes';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    isSubmitting: false,
+    account: null,
+    notifications: true,
+  }),
+});
+
+export default function mutes(state = initialState, action) {
+  switch (action.type) {
+  case MUTES_INIT_MODAL:
+    return state.withMutations((state) => {
+      state.setIn(['new', 'isSubmitting'], false);
+      state.setIn(['new', 'account'], action.account);
+      state.setIn(['new', 'notifications'], true);
+    });
+  case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
+    return state.updateIn(['new', 'notifications'], (old) => !old);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
new file mode 100644
index 000000000..fb2b3f549
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -0,0 +1,191 @@
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+  NOTIFICATIONS_REFRESH_REQUEST,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_REFRESH_FAIL,
+  NOTIFICATIONS_EXPAND_FAIL,
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP,
+  NOTIFICATIONS_DELETE_MARKED_REQUEST,
+  NOTIFICATIONS_DELETE_MARKED_SUCCESS,
+  NOTIFICATION_MARK_FOR_DELETE,
+  NOTIFICATIONS_DELETE_MARKED_FAIL,
+  NOTIFICATIONS_ENTER_CLEARING_MODE,
+  NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+} from 'flavours/glitch/actions/notifications';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  next: null,
+  top: true,
+  unread: 0,
+  loaded: false,
+  isLoading: true,
+  cleaningMode: false,
+  // notification removal mark of new notifs loaded whilst cleaningMode is true.
+  markNewForDelete: false,
+});
+
+const notificationToMap = (state, notification) => ImmutableMap({
+  id: notification.id,
+  type: notification.type,
+  account: notification.account.id,
+  markedForDelete: state.get('markNewForDelete'),
+  status: notification.status ? notification.status.id : null,
+});
+
+const normalizeNotification = (state, notification) => {
+  const top = state.get('top');
+
+  if (!top) {
+    state = state.update('unread', unread => unread + 1);
+  }
+
+  return state.update('items', list => {
+    if (top && list.size > 40) {
+      list = list.take(20);
+    }
+
+    return list.unshift(notificationToMap(state, notification));
+  });
+};
+
+const normalizeNotifications = (state, notifications, next) => {
+  let items    = ImmutableList();
+  const loaded = state.get('loaded');
+
+  notifications.forEach((n, i) => {
+    items = items.set(i, notificationToMap(state, n));
+  });
+
+  if (state.get('next') === null) {
+    state = state.set('next', next);
+  }
+
+  return state
+    .update('items', list => loaded ? items.concat(list) : list.concat(items))
+    .set('loaded', true)
+    .set('isLoading', false);
+};
+
+const appendNormalizedNotifications = (state, notifications, next) => {
+  let items = ImmutableList();
+
+  notifications.forEach((n, i) => {
+    items = items.set(i, notificationToMap(state, n));
+  });
+
+  return state
+    .update('items', list => list.concat(items))
+    .set('next', next)
+    .set('isLoading', false);
+};
+
+const filterNotifications = (state, relationship) => {
+  return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+};
+
+const updateTop = (state, top) => {
+  if (top) {
+    state = state.set('unread', 0);
+  }
+
+  return state.set('top', top);
+};
+
+const deleteByStatus = (state, statusId) => {
+  return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
+};
+
+const markForDelete = (state, notificationId, yes) => {
+  return state.update('items', list => list.map(item => {
+    if(item.get('id') === notificationId) {
+      return item.set('markedForDelete', yes);
+    } else {
+      return item;
+    }
+  }));
+};
+
+const markAllForDelete = (state, yes) => {
+  return state.update('items', list => list.map(item => {
+    if(yes !== null) {
+      return item.set('markedForDelete', yes);
+    } else {
+      return item.set('markedForDelete', !item.get('markedForDelete'));
+    }
+  }));
+};
+
+const unmarkAllForDelete = (state) => {
+  return state.update('items', list => list.map(item => item.set('markedForDelete', false)));
+};
+
+const deleteMarkedNotifs = (state) => {
+  return state.update('items', list => list.filterNot(item => item.get('markedForDelete')));
+};
+
+export default function notifications(state = initialState, action) {
+  let st;
+
+  switch(action.type) {
+  case NOTIFICATIONS_REFRESH_REQUEST:
+  case NOTIFICATIONS_EXPAND_REQUEST:
+  case NOTIFICATIONS_DELETE_MARKED_REQUEST:
+    return state.set('isLoading', true);
+  case NOTIFICATIONS_DELETE_MARKED_FAIL:
+  case NOTIFICATIONS_REFRESH_FAIL:
+  case NOTIFICATIONS_EXPAND_FAIL:
+    return state.set('isLoading', false);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+    return normalizeNotifications(state, action.notifications, action.next);
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return appendNormalizedNotifications(state, action.notifications, action.next);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterNotifications(state, action.relationship);
+  case NOTIFICATIONS_CLEAR:
+    return state.set('items', ImmutableList()).set('next', null);
+  case TIMELINE_DELETE:
+    return deleteByStatus(state, action.id);
+
+  case NOTIFICATION_MARK_FOR_DELETE:
+    return markForDelete(state, action.id, action.yes);
+
+  case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
+    return deleteMarkedNotifs(state).set('isLoading', false);
+
+  case NOTIFICATIONS_ENTER_CLEARING_MODE:
+    st = state.set('cleaningMode', action.yes);
+    if (!action.yes) {
+      return unmarkAllForDelete(st).set('markNewForDelete', false);
+    } else {
+      return st;
+    }
+
+  case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
+    st = state;
+    if (action.yes === null) {
+      // Toggle - this is a bit confusing, as it toggles the all-none mode
+      //st = st.set('markNewForDelete', !st.get('markNewForDelete'));
+    } else {
+      st = st.set('markNewForDelete', action.yes);
+    }
+    return markAllForDelete(st, action.yes);
+
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js
new file mode 100644
index 000000000..1b47ca962
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/push_notifications.js
@@ -0,0 +1,51 @@
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from 'flavours/glitch/actions/push_notifications';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  subscription: null,
+  alerts: new Immutable.Map({
+    follow: false,
+    favourite: false,
+    reblog: false,
+    mention: false,
+  }),
+  isSubscribed: false,
+  browserSupport: false,
+});
+
+export default function push_subscriptions(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE: {
+    const push_subscription = action.state.get('push_subscription');
+
+    if (push_subscription) {
+      return state
+        .set('subscription', new Immutable.Map({
+          id: push_subscription.get('id'),
+          endpoint: push_subscription.get('endpoint'),
+        }))
+        .set('alerts', push_subscription.get('alerts') || initialState.get('alerts'))
+        .set('isSubscribed', true);
+    }
+
+    return state;
+  }
+  case SET_SUBSCRIPTION:
+    return state
+      .set('subscription', new Immutable.Map({
+        id: action.subscription.id,
+        endpoint: action.subscription.endpoint,
+      }))
+      .set('alerts', new Immutable.Map(action.subscription.alerts))
+      .set('isSubscribed', true);
+  case SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case CLEAR_SUBSCRIPTION:
+    return initialState;
+  case SET_ALERTS:
+    return state.setIn(action.path, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
new file mode 100644
index 000000000..6303089ac
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -0,0 +1,46 @@
+import {
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
+  RELATIONSHIPS_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  DOMAIN_BLOCK_SUCCESS,
+  DOMAIN_UNBLOCK_SUCCESS,
+} from 'flavours/glitch/actions/domain_blocks';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
+
+const normalizeRelationships = (state, relationships) => {
+  relationships.forEach(relationship => {
+    state = normalizeRelationship(state, relationship);
+  });
+
+  return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function relationships(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_FOLLOW_SUCCESS:
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_UNBLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+  case ACCOUNT_UNMUTE_SUCCESS:
+    return normalizeRelationship(state, action.relationship);
+  case RELATIONSHIPS_FETCH_SUCCESS:
+    return normalizeRelationships(state, action.relationships);
+  case DOMAIN_BLOCK_SUCCESS:
+    return state.setIn([action.accountId, 'domain_blocking'], true);
+  case DOMAIN_UNBLOCK_SUCCESS:
+    return state.setIn([action.accountId, 'domain_blocking'], false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/reports.js b/app/javascript/flavours/glitch/reducers/reports.js
new file mode 100644
index 000000000..c18fbcdc6
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/reports.js
@@ -0,0 +1,60 @@
+import {
+  REPORT_INIT,
+  REPORT_SUBMIT_REQUEST,
+  REPORT_SUBMIT_SUCCESS,
+  REPORT_SUBMIT_FAIL,
+  REPORT_CANCEL,
+  REPORT_STATUS_TOGGLE,
+  REPORT_COMMENT_CHANGE,
+} from 'flavours/glitch/actions/reports';
+import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
+
+const initialState = ImmutableMap({
+  new: ImmutableMap({
+    isSubmitting: false,
+    account_id: null,
+    status_ids: ImmutableSet(),
+    comment: '',
+  }),
+});
+
+export default function reports(state = initialState, action) {
+  switch(action.type) {
+  case REPORT_INIT:
+    return state.withMutations(map => {
+      map.setIn(['new', 'isSubmitting'], false);
+      map.setIn(['new', 'account_id'], action.account.get('id'));
+
+      if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
+        map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
+        map.setIn(['new', 'comment'], '');
+      } else if (action.status) {
+        map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
+      }
+    });
+  case REPORT_STATUS_TOGGLE:
+    return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => {
+      if (action.checked) {
+        return set.add(action.statusId);
+      }
+
+      return set.remove(action.statusId);
+    });
+  case REPORT_COMMENT_CHANGE:
+    return state.setIn(['new', 'comment'], action.comment);
+  case REPORT_SUBMIT_REQUEST:
+    return state.setIn(['new', 'isSubmitting'], true);
+  case REPORT_SUBMIT_FAIL:
+    return state.setIn(['new', 'isSubmitting'], false);
+  case REPORT_CANCEL:
+  case REPORT_SUBMIT_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['new', 'account_id'], null);
+      map.setIn(['new', 'status_ids'], ImmutableSet());
+      map.setIn(['new', 'comment'], '');
+      map.setIn(['new', 'isSubmitting'], false);
+    });
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js
new file mode 100644
index 000000000..f9bf92098
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/search.js
@@ -0,0 +1,42 @@
+import {
+  SEARCH_CHANGE,
+  SEARCH_CLEAR,
+  SEARCH_FETCH_SUCCESS,
+  SEARCH_SHOW,
+} from 'flavours/glitch/actions/search';
+import { COMPOSE_MENTION, COMPOSE_REPLY } from 'flavours/glitch/actions/compose';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+  value: '',
+  submitted: false,
+  hidden: false,
+  results: ImmutableMap(),
+});
+
+export default function search(state = initialState, action) {
+  switch(action.type) {
+  case SEARCH_CHANGE:
+    return state.set('value', action.value);
+  case SEARCH_CLEAR:
+    return state.withMutations(map => {
+      map.set('value', '');
+      map.set('results', ImmutableMap());
+      map.set('submitted', false);
+      map.set('hidden', false);
+    });
+  case SEARCH_SHOW:
+    return state.set('hidden', false);
+  case COMPOSE_REPLY:
+  case COMPOSE_MENTION:
+    return state.set('hidden', true);
+  case SEARCH_FETCH_SUCCESS:
+    return state.set('results', ImmutableMap({
+      accounts: ImmutableList(action.results.accounts.map(item => item.id)),
+      statuses: ImmutableList(action.results.statuses.map(item => item.id)),
+      hashtags: ImmutableList(action.results.hashtags),
+    })).set('submitted', true);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
new file mode 100644
index 000000000..c04f262da
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -0,0 +1,127 @@
+import { SETTING_CHANGE, SETTING_SAVE } from 'flavours/glitch/actions/settings';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from 'flavours/glitch/actions/columns';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { EMOJI_USE } from 'flavours/glitch/actions/emojis';
+import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import uuid from 'flavours/glitch/util/uuid';
+
+const initialState = ImmutableMap({
+  saved: true,
+
+  onboarded: false,
+  layout: 'auto',
+
+  skinTone: 1,
+
+  home: ImmutableMap({
+    shows: ImmutableMap({
+      reblog: true,
+      reply: true,
+      direct: true,
+    }),
+
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  notifications: ImmutableMap({
+    alerts: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+    }),
+
+    shows: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+    }),
+
+    sounds: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+    }),
+  }),
+
+  community: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  public: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  direct: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+});
+
+const defaultColumns = fromJS([
+  { id: 'COMPOSE', uuid: uuid(), params: {} },
+  { id: 'HOME', uuid: uuid(), params: {} },
+  { id: 'NOTIFICATIONS', uuid: uuid(), params: {} },
+]);
+
+const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val);
+
+const moveColumn = (state, uuid, direction) => {
+  const columns  = state.get('columns');
+  const index    = columns.findIndex(item => item.get('uuid') === uuid);
+  const newIndex = index + direction;
+
+  let newColumns;
+
+  newColumns = columns.splice(index, 1);
+  newColumns = newColumns.splice(newIndex, 0, columns.get(index));
+
+  return state
+    .set('columns', newColumns)
+    .set('saved', false);
+};
+
+const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
+
+const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId));
+
+export default function settings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return hydrate(state, action.state.get('settings'));
+  case SETTING_CHANGE:
+    return state
+      .setIn(action.path, action.value)
+      .set('saved', false);
+  case COLUMN_ADD:
+    return state
+      .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
+      .set('saved', false);
+  case COLUMN_REMOVE:
+    return state
+      .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
+      .set('saved', false);
+  case COLUMN_MOVE:
+    return moveColumn(state, action.uuid, action.direction);
+  case EMOJI_USE:
+    return updateFrequentEmojis(state, action.emoji);
+  case SETTING_SAVE:
+    return state.set('saved', true);
+  case LIST_FETCH_FAIL:
+    return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state;
+  case LIST_DELETE_SUCCESS:
+    return filterDeadListColumns(state, action.id);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/status_lists.js b/app/javascript/flavours/glitch/reducers/status_lists.js
new file mode 100644
index 000000000..6de81c6b1
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/status_lists.js
@@ -0,0 +1,87 @@
+import {
+  FAVOURITED_STATUSES_FETCH_REQUEST,
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_FETCH_FAIL,
+  FAVOURITED_STATUSES_EXPAND_REQUEST,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_FAIL,
+} from 'flavours/glitch/actions/favourites';
+import {
+  PINNED_STATUSES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/pin_statuses';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+
+const initialState = ImmutableMap({
+  favourites: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
+  pins: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('loaded', true);
+    map.set('isLoading', false);
+    map.set('items', ImmutableList(statuses.map(item => item.id)));
+  }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('isLoading', false);
+    map.set('items', map.get('items').concat(statuses.map(item => item.id)));
+  }));
+};
+
+const prependOneToList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').unshift(status.get('id')));
+  }));
+};
+
+const removeOneFromList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').filter(item => item !== status.get('id')));
+  }));
+};
+
+export default function statusLists(state = initialState, action) {
+  switch(action.type) {
+  case FAVOURITED_STATUSES_FETCH_REQUEST:
+  case FAVOURITED_STATUSES_EXPAND_REQUEST:
+    return state.setIn(['favourites', 'isLoading'], true);
+  case FAVOURITED_STATUSES_FETCH_FAIL:
+  case FAVOURITED_STATUSES_EXPAND_FAIL:
+    return state.setIn(['favourites', 'isLoading'], false);
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITE_SUCCESS:
+    return prependOneToList(state, 'favourites', action.status);
+  case UNFAVOURITE_SUCCESS:
+    return removeOneFromList(state, 'favourites', action.status);
+  case PINNED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'pins', action.statuses, action.next);
+  case PIN_SUCCESS:
+    return prependOneToList(state, 'pins', action.status);
+  case UNPIN_SUCCESS:
+    return removeOneFromList(state, 'pins', action.status);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js
new file mode 100644
index 000000000..410bc013b
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -0,0 +1,148 @@
+import {
+  REBLOG_REQUEST,
+  REBLOG_SUCCESS,
+  REBLOG_FAIL,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_REQUEST,
+  FAVOURITE_SUCCESS,
+  FAVOURITE_FAIL,
+  UNFAVOURITE_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS,
+  STATUS_MUTE_SUCCESS,
+  STATUS_UNMUTE_SUCCESS,
+} from 'flavours/glitch/actions/statuses';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/timelines';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/favourites';
+import {
+  PINNED_STATUSES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/pin_statuses';
+import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search';
+import emojify from 'flavours/glitch/util/emoji';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const domParser = new DOMParser();
+
+const normalizeStatus = (state, status) => {
+  if (!status) {
+    return state;
+  }
+
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
+
+  if (status.reblog && status.reblog.id) {
+    state               = normalizeStatus(state, status.reblog);
+    normalStatus.reblog = status.reblog.id;
+  }
+
+  const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+
+  const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
+    obj[`:${emoji.shortcode}:`] = emoji;
+    return obj;
+  }, {});
+
+  normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+  normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
+  normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
+
+  return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
+};
+
+const normalizeStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeStatus(state, status);
+  });
+
+  return state;
+};
+
+const deleteStatus = (state, id, references) => {
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], []);
+  });
+
+  return state.delete(id);
+};
+
+const filterStatuses = (state, relationship) => {
+  state.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
+  });
+
+  return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeStatus(state, action.status);
+  case REBLOG_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+  case PIN_SUCCESS:
+  case UNPIN_SUCCESS:
+    return normalizeStatus(state, action.response);
+  case FAVOURITE_REQUEST:
+    return state.setIn([action.status.get('id'), 'favourited'], true);
+  case FAVOURITE_FAIL:
+    return state.setIn([action.status.get('id'), 'favourited'], false);
+  case REBLOG_REQUEST:
+    return state.setIn([action.status.get('id'), 'reblogged'], true);
+  case REBLOG_FAIL:
+    return state.setIn([action.status.get('id'), 'reblogged'], false);
+  case STATUS_MUTE_SUCCESS:
+    return state.setIn([action.id, 'muted'], true);
+  case STATUS_UNMUTE_SUCCESS:
+    return state.setIn([action.id, 'muted'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case PINNED_STATUSES_FETCH_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeStatuses(state, action.statuses);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterStatuses(state, action.relationship);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js
new file mode 100644
index 000000000..679e1601e
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -0,0 +1,149 @@
+import {
+  TIMELINE_REFRESH_REQUEST,
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_REFRESH_FAIL,
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT,
+} from 'flavours/glitch/actions/timelines';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const initialTimeline = ImmutableMap({
+  unread: 0,
+  online: false,
+  top: true,
+  loaded: false,
+  isLoading: false,
+  next: false,
+  items: ImmutableList(),
+});
+
+const normalizeTimeline = (state, timeline, statuses, next) => {
+  const oldIds    = state.getIn([timeline, 'items'], ImmutableList());
+  const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
+  const wasLoaded = state.getIn([timeline, 'loaded']);
+  const hadNext   = state.getIn([timeline, 'next']);
+
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    mMap.set('loaded', true);
+    mMap.set('isLoading', false);
+    if (!hadNext) mMap.set('next', next);
+    mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids);
+  }));
+};
+
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
+  const oldIds = state.getIn([timeline, 'items'], ImmutableList());
+  const ids    = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
+
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    mMap.set('isLoading', false);
+    mMap.set('next', next);
+    mMap.set('items', oldIds.concat(ids));
+  }));
+};
+
+const updateTimeline = (state, timeline, status, references) => {
+  const top        = state.getIn([timeline, 'top']);
+  const ids        = state.getIn([timeline, 'items'], ImmutableList());
+  const includesId = ids.includes(status.get('id'));
+  const unread     = state.getIn([timeline, 'unread'], 0);
+
+  if (includesId) {
+    return state;
+  }
+
+  let newIds = ids;
+
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    if (!top) mMap.set('unread', unread + 1);
+    if (top && ids.size > 40) newIds = newIds.take(20);
+    if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item));
+    mMap.set('items', newIds.unshift(status.get('id')));
+  }));
+};
+
+const deleteStatus = (state, id, accountId, references) => {
+  state.keySeq().forEach(timeline => {
+    state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
+  });
+
+  // Remove reblogs of deleted status
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], ref[1], []);
+  });
+
+  return state;
+};
+
+const filterTimelines = (state, relationship, statuses) => {
+  let references;
+
+  statuses.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
+    state      = deleteStatus(state, status.get('id'), status.get('account'), references);
+  });
+
+  return state;
+};
+
+const filterTimeline = (timeline, state, relationship, statuses) =>
+  state.updateIn([timeline, 'items'], ImmutableList(), list =>
+    list.filterNot(statusId =>
+      statuses.getIn([statusId, 'account']) === relationship.id
+    ));
+
+const updateTop = (state, timeline, top) => {
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    if (top) mMap.set('unread', 0);
+    mMap.set('top', top);
+  }));
+};
+
+export default function timelines(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_REFRESH_REQUEST:
+  case TIMELINE_EXPAND_REQUEST:
+    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
+  case TIMELINE_REFRESH_FAIL:
+  case TIMELINE_EXPAND_FAIL:
+    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
+  case TIMELINE_REFRESH_SUCCESS:
+    return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+  case TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterTimelines(state, action.relationship, action.statuses);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return filterTimeline('home', state, action.relationship, action.statuses);
+  case TIMELINE_SCROLL_TOP:
+    return updateTop(state, action.timeline, action.top);
+  case TIMELINE_CONNECT:
+    return state.update(action.timeline, initialTimeline, map => map.set('online', true));
+  case TIMELINE_DISCONNECT:
+    return state.update(action.timeline, initialTimeline, map => map.set('online', false));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js
new file mode 100644
index 000000000..a4df9ec8d
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/user_lists.js
@@ -0,0 +1,80 @@
+import {
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+  FOLLOW_REQUEST_REJECT_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/mutes';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+  followers: ImmutableMap(),
+  following: ImmutableMap(),
+  reblogged_by: ImmutableMap(),
+  favourited_by: ImmutableMap(),
+  follow_requests: ImmutableMap(),
+  blocks: ImmutableMap(),
+  mutes: ImmutableMap(),
+});
+
+const normalizeList = (state, type, id, accounts, next) => {
+  return state.setIn([type, id], ImmutableMap({
+    next,
+    items: ImmutableList(accounts.map(item => item.id)),
+  }));
+};
+
+const appendToList = (state, type, id, accounts, next) => {
+  return state.updateIn([type, id], map => {
+    return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id)));
+  });
+};
+
+export default function userLists(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWERS_FETCH_SUCCESS:
+    return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWERS_EXPAND_SUCCESS:
+    return appendToList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWING_FETCH_SUCCESS:
+    return normalizeList(state, 'following', action.id, action.accounts, action.next);
+  case FOLLOWING_EXPAND_SUCCESS:
+    return appendToList(state, 'following', action.id, action.accounts, action.next);
+  case REBLOGS_FETCH_SUCCESS:
+    return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
+  case FAVOURITES_FETCH_SUCCESS:
+    return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+  case BLOCKS_FETCH_SUCCESS:
+    return state.setIn(['blocks', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+  case BLOCKS_EXPAND_SUCCESS:
+    return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+  case MUTES_FETCH_SUCCESS:
+    return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  case MUTES_EXPAND_SUCCESS:
+    return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
new file mode 100644
index 000000000..d26d1b727
--- /dev/null
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -0,0 +1,87 @@
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+
+const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
+const getAccountCounters     = (state, id) => state.getIn(['accounts_counters', id], null);
+const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
+
+export const makeGetAccount = () => {
+  return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => {
+    if (base === null) {
+      return null;
+    }
+
+    return base.merge(counters).set('relationship', relationship);
+  });
+};
+
+export const makeGetStatus = () => {
+  return createSelector(
+    [
+      (state, id) => state.getIn(['statuses', id]),
+      (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+    ],
+
+    (statusBase, statusReblog, accountBase, accountReblog) => {
+      if (!statusBase) {
+        return null;
+      }
+
+      if (statusReblog) {
+        statusReblog = statusReblog.set('account', accountReblog);
+      } else {
+        statusReblog = null;
+      }
+
+      return statusBase.withMutations(map => {
+        map.set('reblog', statusReblog);
+        map.set('account', accountBase);
+      });
+    }
+  );
+};
+
+const getAlertsBase = state => state.get('alerts');
+
+export const getAlerts = createSelector([getAlertsBase], (base) => {
+  let arr = [];
+
+  base.forEach(item => {
+    arr.push({
+      message: item.get('message'),
+      title: item.get('title'),
+      key: item.get('key'),
+      dismissAfter: 5000,
+      barStyle: {
+        zIndex: 200,
+      },
+    });
+  });
+
+  return arr;
+});
+
+export const makeGetNotification = () => {
+  return createSelector([
+    (_, base)             => base,
+    (state, _, accountId) => state.getIn(['accounts', accountId]),
+  ], (base, account) => {
+    return base.set('account', account);
+  });
+};
+
+export const getAccountGallery = createSelector([
+  (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
+  state       => state.get('statuses'),
+], (statusIds, statuses) => {
+  let medias = ImmutableList();
+
+  statusIds.forEach(statusId => {
+    const status = statuses.get(statusId);
+    medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
+  });
+
+  return medias;
+});
diff --git a/app/javascript/flavours/glitch/service_worker/entry.js b/app/javascript/flavours/glitch/service_worker/entry.js
new file mode 100644
index 000000000..eea4cfc3c
--- /dev/null
+++ b/app/javascript/flavours/glitch/service_worker/entry.js
@@ -0,0 +1,10 @@
+import './web_push_notifications';
+
+// Cause a new version of a registered Service Worker to replace an existing one
+// that is already installed, and replace the currently active worker on open pages.
+self.addEventListener('install', function(event) {
+  event.waitUntil(self.skipWaiting());
+});
+self.addEventListener('activate', function(event) {
+  event.waitUntil(self.clients.claim());
+});
diff --git a/app/javascript/flavours/glitch/service_worker/web_push_notifications.js b/app/javascript/flavours/glitch/service_worker/web_push_notifications.js
new file mode 100644
index 000000000..f63cff335
--- /dev/null
+++ b/app/javascript/flavours/glitch/service_worker/web_push_notifications.js
@@ -0,0 +1,159 @@
+const MAX_NOTIFICATIONS = 5;
+const GROUP_TAG = 'tag';
+
+// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
+const formatGroupTitle = (message, count) => message.replace('%{count}', count);
+
+const notify = options =>
+  self.registration.getNotifications().then(notifications => {
+    if (notifications.length === MAX_NOTIFICATIONS) {
+      // Reached the maximum number of notifications, proceed with grouping
+      const group = {
+        title: formatGroupTitle(options.data.message, notifications.length + 1),
+        body: notifications
+          .sort((n1, n2) => n1.timestamp < n2.timestamp)
+          .map(notification => notification.title).join('\n'),
+        badge: '/badge.png',
+        icon: '/android-chrome-192x192.png',
+        tag: GROUP_TAG,
+        data: {
+          url: (new URL('/web/notifications', self.location)).href,
+          count: notifications.length + 1,
+          message: options.data.message,
+        },
+      };
+
+      notifications.forEach(notification => notification.close());
+
+      return self.registration.showNotification(group.title, group);
+    } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
+      // Already grouped, proceed with appending the notification to the group
+      const group = cloneNotification(notifications[0]);
+
+      group.title = formatGroupTitle(group.data.message, group.data.count + 1);
+      group.body  = `${options.title}\n${group.body}`;
+      group.data  = { ...group.data, count: group.data.count + 1 };
+
+      return self.registration.showNotification(group.title, group);
+    }
+
+    return self.registration.showNotification(options.title, options);
+  });
+
+const handlePush = (event) => {
+  const options = event.data.json();
+
+  options.body      = options.data.nsfw || options.data.content;
+  options.dir       = options.data.dir;
+  options.image     = options.image || undefined; // Null results in a network request (404)
+  options.timestamp = options.timestamp && new Date(options.timestamp);
+
+  const expandAction = options.data.actions.find(action => action.todo === 'expand');
+
+  if (expandAction) {
+    options.actions          = [expandAction];
+    options.hiddenActions    = options.data.actions.filter(action => action !== expandAction);
+    options.data.hiddenImage = options.image;
+    options.image            = undefined;
+  } else {
+    options.actions = options.data.actions;
+  }
+
+  event.waitUntil(notify(options));
+};
+
+const cloneNotification = (notification) => {
+  const clone = {  };
+
+  for(var k in notification) {
+    clone[k] = notification[k];
+  }
+
+  return clone;
+};
+
+const expandNotification = (notification) => {
+  const nextNotification = cloneNotification(notification);
+
+  nextNotification.body    = notification.data.content;
+  nextNotification.image   = notification.data.hiddenImage;
+  nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
+
+  return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const makeRequest = (notification, action) =>
+  fetch(action.action, {
+    headers: {
+      'Authorization': `Bearer ${notification.data.access_token}`,
+      'Content-Type': 'application/json',
+    },
+    method: action.method,
+    credentials: 'include',
+  });
+
+const findBestClient = clients => {
+  const focusedClient = clients.find(client => client.focused);
+  const visibleClient = clients.find(client => client.visibilityState === 'visible');
+
+  return focusedClient || visibleClient || clients[0];
+};
+
+const openUrl = url =>
+  self.clients.matchAll({ type: 'window' }).then(clientList => {
+    if (clientList.length !== 0) {
+      const webClients = clientList.filter(client => /\/web\//.test(client.url));
+
+      if (webClients.length !== 0) {
+        const client       = findBestClient(webClients);
+        const { pathname } = new URL(url);
+
+        if (pathname.startsWith('/web/')) {
+          return client.focus().then(client => client.postMessage({
+            type: 'navigate',
+            path: pathname.slice('/web/'.length - 1),
+          }));
+        }
+      } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
+        const client = findBestClient(clientList);
+
+        return client.navigate(url).then(client => client.focus());
+      }
+    }
+
+    return self.clients.openWindow(url);
+  });
+
+const removeActionFromNotification = (notification, action) => {
+  const actions          = notification.actions.filter(act => act.action !== action.action);
+  const nextNotification = cloneNotification(notification);
+
+  nextNotification.actions = actions;
+
+  return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const handleNotificationClick = (event) => {
+  const reactToNotificationClick = new Promise((resolve, reject) => {
+    if (event.action) {
+      const action = event.notification.data.actions.find(({ action }) => action === event.action);
+
+      if (action.todo === 'expand') {
+        resolve(expandNotification(event.notification));
+      } else if (action.todo === 'request') {
+        resolve(makeRequest(event.notification, action)
+          .then(() => removeActionFromNotification(event.notification, action)));
+      } else {
+        reject(`Unknown action: ${action.todo}`);
+      }
+    } else {
+      event.notification.close();
+      resolve(openUrl(event.notification.data.url));
+    }
+  });
+
+  event.waitUntil(reactToNotificationClick);
+};
+
+self.addEventListener('push', handlePush);
+self.addEventListener('notificationclick', handleNotificationClick);
diff --git a/app/javascript/flavours/glitch/store/configureStore.js b/app/javascript/flavours/glitch/store/configureStore.js
new file mode 100644
index 000000000..1376d4cba
--- /dev/null
+++ b/app/javascript/flavours/glitch/store/configureStore.js
@@ -0,0 +1,15 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import appReducer from '../reducers';
+import loadingBarMiddleware from '../middleware/loading_bar';
+import errorsMiddleware from '../middleware/errors';
+import soundsMiddleware from '../middleware/sounds';
+
+export default function configureStore() {
+  return createStore(appReducer, compose(applyMiddleware(
+    thunk,
+    loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
+    errorsMiddleware(),
+    soundsMiddleware()
+  ), window.devToolsExtension ? window.devToolsExtension() : f => f));
+};
diff --git a/app/javascript/flavours/glitch/styles/_mixins.scss b/app/javascript/flavours/glitch/styles/_mixins.scss
new file mode 100644
index 000000000..102723e39
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/_mixins.scss
@@ -0,0 +1,52 @@
+@mixin avatar-radius() {
+  border-radius: $ui-avatar-border-size;
+  background: transparent no-repeat;
+  background-position: 50%;
+  background-clip: padding-box;
+}
+
+@mixin avatar-size($size:48px) {
+  width: $size;
+  height: $size;
+  background-size: $size $size;
+}
+
+@mixin single-column($media, $parent: '&') {
+  .auto-columns #{$parent} {
+    @media #{$media} {
+      @content;
+    }
+  }
+  .single-column #{$parent} {
+    @content;
+  }
+}
+
+@mixin limited-single-column($media, $parent: '&') {
+  .auto-columns #{$parent}, .single-column #{$parent} {
+    @media #{$media} {
+      @content;
+    }
+  }
+}
+
+@mixin multi-columns($media, $parent: '&') {
+  .auto-columns #{$parent} {
+    @media #{$media} {
+      @content;
+    }
+  }
+  .multi-columns #{$parent} {
+    @content;
+  }
+}
+
+@mixin fullwidth-gallery {
+  &.full-width {
+    margin-left: -22px;
+    margin-right: -22px;
+    width: inherit;
+    max-width: none;
+    height: 250px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss
new file mode 100644
index 000000000..31c079cc5
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/about.scss
@@ -0,0 +1,827 @@
+.landing-page {
+  p,
+  li {
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    margin-bottom: 12px;
+    color: $ui-primary-color;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+    }
+  }
+
+  em {
+    display: inline;
+    margin: 0;
+    padding: 0;
+    font-weight: 700;
+    background: transparent;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+    color: lighten($ui-primary-color, 10%);
+  }
+
+  h1 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 26px;
+    line-height: 30px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+
+    small {
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      display: block;
+      font-size: 18px;
+      font-weight: 400;
+      color: $ui-base-lighter-color;
+    }
+  }
+
+  h2 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 22px;
+    line-height: 26px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h3 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 18px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h4 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 16px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h5 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 14px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h6 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 12px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  ul,
+  ol {
+    margin-left: 20px;
+
+    &[type='a'] {
+      list-style-type: lower-alpha;
+    }
+
+    &[type='i'] {
+      list-style-type: lower-roman;
+    }
+  }
+
+  ul {
+    list-style: disc;
+  }
+
+  ol {
+    list-style: decimal;
+  }
+
+  li > ol,
+  li > ul {
+    margin-top: 6px;
+  }
+
+  hr {
+    border-color: rgba($ui-base-lighter-color, .6);
+  }
+
+  .container {
+    width: 100%;
+    box-sizing: border-box;
+    max-width: 800px;
+    margin: 0 auto;
+    word-wrap: break-word;
+  }
+
+  .header-wrapper {
+    padding-top: 15px;
+    background: $ui-base-color;
+    background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color);
+    position: relative;
+
+    &.compact {
+      background: $ui-base-color;
+      padding-bottom: 15px;
+
+      .hero .heading {
+        padding-bottom: 20px;
+        font-family: 'mastodon-font-sans-serif', sans-serif;
+        font-size: 16px;
+        font-weight: 400;
+        font-size: 16px;
+        line-height: 30px;
+        color: $ui-primary-color;
+
+        a {
+          color: $ui-highlight-color;
+          text-decoration: underline;
+        }
+      }
+    }
+
+    .mascot-container {
+      max-width: 800px;
+      margin: 0 auto;
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      height: 100%;
+    }
+
+    .mascot {
+      position: absolute;
+      bottom: -14px;
+      width: auto;
+      height: auto;
+      left: 60px;
+      z-index: 3;
+    }
+  }
+
+  .header {
+    line-height: 30px;
+    overflow: hidden;
+
+    .container {
+      display: flex;
+      justify-content: space-between;
+    }
+
+    .links {
+      position: relative;
+      z-index: 4;
+
+      a {
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        color: $ui-primary-color;
+        text-decoration: none;
+        padding: 12px 16px;
+        line-height: 32px;
+        font-family: 'mastodon-font-display', sans-serif;
+        font-weight: 500;
+        font-size: 14px;
+
+        &:hover {
+          color: $ui-secondary-color;
+        }
+      }
+
+      .brand {
+        a {
+          padding-left: 0;
+          padding-right: 0;
+          color: $white;
+        }
+
+        img {
+          height: 32px;
+          position: relative;
+          top: 4px;
+          left: -10px;
+        }
+      }
+
+      ul {
+        list-style: none;
+        margin: 0;
+
+        li {
+          display: inline-block;
+          vertical-align: bottom;
+          margin: 0;
+
+          &:first-child a {
+            padding-left: 0;
+          }
+
+          &:last-child a {
+            padding-right: 0;
+          }
+        }
+      }
+    }
+
+    .hero {
+      margin-top: 50px;
+      align-items: center;
+      position: relative;
+
+      .floats {
+        position: absolute;
+        width: 100%;
+        height: 100%;
+        top: 0;
+        left: 0;
+
+        div {
+          position: absolute;
+          transition: all 0.1s linear;
+          animation-name: floating;
+          animation-iteration-count: infinite;
+          animation-direction: alternate;
+          animation-timing-function: ease-in-out;
+          z-index: 2;
+        }
+
+        .float-1 {
+          width: 324px;
+          height: 170px;
+          right: -120px;
+          bottom: 0;
+          animation-duration: 3s;
+          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 447.1875 234.375" height="170" width="324"><path fill="#{hex-color($ui-base-lighter-color)}" d="M21.69 233.366c-6.45-1.268-13.347-5.63-16.704-10.564-10.705-15.734-1.513-37.724 18.632-44.57l4.8-1.632.173-17.753c.146-14.77.515-19.063 2.2-25.55 6.736-25.944 24.46-46.032 47.766-54.137 11.913-4.143 19.558-5.366 34.178-5.47l13.828-.096V71.12c0-4.755 2.853-17.457 5.238-23.327 8.588-21.137 26.735-35.957 52.153-42.593 23.248-6.07 50.153-6.415 71.863-.923 11.14 2.82 25.686 9.957 33.857 16.615 19.335 15.756 31.82 41.05 35.183 71.275.59 5.305.672 5.435 3.11 4.926 11.833-2.474 30.4-3.132 40.065-1.42 24.388 4.32 40.568 19.076 47.214 43.058 2.16 7.8 3.953 23.894 3.59 32.237l-.24 5.498 5.156 1.317c6.392 1.633 14.55 7.098 18.003 12.062 1.435 2.062 3.305 6.597 4.156 10.078 1.428 5.84 1.43 6.8.04 12.44-1.807 7.318-5.672 13.252-10.872 16.694-8.508 5.63 3.756 5.33-211.916 5.216-108.56-.056-199.22-.464-201.47-.906z"/></svg>');
+        }
+
+        .float-2 {
+          width: 241px;
+          height: 100px;
+          right: 210px;
+          bottom: 0;
+          animation-duration: 3.5s;
+          animation-delay: 0.2s;
+          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 536.25 222.1875" height="100" width="241"><path fill="#{hex-color($ui-base-lighter-color)}" d="M42.626 221.23c-14.104-1.174-26.442-5.133-32.825-10.534-4.194-3.548-7.684-10.66-8.868-18.075-1.934-12.102.633-22.265 7.528-29.81 7.61-8.328 19.998-12.76 39.855-14.257l8.47-.638-2.08-6.223c-4.826-14.422-6.357-24.813-6.37-43.255-.012-14.923.28-18.513 2.1-25.724 2.283-9.048 8.483-23.034 13.345-30.1 14.76-21.45 43.505-38.425 70.535-41.65 30.628-3.655 64.47 12.073 89.668 41.673l5.955 6.995 2.765-4.174c1.52-2.296 5.74-6.93 9.376-10.295 18.382-17.02 43.436-20.676 73.352-10.705 12.158 4.052 21.315 9.53 29.64 17.733 12.752 12.562 18.16 25.718 18.19 44.26l.02 10.98 2.312-3.01c15.64-20.365 42.29-20.485 62.438-.28 3.644 3.653 7.558 8.593 8.697 10.976 4.895 10.24 5.932 25.688 2.486 37.046-.76 2.507-1.388 4.816-1.393 5.13-.006.316 6.845.87 15.224 1.234 53.06 2.297 76.356 12.98 81.817 37.526 3.554 15.973-3.71 28.604-19.566 34.02-4.554 1.555-17.922 1.655-234.517 1.757-126.327.06-233.497-.21-238.154-.597z"/></svg>');
+        }
+
+        .float-3 {
+          width: 267px;
+          height: 140px;
+          right: 110px;
+          top: -30px;
+          animation-duration: 4s;
+          animation-delay: 0.5s;
+          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388.125 202.5" height="140" width="267"><path fill="#{hex-color($ui-base-lighter-color)}" d="M181.37 201.458c-17.184-1.81-36.762-8.944-49.523-18.05l-5.774-4.12-8.074 2.63c-11.468 3.738-21.382 4.962-35.815 4.422-14.79-.554-24.577-2.845-36.716-8.594-15.483-7.332-28.498-19.98-35.985-34.968C2.44 128.675-.94 108.435.9 91.356c3.362-31.234 18.197-53.698 43.63-66.074 12.803-6.23 22.384-8.55 37.655-9.122 14.433-.54 24.347.684 35.814 4.42l8.073 2.633 5.635-4.01c24.81-17.656 60.007-23.332 92.914-14.985 10.11 2.565 25.498 9.62 33.102 15.178l5.068 3.704 7.632-2.564c10.89-3.66 21.086-4.916 35.516-4.376 45.816 1.716 76.422 30.03 81.285 75.196 1.84 17.08-1.54 37.32-8.585 51.422-7.487 14.99-20.502 27.636-35.984 34.968-12.14 5.75-21.926 8.04-36.716 8.593-14.43.54-24.626-.716-35.516-4.376l-7.632-2.564-5.068 3.704c-12.844 9.387-32.714 16.488-51.545 18.42-10.607 1.09-13.916 1.08-24.81-.066z"/></svg>');
+        }
+      }
+
+      .heading {
+        position: relative;
+        z-index: 4;
+        padding-bottom: 150px;
+      }
+
+      .simple_form,
+      .closed-registrations-message {
+        background: darken($ui-base-color, 4%);
+        width: 280px;
+        padding: 15px 20px;
+        border-radius: 4px 4px 0 0;
+        line-height: initial;
+        position: relative;
+        z-index: 4;
+
+        .actions {
+          margin-bottom: 0;
+
+          button,
+          .button,
+          .block-button {
+            margin-bottom: 0;
+          }
+        }
+      }
+
+      .closed-registrations-message {
+        min-height: 330px;
+        display: flex;
+        flex-direction: column;
+        justify-content: space-between;
+      }
+    }
+  }
+
+  .about-short {
+    background: darken($ui-base-color, 4%);
+    padding: 50px 0 30px;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    color: $ui-primary-color;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+    }
+  }
+
+  .information-board {
+    background: darken($ui-base-color, 4%);
+    padding: 20px 0;
+
+    .container {
+      position: relative;
+      padding-right: 280px + 15px;
+    }
+
+    .information-board-sections {
+      display: flex;
+      justify-content: space-between;
+      flex-wrap: wrap;
+    }
+
+    .section {
+      flex: 1 0 0;
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      font-size: 16px;
+      line-height: 28px;
+      color: $primary-text-color;
+      text-align: right;
+      padding: 10px 15px;
+
+      span,
+      strong {
+        display: block;
+      }
+
+      span {
+        &:last-child {
+          color: $ui-secondary-color;
+        }
+      }
+
+      strong {
+        font-weight: 500;
+        font-size: 32px;
+        line-height: 48px;
+      }
+    }
+
+    .panel {
+      position: absolute;
+      width: 280px;
+      box-sizing: border-box;
+      background: darken($ui-base-color, 8%);
+      padding: 20px;
+      padding-top: 10px;
+      border-radius: 4px 4px 0 0;
+      right: 0;
+      bottom: -40px;
+
+      .panel-header {
+        font-family: 'mastodon-font-display', sans-serif;
+        font-size: 14px;
+        line-height: 24px;
+        font-weight: 500;
+        color: $ui-primary-color;
+        padding-bottom: 5px;
+        margin-bottom: 15px;
+        border-bottom: 1px solid lighten($ui-base-color, 4%);
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        overflow: hidden;
+
+        a,
+        span {
+          font-weight: 400;
+          color: darken($ui-primary-color, 10%);
+        }
+
+        a {
+          text-decoration: none;
+        }
+      }
+    }
+
+    .owner {
+      text-align: center;
+
+      .avatar {
+        width: 80px;
+        height: 80px;
+        margin: 0 auto;
+        margin-bottom: 15px;
+        @include avatar-size(80px);
+
+        img {
+          display: block;
+          width: 80px;
+          height: 80px;
+          border-radius: 48px;
+          @include avatar-radius();
+          @include avatar-size(80px);
+        }
+      }
+
+      .name {
+        font-size: 14px;
+
+        a {
+          display: block;
+          color: $primary-text-color;
+          text-decoration: none;
+
+          &:hover {
+            .display_name {
+              text-decoration: underline;
+            }
+          }
+        }
+
+        .username {
+          display: block;
+          color: $ui-primary-color;
+        }
+      }
+    }
+  }
+
+  .features {
+    padding: 50px 0;
+
+    .container {
+      display: flex;
+    }
+
+    #mastodon-timeline {
+      display: flex;
+      -webkit-overflow-scrolling: touch;
+      -ms-overflow-style: -ms-autohiding-scrollbar;
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      font-size: 13px;
+      line-height: 18px;
+      font-weight: 400;
+      color: $primary-text-color;
+      width: 330px;
+      margin-right: 30px;
+      flex: 0 0 auto;
+      background: $ui-base-color;
+      overflow: hidden;
+      border-radius: 4px;
+      box-shadow: 0 0 6px rgba($black, 0.1);
+
+      .column-header {
+        color: inherit;
+        font-family: inherit;
+        font-size: 16px;
+        line-height: inherit;
+        font-weight: inherit;
+        margin: 0;
+        padding: 15px;
+      }
+
+      .column {
+        padding: 0;
+        border-radius: 4px;
+        overflow: hidden;
+      }
+
+      .scrollable {
+        height: 400px;
+      }
+
+      p {
+        font-size: inherit;
+        line-height: inherit;
+        font-weight: inherit;
+        color: $primary-text-color;
+        margin-bottom: 20px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        a {
+          color: $ui-secondary-color;
+          text-decoration: none;
+        }
+      }
+    }
+
+    .about-mastodon {
+      max-width: 675px;
+
+      p {
+        margin-bottom: 20px;
+      }
+
+      .features-list {
+        margin-top: 20px;
+
+        .features-list__row {
+          display: flex;
+          padding: 10px 0;
+          justify-content: space-between;
+
+          &:first-child {
+            padding-top: 0;
+          }
+
+          .visual {
+            flex: 0 0 auto;
+            display: flex;
+            align-items: center;
+            margin-left: 15px;
+
+            .fa {
+              display: block;
+              color: $ui-primary-color;
+              font-size: 48px;
+            }
+          }
+
+          .text {
+            font-size: 16px;
+            line-height: 30px;
+            color: $ui-primary-color;
+
+            h6 {
+              font-size: inherit;
+              line-height: inherit;
+              margin-bottom: 0;
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .extended-description {
+    padding: 50px 0;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    color: $ui-primary-color;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+    }
+  }
+
+  .footer-links {
+    padding-bottom: 50px;
+    text-align: right;
+    color: $ui-base-lighter-color;
+
+    p {
+      font-size: 14px;
+    }
+
+    a {
+      color: inherit;
+      text-decoration: underline;
+    }
+  }
+
+  @media screen and (max-width: 840px) {
+    .container {
+      padding: 0 20px;
+    }
+
+    .information-board {
+
+      .container {
+        padding-right: 20px;
+      }
+
+      .section {
+        text-align: center;
+      }
+
+      .panel {
+        position: static;
+        margin-top: 20px;
+        width: 100%;
+        border-radius: 4px;
+
+        .panel-header {
+          text-align: center;
+        }
+      }
+    }
+
+    .header-wrapper .mascot {
+      left: 20px;
+    }
+  }
+
+  @media screen and (max-width: 689px) {
+    .header-wrapper .mascot {
+      display: none;
+    }
+  }
+
+  @media screen and (max-width: 675px) {
+    .header-wrapper {
+      padding-top: 0;
+
+      &.compact {
+        padding-bottom: 0;
+      }
+
+      &.compact .hero .heading {
+        text-align: initial;
+      }
+    }
+
+    .header .container,
+    .features .container {
+      display: block;
+    }
+
+    .header {
+
+      .links {
+        padding-top: 15px;
+        background: darken($ui-base-color, 4%);
+
+        a {
+          padding: 12px 8px;
+        }
+
+        .nav {
+          display: flex;
+          flex-flow: row wrap;
+          justify-content: space-around;
+        }
+
+        .brand img {
+          left: 0;
+          top: 0;
+        }
+      }
+
+      .hero {
+        margin-top: 30px;
+        padding: 0;
+
+        .floats {
+          display: none;
+        }
+
+        .heading {
+          padding: 30px 20px;
+          text-align: center;
+        }
+
+        .simple_form,
+        .closed-registrations-message {
+          background: darken($ui-base-color, 8%);
+          width: 100%;
+          border-radius: 0;
+          box-sizing: border-box;
+        }
+      }
+    }
+
+    .features #mastodon-timeline {
+      height: 70vh;
+      width: 100%;
+      margin-bottom: 50px;
+
+      .column {
+        width: 100%;
+      }
+    }
+  }
+
+  .cta {
+    margin: 20px;
+  }
+
+  &.tag-page {
+    .features {
+      padding: 30px 0;
+
+      .container {
+        max-width: 820px;
+
+        #mastodon-timeline {
+          margin-right: 0;
+          border-top-right-radius: 0;
+        }
+
+        .about-mastodon {
+          .about-hashtag {
+            background: darken($ui-base-color, 4%);
+            padding: 0 20px 20px 30px;
+            border-radius: 0 5px 5px 0;
+
+            .brand {
+              padding-top: 20px;
+              margin-bottom: 20px;
+
+              img {
+                height: 48px;
+                width: auto;
+              }
+            }
+
+            p {
+              strong {
+                color: $ui-secondary-color;
+                font-weight: 700;
+              }
+            }
+
+            .cta {
+              margin: 0;
+
+              .button {
+                margin-right: 4px;
+              }
+            }
+          }
+
+          .features-list {
+            margin-left: 30px;
+            margin-right: 10px;
+          }
+        }
+      }
+    }
+
+    @media screen and (max-width: 675px) {
+      .features {
+        padding: 10px 0;
+
+        .container {
+          display: flex;
+          flex-direction: column;
+
+          #mastodon-timeline {
+            order: 2;
+            flex: 0 0 auto;
+            height: 60vh;
+            margin-bottom: 20px;
+            border-top-right-radius: 4px;
+          }
+
+          .about-mastodon {
+            order: 1;
+            flex: 0 0 auto;
+            max-width: 100%;
+
+            .about-hashtag {
+              background: unset;
+              padding: 0;
+              border-radius: 0;
+
+              .cta {
+                margin: 20px 0;
+              }
+            }
+
+            .features-list {
+              display: none;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+@keyframes floating {
+  from {
+    transform: translate(0, 0);
+  }
+
+  65% {
+    transform: translate(0, 4px);
+  }
+
+  to {
+    transform: translate(0, -0);
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
new file mode 100644
index 000000000..497ee9ba6
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -0,0 +1,566 @@
+.card {
+  background-color: lighten($ui-base-color, 4%);
+  background-size: cover;
+  background-position: center;
+  border-radius: 4px 4px 0 0;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  overflow: hidden;
+  position: relative;
+  display: flex;
+
+  &::after {
+    background: rgba(darken($ui-base-color, 8%), 0.5);
+    display: block;
+    content: "";
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 1;
+  }
+
+  @media screen and (max-width: 740px) {
+    border-radius: 0;
+    box-shadow: none;
+  }
+
+  .card__illustration {
+    padding: 60px 0;
+    position: relative;
+    flex: 1 1 auto;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .card__bio {
+    max-width: 260px;
+    flex: 1 1 auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    background: rgba(darken($ui-base-color, 8%), 0.8);
+    position: relative;
+    z-index: 2;
+  }
+
+  &.compact {
+    padding: 30px 0;
+    border-radius: 4px;
+
+    .avatar {
+      margin-bottom: 0;
+
+      img {
+        object-fit: cover;
+      }
+    }
+  }
+
+  .name {
+    display: block;
+    font-size: 20px;
+    line-height: 18px * 1.5;
+    color: $primary-text-color;
+    padding: 10px 15px;
+    padding-bottom: 0;
+    font-weight: 500;
+    position: relative;
+    z-index: 2;
+    margin-bottom: 15px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+
+    small {
+      display: block;
+      font-size: 14px;
+      color: $ui-highlight-color;
+      font-weight: 400;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
+  .avatar {
+    width: 120px;
+    margin: 0 auto;
+    position: relative;
+    z-index: 2;
+    @include avatar-size(120px);
+
+    img {
+      width: 120px;
+      height: 120px;
+      display: block;
+      border-radius: 120px;
+      box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+      @include avatar-radius();
+      @include avatar-size(120px);
+    }
+  }
+
+  .controls {
+    position: absolute;
+    top: 15px;
+    left: 15px;
+    z-index: 2;
+
+    .icon-button {
+      color: rgba($white, 0.8);
+      text-decoration: none;
+      font-size: 13px;
+      line-height: 13px;
+      font-weight: 500;
+
+      .fa {
+        font-weight: 400;
+        margin-right: 5px;
+      }
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: $white;
+      }
+    }
+  }
+
+  .roles {
+    margin-bottom: 15px;
+    padding: 0 15px;
+  }
+
+  .details-counters {
+    margin-top: 30px;
+    display: flex;
+    flex-direction: row;
+    width: 100%;
+  }
+
+  .counter {
+    width: 33.3%;
+    box-sizing: border-box;
+    flex: 0 0 auto;
+    color: $ui-primary-color;
+    padding: 5px 10px 0;
+    margin-bottom: 10px;
+    border-right: 1px solid lighten($ui-base-color, 4%);
+    cursor: default;
+    text-align: center;
+    position: relative;
+
+    a {
+      display: block;
+    }
+
+    &:last-child {
+      border-right: 0;
+    }
+
+    &::after {
+      display: block;
+      content: "";
+      position: absolute;
+      bottom: -10px;
+      left: 0;
+      width: 100%;
+      border-bottom: 4px solid $ui-primary-color;
+      opacity: 0.5;
+      transition: all 400ms ease;
+    }
+
+    &.active {
+      &::after {
+        border-bottom: 4px solid $ui-highlight-color;
+        opacity: 1;
+      }
+    }
+
+    &:hover {
+      &::after {
+        opacity: 1;
+        transition-duration: 100ms;
+      }
+    }
+
+    a {
+      text-decoration: none;
+      color: inherit;
+    }
+
+    .counter-label {
+      font-size: 12px;
+      display: block;
+      margin-bottom: 5px;
+    }
+
+    .counter-number {
+      font-weight: 500;
+      font-size: 18px;
+      color: $primary-text-color;
+      font-family: 'mastodon-font-display', sans-serif;
+    }
+  }
+
+  .bio {
+    font-size: 14px;
+    line-height: 18px;
+    padding: 0 15px;
+    text-align: center;
+    color: $ui-secondary-color;
+  }
+
+  @media screen and (max-width: 480px) {
+    display: block;
+
+    .card__bio {
+      max-width: none;
+    }
+
+    .name,
+    .roles {
+      text-align: center;
+      margin-bottom: 5px;
+    }
+
+    .bio {
+      margin-bottom: 15px;
+    }
+  }
+}
+
+.pagination {
+  padding: 30px 0;
+  text-align: center;
+  overflow: hidden;
+
+  a,
+  .current,
+  .next,
+  .prev,
+  .page,
+  .gap {
+    font-size: 14px;
+    color: $primary-text-color;
+    font-weight: 500;
+    display: inline-block;
+    padding: 6px 10px;
+    text-decoration: none;
+  }
+
+  .current {
+    background: $simple-background-color;
+    border-radius: 100px;
+    color: $ui-base-color;
+    cursor: default;
+    margin: 0 10px;
+  }
+
+  .gap {
+    cursor: default;
+  }
+
+  .prev,
+  .next {
+    text-transform: uppercase;
+    color: $ui-secondary-color;
+  }
+
+  .prev {
+    float: left;
+    padding-left: 0;
+
+    .fa {
+      display: inline-block;
+      margin-right: 5px;
+    }
+  }
+
+  .next {
+    float: right;
+    padding-right: 0;
+
+    .fa {
+      display: inline-block;
+      margin-left: 5px;
+    }
+  }
+
+  .disabled {
+    cursor: default;
+    color: lighten($ui-base-color, 10%);
+  }
+
+  @media screen and (max-width: 700px) {
+    padding: 30px 20px;
+
+    .page {
+      display: none;
+    }
+
+    .next,
+    .prev {
+      display: inline-block;
+    }
+  }
+}
+
+.accounts-grid {
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  background: darken($simple-background-color, 8%);
+  border-radius: 0 0 4px 4px;
+  padding: 20px 5px;
+  padding-bottom: 10px;
+  overflow: hidden;
+  display: flex;
+  flex-wrap: wrap;
+  z-index: 2;
+  position: relative;
+
+  @media screen and (max-width: 740px) {
+    border-radius: 0;
+    box-shadow: none;
+  }
+
+  .account-grid-card {
+    box-sizing: border-box;
+    width: 335px;
+    background: $simple-background-color;
+    border-radius: 4px;
+    color: $ui-base-color;
+    margin: 0 5px 10px;
+    position: relative;
+
+    @media screen and (max-width: 740px) {
+      width: calc(100% - 10px);
+    }
+
+    .account-grid-card__header {
+      overflow: hidden;
+      height: 100px;
+      border-radius: 4px 4px 0 0;
+      background-color: lighten($ui-base-color, 4%);
+      background-size: cover;
+      background-position: center;
+      position: relative;
+
+      &::after {
+        background: rgba(darken($ui-base-color, 8%), 0.5);
+        display: block;
+        content: "";
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+        z-index: 1;
+      }
+    }
+
+    .account-grid-card__avatar {
+      box-sizing: border-box;
+      padding: 15px;
+      position: absolute;
+      z-index: 2;
+      top: 100px - (40px + 2px);
+      left: -2px;
+    }
+
+    .avatar {
+      width: 80px;
+      height: 80px;
+      @include avatar-size(80px);
+
+      img {
+        display: block;
+        width: 80px;
+        height: 80px;
+        border-radius: 80px;
+        border: 2px solid $simple-background-color;
+        background: $simple-background-color;
+        @include avatar-radius();
+        @include avatar-size(80px);
+      }
+    }
+
+    .name {
+      padding: 15px;
+      padding-top: 10px;
+      padding-left: 15px + 80px + 15px;
+
+      a {
+        display: block;
+        color: $ui-base-color;
+        text-decoration: none;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        font-weight: 500;
+
+        &:hover {
+          .display_name {
+            text-decoration: underline;
+          }
+        }
+      }
+    }
+
+    .display_name {
+      font-size: 16px;
+      display: block;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+
+    .username {
+      color: lighten($ui-base-color, 34%);
+      font-size: 14px;
+      font-weight: 400;
+    }
+
+    .note {
+      padding: 10px 15px;
+      padding-top: 15px;
+      box-sizing: border-box;
+      color: lighten($ui-base-color, 26%);
+      word-wrap: break-word;
+      min-height: 80px;
+    }
+  }
+}
+
+.nothing-here {
+  width: 100%;
+  display: block;
+  color: $ui-primary-color;
+  font-size: 14px;
+  font-weight: 500;
+  text-align: center;
+  padding: 60px 0;
+  padding-top: 55px;
+  cursor: default;
+}
+
+.account-card {
+  padding: 14px 10px;
+  background: $simple-background-color;
+  border-radius: 4px;
+  text-align: left;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+  .detailed-status__display-name {
+    display: block;
+    overflow: hidden;
+    margin-bottom: 15px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    & > div {
+      float: left;
+      margin-right: 10px;
+      width: 48px;
+      height: 48px;
+      @include avatar-size(48px);
+    }
+
+    .avatar {
+      display: block;
+      border-radius: 4px;
+      @include avatar-radius();
+    }
+
+    .display-name {
+      display: block;
+      max-width: 100%;
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      cursor: default;
+
+      strong {
+        font-weight: 500;
+        color: $ui-base-color;
+
+        @each $lang in $cjk-langs {
+          &:lang(#{$lang}) {
+            font-weight: 700;
+          }
+        }
+      }
+
+      span {
+        font-size: 14px;
+        color: $ui-primary-color;
+      }
+    }
+
+    &:hover {
+      .display-name {
+        strong {
+          text-decoration: none;
+        }
+      }
+    }
+  }
+
+  .account__header__content {
+    font-size: 14px;
+    color: $ui-base-color;
+  }
+}
+
+.activity-stream-tabs {
+  background: $simple-background-color;
+  border-bottom: 1px solid $ui-secondary-color;
+  position: relative;
+  z-index: 2;
+
+  a {
+    display: inline-block;
+    padding: 15px;
+    text-decoration: none;
+    color: $ui-highlight-color;
+    text-transform: uppercase;
+    font-weight: 500;
+
+    &:hover,
+    &:active,
+    &:focus {
+      color: lighten($ui-highlight-color, 8%);
+    }
+
+    &.active {
+      color: $ui-base-color;
+      cursor: default;
+    }
+  }
+}
+
+.account-role {
+  display: inline-block;
+  padding: 4px 6px;
+  cursor: default;
+  border-radius: 3px;
+  font-size: 12px;
+  line-height: 12px;
+  font-weight: 500;
+  color: $ui-secondary-color;
+  background-color: rgba($ui-secondary-color, 0.1);
+  border: 1px solid rgba($ui-secondary-color, 0.5);
+
+  &.moderator {
+    color: $success-green;
+    background-color: rgba($success-green, 0.1);
+    border-color: rgba($success-green, 0.5);
+  }
+
+  &.admin {
+    color: lighten($error-red, 12%);
+    background-color: rgba(lighten($error-red, 12%), 0.1);
+    border-color: rgba(lighten($error-red, 12%), 0.5);
+  }
+}
+
+@import 'metadata';
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
new file mode 100644
index 000000000..f9245e134
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -0,0 +1,502 @@
+.admin-wrapper {
+  display: flex;
+  justify-content: center;
+  height: 100%;
+
+  .sidebar-wrapper {
+    flex: 1;
+    height: 100%;
+    background: $ui-base-color;
+    display: flex;
+    justify-content: flex-end;
+  }
+
+  .sidebar {
+    width: 240px;
+    height: 100%;
+    padding: 0;
+    overflow-y: auto;
+
+    .logo {
+      display: block;
+      margin: 40px auto;
+      width: 100px;
+      height: 100px;
+    }
+
+    ul {
+      list-style: none;
+      border-radius: 4px 0 0 4px;
+      overflow: hidden;
+      margin-bottom: 20px;
+
+      a {
+        display: block;
+        padding: 15px;
+        color: rgba($primary-text-color, 0.7);
+        text-decoration: none;
+        transition: all 200ms linear;
+        border-radius: 4px 0 0 4px;
+
+        i.fa {
+          margin-right: 5px;
+        }
+
+        &:hover {
+          color: $primary-text-color;
+          background-color: darken($ui-base-color, 5%);
+          transition: all 100ms linear;
+        }
+
+        &.selected {
+          background: darken($ui-base-color, 2%);
+          border-radius: 4px 0 0;
+        }
+      }
+
+      ul {
+        background: darken($ui-base-color, 4%);
+        border-radius: 0 0 0 4px;
+        margin: 0;
+
+        a {
+          border: 0;
+          padding: 15px 35px;
+
+          &.selected {
+            color: $primary-text-color;
+            background-color: $ui-highlight-color;
+            border-bottom: 0;
+            border-radius: 0;
+
+            &:hover {
+              background-color: lighten($ui-highlight-color, 5%);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .content-wrapper {
+    flex: 2;
+    overflow: auto;
+  }
+
+  .content {
+    max-width: 700px;
+    padding: 20px 15px;
+    padding-top: 60px;
+    padding-left: 25px;
+
+    h2 {
+      color: $ui-secondary-color;
+      font-size: 24px;
+      line-height: 28px;
+      font-weight: 400;
+      margin-bottom: 40px;
+    }
+
+    h3 {
+      color: $ui-secondary-color;
+      font-size: 20px;
+      line-height: 28px;
+      font-weight: 400;
+      margin-bottom: 30px;
+    }
+
+    h6 {
+      font-size: 16px;
+      color: $ui-secondary-color;
+      line-height: 28px;
+      font-weight: 400;
+    }
+
+    & > p {
+      font-size: 14px;
+      line-height: 18px;
+      color: $ui-secondary-color;
+      margin-bottom: 20px;
+
+      strong {
+        color: $primary-text-color;
+        font-weight: 500;
+
+        @each $lang in $cjk-langs {
+          &:lang(#{$lang}) {
+            font-weight: 700;
+          }
+        }
+      }
+    }
+
+    hr {
+      margin: 20px 0;
+      border: 0;
+      background: transparent;
+      border-bottom: 1px solid $ui-base-color;
+    }
+
+    .muted-hint {
+      color: $ui-primary-color;
+
+      a {
+        color: $ui-highlight-color;
+      }
+    }
+
+    .positive-hint {
+      color: $valid-value-color;
+      font-weight: 500;
+    }
+  }
+
+  .simple_form {
+    max-width: 400px;
+
+    &.edit_user,
+    &.new_form_admin_settings,
+    &.new_form_two_factor_confirmation,
+    &.new_form_delete_confirmation,
+    &.new_import,
+    &.new_domain_block,
+    &.edit_domain_block {
+      max-width: none;
+    }
+
+    .form_two_factor_confirmation_code,
+    .form_delete_confirmation_password {
+      max-width: 400px;
+    }
+
+    .actions {
+      max-width: 400px;
+    }
+  }
+
+  @media screen and (max-width: 600px) {
+    display: block;
+    overflow-y: auto;
+    -webkit-overflow-scrolling: touch;
+
+    .sidebar-wrapper,
+    .content-wrapper {
+      flex: 0 0 auto;
+      height: auto;
+      overflow: initial;
+    }
+
+    .sidebar {
+      width: 100%;
+      padding: 10px 0;
+      height: auto;
+
+      .logo {
+        margin: 20px auto;
+      }
+    }
+
+    .content {
+      padding-top: 20px;
+    }
+  }
+}
+
+.filters {
+  display: flex;
+  flex-wrap: wrap;
+
+  .filter-subset {
+    flex: 0 0 auto;
+    margin: 0 40px 10px 0;
+
+    &:last-child {
+      margin-bottom: 20px;
+    }
+
+    ul {
+      margin-top: 5px;
+      list-style: none;
+
+      li {
+        display: inline-block;
+        margin-right: 5px;
+      }
+    }
+
+    strong {
+      font-weight: 500;
+      text-transform: uppercase;
+      font-size: 12px;
+
+      @each $lang in $cjk-langs {
+        &:lang(#{$lang}) {
+          font-weight: 700;
+        }
+      }
+    }
+
+    a {
+      display: inline-block;
+      color: rgba($primary-text-color, 0.7);
+      text-decoration: none;
+      text-transform: uppercase;
+      font-size: 12px;
+      font-weight: 500;
+      border-bottom: 2px solid $ui-base-color;
+
+      &:hover {
+        color: $primary-text-color;
+        border-bottom: 2px solid lighten($ui-base-color, 5%);
+      }
+
+      &.selected {
+        color: $ui-highlight-color;
+        border-bottom: 2px solid $ui-highlight-color;
+      }
+    }
+  }
+}
+
+.flavour-screen {
+  display: block;
+  margin: 10px auto;
+  max-width: 100%;
+}
+
+.flavour-description {
+  display: block;
+  font-size: 16px;
+  margin: 10px 0;
+
+  & > p {
+    margin: 10px 0;
+  }
+}
+
+.report-accounts {
+  display: flex;
+  flex-wrap: wrap;
+  margin-bottom: 20px;
+}
+
+.report-accounts__item {
+  display: flex;
+  flex: 250px;
+  flex-direction: column;
+  margin: 0 5px;
+
+  & > strong {
+    display: block;
+    margin: 0 0 10px -5px;
+    font-weight: 500;
+    font-size: 14px;
+    line-height: 18px;
+    color: $ui-secondary-color;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  .account-card {
+    flex: 1 1 auto;
+  }
+}
+
+.report-status,
+.account-status {
+  display: flex;
+  margin-bottom: 10px;
+
+  .activity-stream {
+    flex: 2 0 0;
+    margin-right: 20px;
+    max-width: calc(100% - 60px);
+
+    .entry {
+      border-radius: 4px;
+    }
+  }
+}
+
+.report-status__actions,
+.account-status__actions {
+  flex: 0 0 auto;
+  display: flex;
+  flex-direction: column;
+
+  .icon-button {
+    font-size: 24px;
+    width: 24px;
+    text-align: center;
+    margin-bottom: 10px;
+  }
+}
+
+.batch-form-box {
+  display: flex;
+  flex-wrap: wrap;
+  margin-bottom: 5px;
+
+  #form_status_batch_action {
+    margin: 0 5px 5px 0;
+    font-size: 14px;
+  }
+
+  input.button {
+    margin: 0 5px 5px 0;
+  }
+
+  .media-spoiler-toggle-buttons {
+    margin-left: auto;
+
+    .button {
+      overflow: visible;
+      margin: 0 0 5px 5px;
+      float: right;
+    }
+  }
+}
+
+.batch-checkbox,
+.batch-checkbox-all {
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+}
+
+.back-link {
+  margin-bottom: 10px;
+  font-size: 14px;
+
+  a {
+    color: $classic-highlight-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.spacer {
+  flex: 1 1 auto;
+}
+
+.log-entry {
+  margin-bottom: 8px;
+  line-height: 20px;
+
+  &__header {
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    padding: 10px;
+    background: $ui-base-color;
+    color: $ui-primary-color;
+    border-radius: 4px 4px 0 0;
+    font-size: 14px;
+    position: relative;
+  }
+
+  &__avatar {
+    margin-right: 10px;
+
+    .avatar {
+      display: block;
+      margin: 0;
+      border-radius: 50%;
+      width: 40px;
+      height: 40px;
+    }
+  }
+
+  &__content {
+    max-width: calc(100% - 90px);
+  }
+
+  &__title {
+    word-wrap: break-word;
+  }
+
+  &__timestamp {
+    color: lighten($ui-base-color, 34%);
+  }
+
+  &__extras {
+    background: lighten($ui-base-color, 6%);
+    border-radius: 0 0 4px 4px;
+    padding: 10px;
+    color: $ui-primary-color;
+    font-family: 'mastodon-font-monospace', monospace;
+    font-size: 12px;
+    word-wrap: break-word;
+    min-height: 20px;
+  }
+
+  &__icon {
+    font-size: 28px;
+    margin-right: 10px;
+    color: lighten($ui-base-color, 34%);
+  }
+
+  &__icon__overlay {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+
+    &.positive {
+      background: $success-green;
+    }
+
+    &.negative {
+      background: $error-red;
+    }
+
+    &.neutral {
+      background: $ui-highlight-color;
+    }
+  }
+
+  a,
+  .username,
+  .target {
+    color: $ui-secondary-color;
+    text-decoration: none;
+    font-weight: 500;
+  }
+
+  .diff-old {
+    color: $error-red;
+  }
+
+  .diff-neutral {
+    color: $ui-secondary-color;
+  }
+
+  .diff-new {
+    color: $success-green;
+  }
+}
+
+.name-tag {
+  display: flex;
+  align-items: center;
+
+  .avatar {
+    display: block;
+    margin: 0;
+    margin-right: 5px;
+    border-radius: 50%;
+  }
+
+  .username {
+    font-weight: 500;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/basics.scss b/app/javascript/flavours/glitch/styles/basics.scss
new file mode 100644
index 000000000..b5d77ff63
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/basics.scss
@@ -0,0 +1,122 @@
+body {
+  font-family: 'mastodon-font-sans-serif', sans-serif;
+  background: $ui-base-color;
+  background-size: cover;
+  background-attachment: fixed;
+  font-size: 13px;
+  line-height: 18px;
+  font-weight: 400;
+  color: $primary-text-color;
+  padding-bottom: 20px;
+  text-rendering: optimizelegibility;
+  font-feature-settings: "kern";
+  text-size-adjust: none;
+  -webkit-tap-highlight-color: rgba(0,0,0,0);
+  -webkit-tap-highlight-color: transparent;
+
+  &.system-font {
+    // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
+    // -apple-system => Safari <11 specific
+    // BlinkMacSystemFont => Chrome <56 on macOS specific
+    // Segoe UI => Windows 7/8/10
+    // Oxygen => KDE
+    // Ubuntu => Unity/Ubuntu
+    // Cantarell => GNOME
+    // Fira Sans => Firefox OS
+    // Droid Sans => Older Androids (<4.0)
+    // Helvetica Neue => Older macOS <10.11
+    // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
+    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
+  }
+
+  &.app-body {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    padding: 0;
+    background: $ui-base-color;
+  }
+
+  &.about-body {
+    background: darken($ui-base-color, 8%);
+    padding-bottom: 0;
+  }
+
+  &.tag-body {
+    background: darken($ui-base-color, 8%);
+    padding-bottom: 0;
+  }
+
+  &.embed {
+    background: transparent;
+    margin: 0;
+    padding-bottom: 0;
+
+    .container {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+  }
+
+  &.admin {
+    background: darken($ui-base-color, 4%);
+    position: fixed;
+    width: 100%;
+    height: 100%;
+    padding: 0;
+  }
+
+  &.error {
+    position: absolute;
+    text-align: center;
+    color: $ui-primary-color;
+    background: $ui-base-color;
+    width: 100%;
+    height: 100%;
+    padding: 0;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .dialog {
+      vertical-align: middle;
+      margin: 20px;
+
+      img {
+        display: block;
+        max-width: 470px;
+        width: 100%;
+        height: auto;
+        margin-top: -120px;
+      }
+
+      h1 {
+        font-size: 20px;
+        line-height: 28px;
+        font-weight: 400;
+      }
+    }
+  }
+}
+
+button {
+  font-family: inherit;
+  cursor: pointer;
+
+  &:focus {
+    outline: none;
+  }
+}
+
+.app-holder {
+  &,
+  & > div {
+    display: flex;
+    width: 100%;
+    height: 100%;
+    align-items: center;
+    justify-content: center;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/compact_header.scss b/app/javascript/flavours/glitch/styles/compact_header.scss
new file mode 100644
index 000000000..90d98cc8c
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/compact_header.scss
@@ -0,0 +1,34 @@
+.compact-header {
+  h1 {
+    font-size: 24px;
+    line-height: 28px;
+    color: $ui-primary-color;
+    font-weight: 500;
+    margin-bottom: 20px;
+    padding: 0 10px;
+    word-wrap: break-word;
+
+    @media screen and (max-width: 740px) {
+      text-align: center;
+      padding: 20px 10px 0;
+    }
+
+    a {
+      color: inherit;
+      text-decoration: none;
+    }
+
+    small {
+      font-weight: 400;
+      color: $ui-secondary-color;
+    }
+
+    img {
+      display: inline-block;
+      margin-bottom: -5px;
+      margin-right: 15px;
+      width: 36px;
+      height: 36px;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
new file mode 100644
index 000000000..67096018f
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -0,0 +1,463 @@
+.account {
+  padding: 10px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  color: inherit;
+  text-decoration: none;
+
+  .account__display-name {
+    flex: 1 1 auto;
+    display: block;
+    color: $ui-primary-color;
+    overflow: hidden;
+    text-decoration: none;
+    font-size: 14px;
+  }
+
+  &.small {
+    border: none;
+    padding: 0;
+
+    & > .account__avatar-wrapper { margin: 0 8px 0 0 }
+
+    & > .display-name {
+      height: 24px;
+      line-height: 24px;
+    }
+  }
+}
+
+.account__wrapper {
+  display: flex;
+}
+
+.account__avatar-wrapper {
+  float: left;
+  margin: 6px 16px 6px 6px;
+}
+
+.account__avatar {
+  @include avatar-radius();
+  position: relative;
+  cursor: pointer;
+
+  &-inline {
+    display: inline-block;
+    vertical-align: middle;
+    margin-right: 5px;
+  }
+}
+
+.account__avatar-overlay {
+  position: relative;
+  @include avatar-size(48px);
+
+  &-base {
+    @include avatar-radius();
+    @include avatar-size(36px);
+  }
+
+  &-overlay {
+    @include avatar-radius();
+    @include avatar-size(24px);
+
+    position: absolute;
+    bottom: 0;
+    right: 0;
+    z-index: 1;
+  }
+}
+
+.account__relationship {
+  height: 18px;
+  padding: 10px;
+  white-space: nowrap;
+}
+
+.account__header__wrapper {
+  flex: 0 0 auto;
+  background: lighten($ui-base-color, 4%);
+}
+
+.account__header {
+  flex: 0 0 auto;
+  background: lighten($ui-base-color, 4%);
+  text-align: center;
+  background-size: cover;
+  background-position: center;
+  position: relative;
+
+  .account__avatar {
+    @include avatar-radius();
+    @include avatar-size(90px);
+    display: block;
+    margin: 0 auto 10px;
+    overflow: hidden;
+  }
+
+  &.inactive {
+    opacity: 0.5;
+
+    .account__header__avatar {
+      filter: grayscale(100%);
+    }
+
+    .account__header__username {
+      color: $ui-primary-color;
+    }
+  }
+
+  & > div {
+    background: rgba(lighten($ui-base-color, 4%), 0.9);
+    padding: 20px 10px;
+  }
+
+  .account__header__content {
+    color: $ui-secondary-color;
+  }
+
+  .account__header__display-name {
+    color: $primary-text-color;
+    display: inline-block;
+    width: 100%;
+    font-size: 20px;
+    line-height: 27px;
+    font-weight: 500;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .account__header__username {
+    color: $ui-highlight-color;
+    font-size: 14px;
+    font-weight: 400;
+    display: block;
+    margin-bottom: 10px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.account__disclaimer {
+  padding: 10px;
+  border-top: 1px solid lighten($ui-base-color, 8%);
+  color: $ui-base-lighter-color;
+
+  strong {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  a {
+    font-weight: 500;
+    color: inherit;
+    text-decoration: underline;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: none;
+    }
+  }
+}
+
+.account__header__content {
+  color: $ui-primary-color;
+  font-size: 14px;
+  font-weight: 400;
+  overflow: hidden;
+  word-break: normal;
+  word-wrap: break-word;
+
+  p {
+    margin-bottom: 20px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: inherit;
+    text-decoration: underline;
+
+    &:hover {
+      text-decoration: none;
+    }
+  }
+}
+
+.account__header__display-name {
+  .emojione {
+    width: 25px;
+    height: 25px;
+  }
+}
+
+.account__action-bar {
+  border-top: 1px solid lighten($ui-base-color, 8%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  line-height: 36px;
+  overflow: hidden;
+  flex: 0 0 auto;
+  display: flex;
+}
+
+.account__action-bar-dropdown {
+  flex: 0 1 calc(50% - 140px);
+  padding: 10px;
+
+  .dropdown--active {
+    .dropdown__content.dropdown__right {
+      left: 6px;
+      right: initial;
+    }
+
+    &::after {
+      bottom: initial;
+      margin-left: 11px;
+      margin-top: -7px;
+      right: initial;
+    }
+  }
+}
+
+.account__action-bar-links {
+  display: flex;
+  flex: 1 1 auto;
+  line-height: 18px;
+}
+
+.account__action-bar__tab {
+  text-decoration: none;
+  overflow: hidden;
+  flex: 0 1 80px;
+  border-left: 1px solid lighten($ui-base-color, 8%);
+  padding: 10px 5px;
+
+  & > span {
+    display: block;
+    text-transform: uppercase;
+    font-size: 11px;
+    color: $ui-primary-color;
+  }
+
+  strong {
+    display: block;
+    font-size: 15px;
+    font-weight: 500;
+    color: $primary-text-color;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  abbr {
+    color: $ui-base-lighter-color;
+  }
+}
+
+.account__header__avatar {
+  background-size: 90px 90px;
+  display: block;
+  height: 90px;
+  margin: 0 auto 10px;
+  overflow: hidden;
+  width: 90px;
+}
+
+.account-authorize {
+  padding: 14px 10px;
+
+  .detailed-status__display-name {
+    display: block;
+    margin-bottom: 15px;
+    overflow: hidden;
+  }
+}
+
+.account-authorize__avatar {
+  float: left;
+  margin-right: 10px;
+}
+
+.notification__message {
+  margin-left: 42px;
+  padding: 8px 0 0 26px;
+  cursor: default;
+  color: $ui-primary-color;
+  font-size: 15px;
+  position: relative;
+
+  .fa {
+    color: $ui-highlight-color;
+  }
+
+  > span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.account--panel {
+  background: lighten($ui-base-color, 4%);
+  border-top: 1px solid lighten($ui-base-color, 8%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: row;
+  padding: 10px 0;
+}
+
+.account--panel__button,
+.detailed-status__button {
+  flex: 1 1 auto;
+  text-align: center;
+}
+
+.column-settings__outer {
+  background: lighten($ui-base-color, 8%);
+  padding: 15px;
+}
+
+.column-settings__section {
+  color: $ui-primary-color;
+  cursor: default;
+  display: block;
+  font-weight: 500;
+  margin-bottom: 10px;
+}
+
+.column-settings__row {
+  .text-btn {
+    margin-bottom: 15px;
+  }
+}
+
+.account--follows-info {
+  color: $primary-text-color;
+  position: absolute;
+  top: 10px;
+  left: 10px;
+  opacity: 0.7;
+  display: inline-block;
+  vertical-align: top;
+  background-color: rgba($base-overlay-background, 0.4);
+  text-transform: uppercase;
+  font-size: 11px;
+  font-weight: 500;
+  padding: 4px;
+  border-radius: 4px;
+}
+
+.account--action-button {
+  position: absolute;
+  top: 10px;
+  right: 20px;
+}
+
+.account-gallery__container {
+  margin: -2px;
+  padding: 4px;
+  display: flex;
+  flex-wrap: wrap;
+}
+
+.account-gallery__item {
+  flex: 1 1 auto;
+  width: calc(100% / 3 - 4px);
+  height: 95px;
+  margin: 2px;
+
+  a {
+    display: block;
+    width: 100%;
+    height: 100%;
+    background-color: $base-overlay-background;
+    background-size: cover;
+    background-position: center;
+    position: relative;
+    color: inherit;
+    text-decoration: none;
+
+    &:hover,
+    &:active,
+    &:focus {
+      outline: 0;
+    }
+  }
+}
+
+.account-section-headline {
+  color: $ui-base-lighter-color;
+  background: lighten($ui-base-color, 2%);
+  border-bottom: 1px solid lighten($ui-base-color, 4%);
+  padding: 15px 10px;
+  font-size: 14px;
+  font-weight: 500;
+  position: relative;
+  cursor: default;
+
+  &::before,
+  &::after {
+    display: block;
+    content: "";
+    position: absolute;
+    bottom: 0;
+    left: 18px;
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 0 10px 10px;
+    border-color: transparent transparent lighten($ui-base-color, 4%);
+  }
+
+  &::after {
+    bottom: -1px;
+    border-color: transparent transparent $ui-base-color;
+  }
+}
+
+.account__moved-note {
+  padding: 14px 10px;
+  padding-bottom: 16px;
+  background: lighten($ui-base-color, 4%);
+  border-top: 1px solid lighten($ui-base-color, 8%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  &__message {
+    position: relative;
+    margin-left: 58px;
+    color: $ui-base-lighter-color;
+    padding: 8px 0;
+    padding-top: 0;
+    padding-bottom: 4px;
+    font-size: 14px;
+
+    > span {
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
+  &__icon-wrapper {
+    left: -26px;
+    position: absolute;
+  }
+
+  .detailed-status__display-avatar {
+    position: relative;
+  }
+
+  .detailed-status__display-name {
+    margin-bottom: 0;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/boost.scss b/app/javascript/flavours/glitch/styles/components/boost.scss
new file mode 100644
index 000000000..b07b72f8e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/boost.scss
@@ -0,0 +1,28 @@
+@function hex-color($color) {
+  @if type-of($color) == 'color' {
+    $color: str-slice(ie-hex-str($color), 4);
+  }
+  @return '%23' + unquote($color)
+}
+
+button.icon-button i.fa-retweet {
+  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-base-lighter-color)}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>");
+
+  &:hover {
+    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 33%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>");
+  }
+}
+
+// Disabled variant
+button.icon-button.disabled i.fa-retweet {
+  &, &:hover {
+    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 13%))}' stroke-width='0'/></svg>");
+  }
+}
+
+// Disabled variant for use with DMs
+.status-direct button.icon-button.disabled i.fa-retweet {
+  &, &:hover {
+    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 16%))}' stroke-width='0'/></svg>");
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
new file mode 100644
index 000000000..6e03650ed
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -0,0 +1,503 @@
+.column__wrapper {
+  display: flex;
+  flex: 1 1 auto;
+  position: relative;
+}
+
+.column-icon {
+  background: lighten($ui-base-color, 4%);
+  color: $ui-primary-color;
+  cursor: pointer;
+  font-size: 16px;
+  padding: 15px;
+  position: absolute;
+  right: 0;
+  top: -48px;
+  z-index: 3;
+
+  &:hover {
+    color: lighten($ui-primary-color, 7%);
+  }
+}
+
+.columns-area {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: row;
+  justify-content: flex-start;
+  overflow-x: auto;
+  position: relative;
+}
+
+@include limited-single-column('screen and (max-width: 360px)', $parent: null) {
+  .columns-area {
+    padding: 10px;
+  }
+
+  .react-swipeable-view-container .columns-area {
+    height: calc(100% - 20px) !important;
+  }
+}
+
+.react-swipeable-view-container {
+  &,
+  .columns-area,
+  .column {
+    height: 100%;
+  }
+}
+
+.react-swipeable-view-container > * {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+}
+
+.column {
+  width: 330px;
+  position: relative;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+
+  > .scrollable {
+    background: $ui-base-color;
+  }
+}
+
+.ui {
+  flex: 0 0 auto;
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  background: darken($ui-base-color, 7%);
+}
+
+.column {
+  overflow: hidden;
+}
+
+@include limited-single-column('screen and (max-width: 360px)', $parent: null) {
+  .tabs-bar {
+    margin: 0;
+  }
+}
+
+:root {  //  Overrides .wide stylings for mobile view
+  @include single-column('screen and (max-width: 630px)', $parent: null) {
+    .column {
+      flex: auto;
+      width: 100%;
+      min-width: 0;
+      max-width: none;
+      padding: 0;
+    }
+
+    .columns-area {
+      flex-direction: column;
+    }
+
+    .search__input,
+    .autosuggest-textarea__textarea {
+      font-size: 16px;
+    }
+  }
+}
+
+@include multi-columns('screen and (min-width: 631px)', $parent: null) {
+  .columns-area {
+    padding: 0;
+  }
+
+  .column {
+    flex: 0 0 auto;
+    padding: 10px;
+    padding-left: 5px;
+    padding-right: 5px;
+
+    &:first-child {
+      padding-left: 10px;
+    }
+
+    &:last-child {
+      padding-right: 10px;
+    }
+  }
+
+  .columns-area > div {
+    .column {
+      padding-left: 5px;
+      padding-right: 5px;
+    }
+  }
+}
+
+.column-back-button {
+  background: lighten($ui-base-color, 4%);
+  color: $ui-highlight-color;
+  cursor: pointer;
+  flex: 0 0 auto;
+  font-size: 16px;
+  border: 0;
+  text-align: unset;
+  padding: 15px;
+  margin: 0;
+  z-index: 3;
+
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+.column-header__back-button {
+  background: lighten($ui-base-color, 4%);
+  border: 0;
+  font-family: inherit;
+  color: $ui-highlight-color;
+  cursor: pointer;
+  flex: 0 0 auto;
+  font-size: 16px;
+  padding: 0 5px 0 0;
+  z-index: 3;
+
+  &:hover {
+    text-decoration: underline;
+  }
+
+  &:last-child {
+    padding: 0 15px 0 0;
+  }
+}
+
+.column-back-button__icon {
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.column-back-button--slim {
+  position: relative;
+}
+
+.column-back-button--slim-button {
+  cursor: pointer;
+  flex: 0 0 auto;
+  font-size: 16px;
+  padding: 15px;
+  position: absolute;
+  right: 0;
+  top: -48px;
+}
+
+.column-link {
+  background: lighten($ui-base-color, 8%);
+  color: $primary-text-color;
+  display: block;
+  font-size: 16px;
+  padding: 15px;
+  text-decoration: none;
+
+  &:hover {
+    background: lighten($ui-base-color, 11%);
+  }
+}
+
+.column-link__icon {
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.column-subheading {
+  background: $ui-base-color;
+  color: $ui-base-lighter-color;
+  padding: 8px 20px;
+  font-size: 12px;
+  font-weight: 500;
+  text-transform: uppercase;
+  cursor: default;
+}
+
+.column-header__wrapper {
+  position: relative;
+  flex: 0 0 auto;
+
+  &.active {
+    &::before {
+      display: block;
+      content: "";
+      position: absolute;
+      top: 35px;
+      left: 0;
+      right: 0;
+      margin: 0 auto;
+      width: 60%;
+      pointer-events: none;
+      height: 28px;
+      z-index: 1;
+      background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);
+    }
+  }
+}
+
+.column-header {
+  display: flex;
+  padding: 15px;
+  font-size: 16px;
+  background: lighten($ui-base-color, 4%);
+  flex: 0 0 auto;
+  cursor: pointer;
+  position: relative;
+  z-index: 2;
+  outline: 0;
+
+  &.active {
+    box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);
+
+    .column-header__icon {
+      color: $ui-highlight-color;
+      text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);
+    }
+  }
+
+  &:focus,
+  &:active {
+    outline: 0;
+  }
+}
+
+.column {
+  width: 330px;
+  position: relative;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+
+  .wide & {
+    flex: auto;
+    min-width: 330px;
+    max-width: 400px;
+  }
+
+  > .scrollable {
+    background: $ui-base-color;
+  }
+}
+
+.column-header__buttons {
+  height: 48px;
+  display: flex;
+  margin: -15px;
+  margin-left: 0;
+}
+
+.column-header__links .text-btn {
+  margin-right: 10px;
+}
+
+.column-header__button {
+  background: lighten($ui-base-color, 4%);
+  border: 0;
+  color: $ui-primary-color;
+  cursor: pointer;
+  font-size: 16px;
+  padding: 0 15px;
+
+  &:hover {
+    color: lighten($ui-primary-color, 7%);
+  }
+
+  &.active {
+    color: $primary-text-color;
+    background: lighten($ui-base-color, 8%);
+
+    &:hover {
+      color: $primary-text-color;
+      background: lighten($ui-base-color, 8%);
+    }
+  }
+
+  // glitch - added focus ring for keyboard navigation
+  &:focus {
+    text-shadow: 0 0 4px darken($ui-highlight-color, 5%);
+  }
+}
+
+.column-header__notif-cleaning-buttons {
+  display: flex;
+  align-items: stretch;
+  justify-content: space-around;
+
+  button {
+    @extend .column-header__button;
+    background: transparent;
+    text-align: center;
+    padding: 10px 0;
+    white-space: pre-wrap;
+  }
+
+  b {
+    font-weight: bold;
+  }
+}
+
+// The notifs drawer with no padding to have more space for the buttons
+.column-header__collapsible-inner.nopad-drawer {
+  padding: 0;
+}
+
+.column-header__collapsible {
+  max-height: 70vh;
+  overflow: hidden;
+  overflow-y: auto;
+  color: $ui-primary-color;
+  transition: max-height 150ms ease-in-out, opacity 300ms linear;
+  opacity: 1;
+
+  &.collapsed {
+    max-height: 0;
+    opacity: 0.5;
+  }
+
+  &.animating {
+    overflow-y: hidden;
+  }
+
+  hr {
+    height: 0;
+    background: transparent;
+    border: 0;
+    border-top: 1px solid lighten($ui-base-color, 12%);
+    margin: 10px 0;
+  }
+
+  // notif cleaning drawer
+  &.ncd {
+    transition: none;
+    &.collapsed {
+      max-height: 0;
+      opacity: 0.7;
+    }
+  }
+}
+
+.column-header__collapsible-inner {
+  background: lighten($ui-base-color, 8%);
+  padding: 15px;
+}
+
+.column-header__setting-btn {
+  &:hover {
+    color: lighten($ui-primary-color, 4%);
+    text-decoration: underline;
+  }
+}
+
+.column-header__setting-arrows {
+  float: right;
+
+  .column-header__setting-btn {
+    padding: 0 10px;
+
+    &:last-child {
+      padding-right: 0;
+    }
+  }
+}
+
+.column-header__title {
+  display: inline-block;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+  flex: 1;
+}
+
+.column-header__icon {
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.empty-column-indicator,
+.error-column {
+  color: lighten($ui-base-color, 20%);
+  background: $ui-base-color;
+  text-align: center;
+  padding: 20px;
+  font-size: 15px;
+  font-weight: 400;
+  cursor: default;
+  display: flex;
+  flex: 1 1 auto;
+  align-items: center;
+  justify-content: center;
+  @supports(display: grid) { // hack to fix Chrome <57
+    contain: strict;
+  }
+
+  a {
+    color: $ui-highlight-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.error-column {
+  flex-direction: column;
+}
+
+// more fixes for the navbar-under mode
+@mixin fix-margins-for-navbar-under {
+  .tabs-bar {
+    margin-top: 0 !important;
+    margin-bottom: -6px !important;
+  }
+}
+
+.single-column.navbar-under {
+  @include fix-margins-for-navbar-under;
+}
+
+.auto-columns.navbar-under {
+  @media screen and (max-width: 360px) {
+    @include fix-margins-for-navbar-under;
+  }
+}
+
+.auto-columns.navbar-under .react-swipeable-view-container .columns-area,
+.single-column.navbar-under .react-swipeable-view-container .columns-area {
+  @media screen and (max-width: 360px) {
+    height: 100% !important;
+  }
+}
+
+.column-inline-form {
+  padding: 7px 15px;
+  padding-right: 5px;
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  background: lighten($ui-base-color, 4%);
+
+  label {
+    flex: 1 1 auto;
+
+    input {
+      width: 100%;
+      margin-bottom: 6px;
+
+      &:focus {
+        outline: 0;
+      }
+    }
+  }
+
+  .icon-button {
+    flex: 0 0 auto;
+    margin-left: 5px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
new file mode 100644
index 000000000..7112400f4
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -0,0 +1,445 @@
+.composer {
+  padding: 10px;
+}
+
+.composer--spoiler {
+  input {
+    display: block;
+    box-sizing: border-box;
+    margin: 0;
+    border: none;
+    border-radius: 4px;
+    padding: 10px;
+    width: 100%;
+    outline: 0;
+    color: $ui-base-color;
+    background: $simple-background-color;
+    font-size: 14px;
+    font-family: inherit;
+    resize: vertical;
+
+    &:focus { outline: 0 }
+    @include single-column('screen and (max-width: 630px)') { font-size: 16px }
+  }
+}
+
+.composer--warning {
+  color: darken($ui-secondary-color, 65%);
+  margin-bottom: 15px;
+  background: $ui-primary-color;
+  box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
+  padding: 8px 10px;
+  border-radius: 4px;
+  font-size: 13px;
+  font-weight: 400;
+
+  a {
+    color: darken($ui-primary-color, 33%);
+    font-weight: 500;
+    text-decoration: underline;
+
+    &:active,
+    &:focus,
+    &:hover { text-decoration: none }
+  }
+}
+
+.composer--reply {
+  margin: 0 0 -2px;
+  border-radius: 4px 4px 0 0;
+  padding: 10px;
+  background: $ui-primary-color;
+
+  & > header {
+    margin-bottom: 5px;
+    overflow: hidden;
+
+    & > .account.small { color: $ui-base-color }
+
+    & > .cancel {
+      float: right;
+      line-height: 24px;
+    }
+  }
+
+  & > .content {
+    position: relative;
+    margin: 10px 0;
+    padding: 0 12px;
+    font-size: 14px;
+    line-height: 20px;
+    color: $ui-base-color;
+    word-wrap: break-word;
+    font-weight: 400;
+    overflow: visible;
+    white-space: pre-wrap;
+    padding-top: 5px;
+
+    p {
+      margin-bottom: 20px;
+
+      &:last-child { margin-bottom: 0 }
+    }
+
+    a {
+      color: lighten($ui-base-color, 20%);
+      text-decoration: none;
+
+      &:hover { text-decoration: underline }
+
+      &.mention {
+        &:hover {
+          text-decoration: none;
+
+          span { text-decoration: underline }
+        }
+      }
+    }
+  }
+
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -5px 0 0;
+  }
+}
+
+.emoji-picker-dropdown {
+  position: absolute;
+  right: 5px;
+  top: 5px;
+
+  ::-webkit-scrollbar-track:hover,
+  ::-webkit-scrollbar-track:active {
+    background-color: rgba($base-overlay-background, 0.3);
+  }
+}
+
+.composer--textarea {
+  position: relative;
+
+  & > label {
+    .textarea {
+      display: block;
+      box-sizing: border-box;
+      margin: 0;
+      border: none;
+      border-radius: 4px 4px 0 0;
+      padding: 10px 32px 0 10px;
+      width: 100%;
+      min-height: 100px;
+      outline: 0;
+      color: $ui-base-color;
+      background: $simple-background-color;
+      font-size: 14px;
+      font-family: inherit;
+      resize: none;
+
+      &:disabled { background: $ui-secondary-color }
+      &:focus { outline: 0 }
+      @include single-column('screen and (max-width: 630px)') { font-size: 16px }
+
+      @include limited-single-column('screen and (max-width: 600px)') {
+        height: 100px !important; // prevent auto-resize textarea
+        resize: vertical;
+      }
+    }
+  }
+}
+
+.composer--textarea--icons {
+  display: block;
+  position: absolute;
+  top: 29px;
+  right: 5px;
+  bottom: 5px;
+  overflow: hidden;
+
+  & > .textarea_icon {
+    display: block;
+    margin: 2px 0 0 2px;
+    width: 24px;
+    height: 24px;
+    color: darken($ui-primary-color, 24%);
+    font-size: 18px;
+    line-height: 24px;
+    text-align: center;
+    opacity: .8;
+  }
+}
+
+.composer--textarea--suggestions {
+  display: block;
+  position: absolute;
+  box-sizing: border-box;
+  top: 100%;
+  border-radius: 0 0 4px 4px;
+  padding: 6px;
+  width: 100%;
+  color: $ui-base-color;
+  background: $ui-secondary-color;
+  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+  font-size: 14px;
+  z-index: 99;
+
+  &[hidden] { display: none }
+}
+
+.composer--textarea--suggestions--item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-start;
+  border-radius: 4px;
+  padding: 10px;
+  font-size: 14px;
+  line-height: 18px;
+  overflow: hidden;
+  cursor: pointer;
+
+  &:hover,
+  &:focus,
+  &:active,
+  &.selected { background: darken($ui-secondary-color, 10%) }
+
+  & > .emoji {
+    img {
+      display: block;
+      float: left;
+      margin-right: 8px;
+      width: 18px;
+      height: 18px;
+    }
+  }
+
+  & > .account.small {
+    .display-name {
+      & > span { color: lighten($ui-base-color, 36%) }
+    }
+  }
+}
+
+.composer--upload_form {
+  padding: 5px;
+  color: $ui-base-color;
+  background: $simple-background-color;
+  font-size: 14px;
+
+  & > .content {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    font-family: inherit;
+    overflow: hidden;
+  }
+}
+
+.composer--upload_form--item {
+  flex: 1 1 0;
+  margin: 5px;
+  min-width: 40%;
+
+  & > div {
+    position: relative;
+    border-radius: 4px;
+    height: 100px;
+    width: 100%;
+    background-position: center;
+    background-size: cover;
+    background-repeat: no-repeat;
+
+    input {
+      display: block;
+      position: absolute;
+      box-sizing: border-box;
+      bottom: 0;
+      left: 0;
+      margin: 0;
+      border: 0;
+      padding: 10px;
+      width: 100%;
+      color: $ui-secondary-color;
+      background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+      font-size: 14px;
+      font-family: inherit;
+      font-weight: 500;
+      opacity: 0;
+      z-index: 2;
+      transition: opacity .1s ease;
+
+      &:focus { color: $white }
+
+      &::placeholder {
+        opacity: 0.54;
+        color: $ui-secondary-color;
+      }
+    }
+
+    & > .close { mix-blend-mode: difference }
+  }
+
+  &.active {
+    & > div {
+      input { opacity: 1 }
+    }
+  }
+}
+
+.composer--upload_form--progress {
+  display: flex;
+  padding: 10px;
+  color: $ui-base-lighter-color;
+  overflow: hidden;
+
+  & > .fa {
+    font-size: 34px;
+    margin-right: 10px;
+  }
+
+  & > .message {
+    flex: 1 1 auto;
+
+    & > span {
+      display: block;
+      font-size: 12px;
+      font-weight: 500;
+      text-transform: uppercase;
+    }
+
+    & > .backdrop {
+      position: relative;
+      margin-top: 5px;
+      border-radius: 6px;
+      width: 100%;
+      height: 6px;
+      background: $ui-base-lighter-color;
+
+      & > .tracker {
+        position: absolute;
+        top: 0;
+        left: 0;
+        height: 6px;
+        border-radius: 6px;
+        background: $ui-highlight-color;
+      }
+    }
+  }
+}
+
+.composer--options {
+  padding: 10px;
+  background: darken($simple-background-color, 8%);
+  box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
+  border-radius: 0 0 4px 4px;
+  height: 27px;
+
+  & > * {
+    display: inline-block;
+    box-sizing: content-box;
+    padding: 0 3px;
+    height: 27px;
+    line-height: 27px;
+    vertical-align: bottom;
+  }
+
+  & > hr {
+    display: inline-block;
+    margin: 0 3px;
+    border-width: 0 0 0 1px;
+    border-style: none none none solid;
+    border-color: transparent transparent transparent darken($simple-background-color, 24%);
+    padding: 0;
+    width: 0;
+    height: 27px;
+    background: transparent;
+  }
+}
+
+.composer--options--dropdown {
+  &.open {
+    & > .value {
+      border-radius: 4px 4px 0 0;
+      box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+      color: $primary-text-color;
+      background: $ui-highlight-color;
+      transition: none;
+    }
+  }
+}
+
+.composer--options--dropdown--content {
+  position: absolute;
+  border-radius: 4px;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  background: $simple-background-color;
+  overflow: hidden;
+  transform-origin: 50% 0;
+}
+
+.composer--options--dropdown--content--item {
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  color: $ui-base-color;
+  cursor: pointer;
+
+  & > .content {
+    flex: 1 1 auto;
+    color: darken($ui-primary-color, 24%);
+
+    &:not(:first-child) { margin-left: 10px }
+
+    strong {
+      display: block;
+      color: $ui-base-color;
+      font-weight: 500;
+    }
+  }
+
+  &:hover,
+  &.active {
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+
+    & > .content {
+      color: $primary-text-color;
+
+      strong { color: $primary-text-color }
+    }
+  }
+
+  &.active:hover { background: lighten($ui-highlight-color, 4%) }
+}
+
+.composer--publisher {
+  padding-top: 10px;
+  text-align: right;
+  white-space: nowrap;
+  overflow: hidden;
+
+  & > .count {
+    display: inline-block;
+    margin: 0 16px 0 8px;
+    font-size: 16px;
+    line-height: 36px;
+  }
+
+  & > .primary {
+    display: inline-block;
+    margin: 0;
+    padding: 0 10px;
+    text-align: center;
+  }
+
+  & > .side_arm {
+    display: inline-block;
+    margin: 0 2px 0 0;
+    padding: 0;
+    width: 36px;
+    text-align: center;
+  }
+
+  &.over {
+    & > .count { color: $warning-red }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/doodle.scss b/app/javascript/flavours/glitch/styles/components/doodle.scss
new file mode 100644
index 000000000..a4a1cfc84
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/doodle.scss
@@ -0,0 +1,86 @@
+$doodleBg: #d9e1e8;
+.doodle-modal {
+  @extend .boost-modal;
+  width: unset;
+}
+
+.doodle-modal__container {
+  background: $doodleBg;
+  text-align: center;
+  line-height: 0; // remove weird gap under canvas
+  canvas {
+    border: 5px solid $doodleBg;
+  }
+}
+
+.doodle-modal__action-bar {
+  @extend .boost-modal__action-bar;
+
+  .filler {
+    flex-grow: 1;
+    margin: 0;
+    padding: 0;
+  }
+
+  .doodle-toolbar {
+    line-height: 1;
+
+    display: flex;
+    flex-direction: column;
+    flex-grow: 0;
+    justify-content: space-around;
+
+    &.with-inputs {
+      label {
+        display: inline-block;
+        width: 70px;
+        text-align: right;
+        margin-right: 2px;
+      }
+
+      input[type="number"],input[type="text"] {
+        width: 40px;
+      }
+      span.val {
+        display: inline-block;
+        text-align: left;
+        width: 50px;
+      }
+    }
+  }
+
+  .doodle-palette {
+    padding-right: 0 !important;
+    border: 1px solid black;
+    line-height: .2rem;
+    flex-grow: 0;
+    background: white;
+
+    button {
+      appearance: none;
+      width: 1rem;
+      height: 1rem;
+      margin: 0; padding: 0;
+      text-align: center;
+      color: black;
+      text-shadow: 0 0 1px white;
+      cursor: pointer;
+      box-shadow: inset 0 0 1px rgba(white, .5);
+      border: 1px solid black;
+      outline-offset:-1px;
+
+      &.foreground {
+        outline: 1px dashed white;
+      }
+
+      &.background {
+        outline: 1px dashed red;
+      }
+
+      &.foreground.background {
+        outline: 1px dashed red;
+        border-color: white;
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
new file mode 100644
index 000000000..11ac0a37f
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -0,0 +1,333 @@
+.drawer {
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+  padding: 10px 5px;
+  width: 300px;
+  flex: none;
+  contain: strict;
+
+  &:first-child {
+    padding-left: 10px;
+  }
+
+  &:last-child {
+    padding-right: 10px;
+  }
+
+  @include single-column('screen and (max-width: 630px)') { flex: auto }
+
+  @include limited-single-column('screen and (max-width: 630px)') {
+    &, &:first-child, &:last-child { padding: 0 }
+  }
+
+  .wide & {
+    min-width: 300px;
+    max-width: 400px;
+    flex: 1 1 200px;
+  }
+
+  @include single-column('screen and (max-width: 630px)') {
+    :root & {  //  Overrides `.wide` for single-column view
+      flex: auto;
+      width: 100%;
+      min-width: 0;
+      max-width: none;
+      padding: 0;
+    }
+  }
+
+  .react-swipeable-view-container & { height: 100% }
+
+  & > .contents {
+    display: flex;
+    position: relative;
+    flex-direction: column;
+    padding: 0;
+    width: 100%;
+    height: 100%;
+    background: lighten($ui-base-color, 13%);
+    overflow-x: hidden;
+    overflow-y: auto;
+    contain: strict;
+
+    & > .mastodon {
+      flex: 1;
+      border: none;
+      cursor: inherit;
+    }
+  }
+
+  @for $i from 0 through 3 {
+    &.mbstobon-#{$i} > .contents {
+      @if $i == 3 {
+        background: url('~flavours/glitch/images/wave-drawer.png') no-repeat bottom / 100% auto, lighten($ui-base-color, 13%);
+      } @else {
+        background: url('~flavours/glitch/images/wave-drawer-glitched.png') no-repeat bottom / 100% auto, lighten($ui-base-color, 13%);
+      }
+
+      & > .mastodon {
+        background: url("~flavours/glitch/images/mbstobon-ui-#{$i}.png") no-repeat left bottom / contain;
+
+        @if $i != 3 {
+          filter: contrast(50%) brightness(50%);
+        }
+      }
+    }
+  }
+}
+
+.drawer--header {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 10px;
+  flex: none;
+  background: lighten($ui-base-color, 8%);
+  font-size: 16px;
+
+  & > * {
+    display: block;
+    box-sizing: border-box;
+    border-bottom: 2px solid transparent;
+    padding: 15px 5px 13px;
+    height: 48px;
+    flex: 1 1 auto;
+    color: $ui-primary-color;
+    text-align: center;
+    text-decoration: none;
+    cursor: pointer;
+  }
+
+  a {
+    transition: background 100ms ease-in;
+
+    &:focus,
+    &:hover {
+      outline: none;
+      background: lighten($ui-base-color, 3%);
+      transition: background 200ms ease-out;
+    }
+  }
+}
+
+.drawer--search {
+  position: relative;
+  margin-bottom: 10px;
+  flex: none;
+
+  @include limited-single-column('screen and (max-width: 360px)') { margin-bottom: 0 }
+  @include single-column('screen and (max-width: 630px)') { font-size: 16px }
+
+  input {
+    display: block;
+    box-sizing: border-box;
+    margin: 0;
+    border: none;
+    padding: 10px 30px 10px 10px;
+    width: 100%;
+    height: 36px;
+    outline: 0;
+    color: $ui-primary-color;
+    background: $ui-base-color;
+    font-size: 14px;
+    font-family: inherit;
+    line-height: 16px;
+
+    &:focus {
+      outline: 0;
+      background: lighten($ui-base-color, 4%);
+    }
+  }
+
+  & > .icon {
+    display: block;
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    width: 18px;
+    height: 18px;
+    color: $ui-secondary-color;
+    font-size: 18px;
+    line-height: 18px;
+    z-index: 2;
+
+    .fa {
+      display: inline-block;
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      opacity: 0;
+      cursor: default;
+      pointer-events: none;
+      transition: all 100ms linear;
+    }
+
+    .fa-search {
+      opacity: 0.3;
+      transform: rotate(0deg);
+    }
+
+    .fa-times-circle {
+      transform: rotate(-90deg);
+      cursor: pointer;
+
+      &:hover { color: $primary-text-color }
+    }
+  }
+
+  &.active {
+    & > .icon {
+      .fa-search {
+        opacity: 0;
+        transform: rotate(90deg);
+      }
+
+      .fa-times-circle {
+        opacity: 0.3;
+        pointer-events: auto;
+        transform: rotate(0deg);
+      }
+    }
+  }
+}
+
+.drawer--search--popout {
+  box-sizing: border-box;
+  margin-top: 10px;
+  border-radius: 4px;
+  padding: 10px 14px 14px 14px;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  color: $ui-primary-color;
+  background: $simple-background-color;
+
+  h4 {
+    margin-bottom: 10px;
+    color: $ui-primary-color;
+    font-size: 13px;
+    font-weight: 500;
+    text-transform: uppercase;
+  }
+
+  ul { margin-bottom: 10px }
+  li { padding: 4px 0 }
+
+  em {
+    color: $ui-base-color;
+    font-weight: 500;
+  }
+}
+
+.drawer--account {
+  padding: 10px;
+  color: $ui-primary-color;
+
+  & > a {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  & > .avatar {
+    float: left;
+    margin-right: 10px;
+  }
+
+  & > .acct {
+    display: block;
+    color: $primary-text-color;
+    font-weight: 500;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.drawer--results {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 0;
+  background: $ui-base-color;
+  overflow-x: hidden;
+  overflow-y: auto;
+  contain: strict;
+
+  & > header {
+    border-bottom: 1px solid darken($ui-base-color, 4%);
+    padding: 15px 10px;
+    color: $ui-base-lighter-color;
+    background: lighten($ui-base-color, 2%);
+    font-size: 14px;
+    font-weight: 500;
+  }
+
+  & > section {
+    background: $ui-base-color;
+
+    & > .hashtag {
+      display: block;
+      padding: 10px;
+      color: $ui-secondary-color;
+      text-decoration: none;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($ui-secondary-color, 4%);
+        text-decoration: underline;
+      }
+    }
+  }
+}
+
+.drawer__pager {
+  box-sizing: border-box;
+  padding: 0;
+  flex-grow: 1;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+}
+
+.drawer__inner {
+  position: absolute;
+  top: 0;
+  left: 0;
+  background: lighten($ui-base-color, 13%) url('~images/wave-drawer.png') no-repeat bottom / 100% auto;
+  box-sizing: border-box;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  overflow-y: auto;
+  width: 100%;
+  height: 100%;
+
+  &.darker {
+    background: $ui-base-color;
+  }
+
+  > .mastodon {
+    background: url('~images/mastodon-ui.png') no-repeat left bottom / contain;
+    flex: 1;
+  }
+}
+
+.pseudo-drawer {
+  background: lighten($ui-base-color, 13%);
+  font-size: 13px;
+  text-align: left;
+}
+
+.drawer__backdrop {
+  cursor: pointer;
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba($base-overlay-background, 0.5);
+}
diff --git a/app/javascript/flavours/glitch/styles/components/emoji.scss b/app/javascript/flavours/glitch/styles/components/emoji.scss
new file mode 100644
index 000000000..c91964810
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/emoji.scss
@@ -0,0 +1,105 @@
+.emojione {
+  display: inline-block;
+  font-size: inherit;
+  vertical-align: middle;
+  object-fit: contain;
+  margin: -.2ex .15em .2ex;
+  width: 16px;
+  height: 16px;
+
+  img {
+    width: auto;
+  }
+}
+
+.emoji-picker-dropdown__menu {
+  background: $simple-background-color;
+  position: absolute;
+  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+  border-radius: 4px;
+  margin-top: 5px;
+
+  .emoji-mart-scroll {
+    transition: opacity 200ms ease;
+  }
+
+  &.selecting .emoji-mart-scroll {
+    opacity: 0.5;
+  }
+}
+
+.emoji-picker-dropdown__modifiers {
+  position: absolute;
+  top: 60px;
+  right: 11px;
+  cursor: pointer;
+}
+
+.emoji-picker-dropdown__modifiers__menu {
+  position: absolute;
+  z-index: 4;
+  top: -4px;
+  left: -8px;
+  background: $simple-background-color;
+  border-radius: 4px;
+  box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+  overflow: hidden;
+
+  button {
+    display: block;
+    cursor: pointer;
+    border: 0;
+    padding: 4px 8px;
+    background: transparent;
+
+    &:hover,
+    &:focus,
+    &:active {
+      background: rgba($ui-secondary-color, 0.4);
+    }
+  }
+
+  .emoji-mart-emoji {
+    height: 22px;
+  }
+}
+
+.emoji-mart-emoji {
+  span {
+    background-repeat: no-repeat;
+  }
+}
+
+.emoji-button {
+  display: block;
+  font-size: 24px;
+  line-height: 24px;
+  margin-left: 2px;
+  width: 24px;
+  outline: 0;
+  cursor: pointer;
+
+  &:active,
+  &:focus {
+    outline: 0 !important;
+  }
+
+  img {
+    filter: grayscale(100%);
+    opacity: 0.8;
+    display: block;
+    margin: 0;
+    width: 22px;
+    height: 22px;
+    margin-top: 2px;
+  }
+
+  &:hover,
+  &:active,
+  &:focus {
+    img {
+      opacity: 1;
+      filter: none;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/emoji_picker.scss b/app/javascript/flavours/glitch/styles/components/emoji_picker.scss
new file mode 100644
index 000000000..4161cc045
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/emoji_picker.scss
@@ -0,0 +1,204 @@
+.emoji-mart {
+  &,
+  * {
+    box-sizing: border-box;
+    line-height: 1.15;
+  }
+
+  font-size: 13px;
+  display: inline-block;
+  color: $ui-base-color;
+
+  .emoji-mart-emoji {
+    padding: 6px;
+  }
+}
+
+.emoji-mart-bar {
+  border: 0 solid darken($ui-secondary-color, 8%);
+
+  &:first-child {
+    border-bottom-width: 1px;
+    border-top-left-radius: 5px;
+    border-top-right-radius: 5px;
+    background: $ui-secondary-color;
+  }
+
+  &:last-child {
+    border-top-width: 1px;
+    border-bottom-left-radius: 5px;
+    border-bottom-right-radius: 5px;
+    display: none;
+  }
+}
+
+.emoji-mart-anchors {
+  display: flex;
+  justify-content: space-between;
+  padding: 0 6px;
+  color: $ui-primary-color;
+  line-height: 0;
+}
+
+.emoji-mart-anchor {
+  position: relative;
+  flex: 1;
+  text-align: center;
+  padding: 12px 4px;
+  overflow: hidden;
+  transition: color .1s ease-out;
+  cursor: pointer;
+
+  &:hover {
+    color: darken($ui-primary-color, 4%);
+  }
+}
+
+.emoji-mart-anchor-selected {
+  color: darken($ui-highlight-color, 3%);
+
+  &:hover {
+    color: darken($ui-highlight-color, 3%);
+  }
+
+  .emoji-mart-anchor-bar {
+    bottom: 0;
+  }
+}
+
+.emoji-mart-anchor-bar {
+  position: absolute;
+  bottom: -3px;
+  left: 0;
+  width: 100%;
+  height: 3px;
+  background-color: darken($ui-highlight-color, 3%);
+}
+
+.emoji-mart-anchors {
+  i {
+    display: inline-block;
+    width: 100%;
+    max-width: 22px;
+  }
+
+  svg {
+    fill: currentColor;
+    max-height: 18px;
+  }
+}
+
+.emoji-mart-scroll {
+  overflow-y: scroll;
+  height: 270px;
+  max-height: 35vh;
+  padding: 0 6px 6px;
+  background: $simple-background-color;
+  will-change: transform;
+
+  &::-webkit-scrollbar-track:hover,
+  &::-webkit-scrollbar-track:active {
+    background-color: rgba($base-overlay-background, 0.3);
+  }
+}
+
+.emoji-mart-search {
+  padding: 10px;
+  padding-right: 45px;
+  background: $simple-background-color;
+
+  input {
+    font-size: 14px;
+    font-weight: 400;
+    padding: 7px 9px;
+    font-family: inherit;
+    display: block;
+    width: 100%;
+    background: rgba($ui-secondary-color, 0.3);
+    color: $ui-primary-color;
+    border: 1px solid $ui-secondary-color;
+    border-radius: 4px;
+
+    &::-moz-focus-inner {
+      border: 0;
+    }
+
+    &::-moz-focus-inner,
+    &:focus,
+    &:active {
+      outline: 0 !important;
+    }
+  }
+}
+
+.emoji-mart-category .emoji-mart-emoji {
+  cursor: pointer;
+
+  span {
+    z-index: 1;
+    position: relative;
+    text-align: center;
+  }
+
+  &:hover::before {
+    z-index: 0;
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: rgba($ui-secondary-color, 0.7);
+    border-radius: 100%;
+  }
+}
+
+.emoji-mart-category-label {
+  z-index: 2;
+  position: relative;
+  position: -webkit-sticky;
+  position: sticky;
+  top: 0;
+
+  span {
+    display: block;
+    width: 100%;
+    font-weight: 500;
+    padding: 5px 6px;
+    background: $simple-background-color;
+  }
+}
+
+.emoji-mart-emoji {
+  position: relative;
+  display: inline-block;
+  font-size: 0;
+
+  span {
+    width: 22px;
+    height: 22px;
+  }
+}
+
+.emoji-mart-no-results {
+  font-size: 14px;
+  text-align: center;
+  padding-top: 70px;
+  color: $ui-primary-color;
+
+  .emoji-mart-category-label {
+    display: none;
+  }
+
+  .emoji-mart-no-results-label {
+    margin-top: .2em;
+  }
+
+  .emoji-mart-emoji:hover::before {
+    content: none;
+  }
+}
+
+.emoji-mart-preview {
+  display: none;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
new file mode 100644
index 000000000..8f051f7a0
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -0,0 +1,1173 @@
+.app-body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: -ms-autohiding-scrollbar;
+}
+
+.button {
+  background-color: darken($ui-highlight-color, 3%);
+  border: 10px none;
+  border-radius: 4px;
+  box-sizing: border-box;
+  color: $primary-text-color;
+  cursor: pointer;
+  display: inline-block;
+  font-family: inherit;
+  font-size: 14px;
+  font-weight: 500;
+  height: 36px;
+  letter-spacing: 0;
+  line-height: 36px;
+  overflow: hidden;
+  padding: 0 16px;
+  position: relative;
+  text-align: center;
+  text-transform: uppercase;
+  text-decoration: none;
+  text-overflow: ellipsis;
+  transition: all 100ms ease-in;
+  white-space: nowrap;
+  width: auto;
+
+  &:active,
+  &:focus,
+  &:hover {
+    background-color: lighten($ui-highlight-color, 7%);
+    transition: all 200ms ease-out;
+  }
+
+  &:disabled {
+    background-color: $ui-primary-color;
+    cursor: default;
+  }
+
+  &.button-alternative {
+    font-size: 16px;
+    line-height: 36px;
+    height: auto;
+    color: $ui-base-color;
+    background: $ui-primary-color;
+    text-transform: none;
+    padding: 4px 16px;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-primary-color, 4%);
+    }
+  }
+
+  &.button-secondary {
+    font-size: 16px;
+    line-height: 36px;
+    height: auto;
+    color: $ui-primary-color;
+    text-transform: none;
+    background: transparent;
+    padding: 3px 15px;
+    border-radius: 4px;
+    border: 1px solid $ui-primary-color;
+
+    &:active,
+    &:focus,
+    &:hover {
+      border-color: lighten($ui-primary-color, 4%);
+      color: lighten($ui-primary-color, 4%);
+    }
+  }
+
+  &.button--block {
+    display: block;
+    width: 100%;
+  }
+}
+
+.icon-button {
+  display: inline-block;
+  padding: 0;
+  color: $ui-base-lighter-color;
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  transition: color 100ms ease-in;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: lighten($ui-base-color, 33%);
+    transition: color 200ms ease-out;
+  }
+
+  &.disabled {
+    color: lighten($ui-base-color, 13%);
+    cursor: default;
+  }
+
+  &.active {
+    color: $ui-highlight-color;
+  }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus,
+  &:active {
+    outline: 0 !important;
+  }
+
+  &.inverted {
+    color: lighten($ui-base-color, 33%);
+
+    &:hover,
+    &:active,
+    &:focus {
+      color: $ui-base-lighter-color;
+    }
+
+    &.disabled {
+      color: $ui-primary-color;
+    }
+
+    &.active {
+      color: $ui-highlight-color;
+
+      &.disabled {
+        color: lighten($ui-highlight-color, 13%);
+      }
+    }
+  }
+
+  &.overlayed {
+    box-sizing: content-box;
+    background: rgba($base-overlay-background, 0.6);
+    color: rgba($primary-text-color, 0.7);
+    border-radius: 4px;
+    padding: 2px;
+
+    &:hover {
+      background: rgba($base-overlay-background, 0.9);
+    }
+  }
+}
+
+.text-icon-button {
+  color: lighten($ui-base-color, 33%);
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  font-weight: 600;
+  font-size: 11px;
+  padding: 0 3px;
+  line-height: 27px;
+  outline: 0;
+  transition: color 100ms ease-in;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: $ui-base-lighter-color;
+    transition: color 200ms ease-out;
+  }
+
+  &.disabled {
+    color: lighten($ui-base-color, 13%);
+    cursor: default;
+  }
+
+  &.active {
+    color: $ui-highlight-color;
+  }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus,
+  &:active {
+    outline: 0 !important;
+  }
+}
+
+.dropdown-menu {
+  position: absolute;
+  transform-origin: 50% 0;
+}
+
+.dropdown--active .icon-button {
+  color: $ui-highlight-color;
+}
+
+.dropdown--active::after {
+  @media screen and (min-width: 631px) {
+    content: "";
+    display: block;
+    position: absolute;
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 0 4.5px 7.8px;
+    border-color: transparent transparent $ui-secondary-color;
+    bottom: 8px;
+    right: 104px;
+  }
+}
+
+.invisible {
+  font-size: 0;
+  line-height: 0;
+  display: inline-block;
+  width: 0;
+  height: 0;
+  position: absolute;
+
+  img,
+  svg {
+    margin: 0 !important;
+    border: 0 !important;
+    padding: 0 !important;
+    width: 0 !important;
+    height: 0 !important;
+  }
+}
+
+.ellipsis {
+  &::after {
+    content: "…";
+  }
+}
+
+.lightbox .icon-button {
+  color: $ui-base-color;
+}
+
+.notification__favourite-icon-wrapper {
+  left: 0;
+  position: absolute;
+
+  .fa.star-icon {
+    color: $gold-star;
+  }
+}
+
+.star-icon.active {
+  color: $gold-star;
+}
+
+.notification__display-name {
+  color: inherit;
+  font-weight: 500;
+  text-decoration: none;
+
+  &:hover {
+    color: $primary-text-color;
+    text-decoration: underline;
+  }
+}
+
+.display-name {
+  display: block;
+  padding: 6px 0;
+  max-width: 100%;
+  height: 36px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+
+  strong {
+    display: block;
+    height: 18px;
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 18px;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+  }
+
+  span {
+    display: block;
+    height: 18px;
+    font-size: 15px;
+    line-height: 18px;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+  }
+
+  &:hover {
+    strong {
+      text-decoration: underline;
+    }
+  }
+
+  &.inline {
+    padding: 0;
+    height: 18px;
+    font-size: 15px;
+    line-height: 18px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+
+    strong {
+      display: inline;
+      height: auto;
+      font-size: inherit;
+      line-height: inherit;
+    }
+
+    span {
+      display: inline;
+      height: auto;
+      font-size: inherit;
+      line-height: inherit;
+    }
+  }
+}
+
+.display-name__html {
+  font-weight: 500;
+}
+
+.display-name__account {
+  font-size: 14px;
+}
+
+.image-loader {
+  position: relative;
+
+  &.image-loader--loading {
+    .image-loader__preview-canvas {
+      filter: blur(2px);
+    }
+  }
+
+  .image-loader__img {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    max-width: 100%;
+    max-height: 100%;
+    background-image: none;
+  }
+
+  &.image-loader--amorphous {
+    position: static;
+
+    .image-loader__preview-canvas {
+      display: none;
+    }
+
+    .image-loader__img {
+      position: static;
+      width: auto;
+      height: auto;
+    }
+  }
+}
+
+.dropdown {
+  display: inline-block;
+}
+
+.dropdown__content {
+  display: none;
+  position: absolute;
+}
+
+.dropdown-menu__separator {
+  border-bottom: 1px solid darken($ui-secondary-color, 8%);
+  margin: 5px 7px 6px;
+  height: 0;
+}
+
+.dropdown-menu {
+  background: $ui-secondary-color;
+  padding: 4px 0;
+  border-radius: 4px;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+  ul {
+    list-style: none;
+  }
+}
+
+.dropdown-menu__arrow {
+  position: absolute;
+  width: 0;
+  height: 0;
+  border: 0 solid transparent;
+
+  &.left {
+    right: -5px;
+    margin-top: -5px;
+    border-width: 5px 0 5px 5px;
+    border-left-color: $ui-secondary-color;
+  }
+
+  &.top {
+    bottom: -5px;
+    margin-left: -13px;
+    border-width: 5px 7px 0;
+    border-top-color: $ui-secondary-color;
+  }
+
+  &.bottom {
+    top: -5px;
+    margin-left: -13px;
+    border-width: 0 7px 5px;
+    border-bottom-color: $ui-secondary-color;
+  }
+
+  &.right {
+    left: -5px;
+    margin-top: -5px;
+    border-width: 5px 5px 5px 0;
+    border-right-color: $ui-secondary-color;
+  }
+}
+
+.dropdown-menu__item {
+  a {
+    font-size: 13px;
+    line-height: 18px;
+    display: block;
+    padding: 4px 14px;
+    box-sizing: border-box;
+    text-decoration: none;
+    background: $ui-secondary-color;
+    color: $ui-base-color;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:focus,
+    &:hover,
+    &:active {
+      background: $ui-highlight-color;
+      color: $ui-secondary-color;
+      outline: 0;
+    }
+  }
+}
+
+.dropdown--active .dropdown__content {
+  display: block;
+  line-height: 18px;
+  max-width: 311px;
+  right: 0;
+  text-align: left;
+  z-index: 9999;
+
+  & > ul {
+    list-style: none;
+    background: $ui-secondary-color;
+    padding: 4px 0;
+    border-radius: 4px;
+    box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
+    min-width: 140px;
+    position: relative;
+  }
+
+  &.dropdown__right {
+    right: 0;
+  }
+
+  &.dropdown__left {
+    & > ul {
+      left: -98px;
+    }
+  }
+
+  & > ul > li > a {
+    font-size: 13px;
+    line-height: 18px;
+    display: block;
+    padding: 4px 14px;
+    box-sizing: border-box;
+    text-decoration: none;
+    background: $ui-secondary-color;
+    color: $ui-base-color;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:focus {
+      outline: 0;
+    }
+
+    &:hover {
+      background: $ui-highlight-color;
+      color: $ui-secondary-color;
+    }
+  }
+}
+
+.dropdown__icon {
+  vertical-align: middle;
+}
+
+.static-content {
+  padding: 10px;
+  padding-top: 20px;
+  color: $ui-base-lighter-color;
+
+  h1 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 40px;
+    text-align: center;
+  }
+
+  p {
+    font-size: 13px;
+    margin-bottom: 20px;
+  }
+}
+
+.tabs-bar {
+  display: flex;
+  background: lighten($ui-base-color, 8%);
+  flex: 0 0 auto;
+  overflow-y: auto;
+}
+
+.tabs-bar__link {
+  display: block;
+  flex: 1 1 auto;
+  padding: 15px 10px;
+  color: $primary-text-color;
+  text-decoration: none;
+  text-align: center;
+  font-size: 14px;
+  font-weight: 500;
+  border-bottom: 2px solid lighten($ui-base-color, 8%);
+  transition: all 200ms linear;
+
+  .fa {
+    font-weight: 400;
+    font-size: 16px;
+  }
+
+  &.active {
+    border-bottom: 2px solid $ui-highlight-color;
+    color: $ui-highlight-color;
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    @include multi-columns('screen and (min-width: 631px)') {
+      background: lighten($ui-base-color, 14%);
+      transition: all 100ms linear;
+    }
+  }
+
+  span {
+    margin-left: 5px;
+    display: none;
+  }
+}
+
+@include limited-single-column('screen and (max-width: 600px)', $parent: null) {
+  .tabs-bar__link {
+    span {
+      display: inline;
+    }
+  }
+}
+
+@include multi-columns('screen and (min-width: 631px)', $parent: null) {
+  .tabs-bar {
+    display: none;
+  }
+}
+
+.scrollable {
+  overflow-y: scroll;
+  overflow-x: hidden;
+  flex: 1 1 auto;
+  -webkit-overflow-scrolling: touch;
+  will-change: transform; // improves perf in mobile Chrome
+
+  &.optionally-scrollable {
+    overflow-y: auto;
+  }
+
+  @supports(display: grid) { // hack to fix Chrome <57
+    contain: strict;
+  }
+}
+
+.scrollable.fullscreen {
+  @supports(display: grid) { // hack to fix Chrome <57
+    contain: none;
+  }
+}
+
+.react-toggle {
+  display: inline-block;
+  position: relative;
+  cursor: pointer;
+  background-color: transparent;
+  border: 0;
+  padding: 0;
+  user-select: none;
+  -webkit-tap-highlight-color: rgba($base-overlay-background, 0);
+  -webkit-tap-highlight-color: transparent;
+}
+
+.react-toggle-screenreader-only {
+  border: 0;
+  clip: rect(0 0 0 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  width: 1px;
+}
+
+.react-toggle--disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+  transition: opacity 0.25s;
+}
+
+.react-toggle-track {
+  width: 50px;
+  height: 24px;
+  padding: 0;
+  border-radius: 30px;
+  background-color: $ui-base-color;
+  transition: all 0.2s ease;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background-color: darken($ui-base-color, 10%);
+}
+
+.react-toggle--checked .react-toggle-track {
+  background-color: $ui-highlight-color;
+}
+
+.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background-color: lighten($ui-highlight-color, 10%);
+}
+
+.react-toggle-track-check {
+  position: absolute;
+  width: 14px;
+  height: 10px;
+  top: 0;
+  bottom: 0;
+  margin-top: auto;
+  margin-bottom: auto;
+  line-height: 0;
+  left: 8px;
+  opacity: 0;
+  transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-check {
+  opacity: 1;
+  transition: opacity 0.25s ease;
+}
+
+.react-toggle-track-x {
+  position: absolute;
+  width: 10px;
+  height: 10px;
+  top: 0;
+  bottom: 0;
+  margin-top: auto;
+  margin-bottom: auto;
+  line-height: 0;
+  right: 10px;
+  opacity: 1;
+  transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-x {
+  opacity: 0;
+}
+
+.react-toggle-thumb {
+  transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+  position: absolute;
+  top: 1px;
+  left: 1px;
+  width: 22px;
+  height: 22px;
+  border: 1px solid $ui-base-color;
+  border-radius: 50%;
+  background-color: darken($simple-background-color, 2%);
+  box-sizing: border-box;
+  transition: all 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-thumb {
+  left: 27px;
+  border-color: $ui-highlight-color;
+}
+
+.getting-started__wrapper,
+.getting_started {
+  background: $ui-base-color;
+}
+
+.getting-started__wrapper {
+  position: relative;
+  overflow-y: auto;
+}
+
+.getting-started {
+  background: $ui-base-color;
+  flex: 1 0 auto;
+
+  p {
+    color: $ui-secondary-color;
+  }
+
+  a {
+    color: $ui-base-lighter-color;
+  }
+}
+
+.keyboard-shortcuts {
+  padding: 8px 0 0;
+  overflow: hidden;
+
+  thead {
+    position: absolute;
+    left: -9999px;
+  }
+
+  td {
+    padding: 0 10px 8px;
+  }
+
+  kbd {
+    display: inline-block;
+    padding: 3px 5px;
+    background-color: lighten($ui-base-color, 8%);
+    border: 1px solid darken($ui-base-color, 4%);
+  }
+}
+
+.setting-text {
+  color: $ui-primary-color;
+  background: transparent;
+  border: none;
+  border-bottom: 2px solid $ui-primary-color;
+  box-sizing: border-box;
+  display: block;
+  font-family: inherit;
+  margin-bottom: 10px;
+  padding: 7px 0;
+  width: 100%;
+
+  &:focus,
+  &:active {
+    color: $primary-text-color;
+    border-bottom-color: $ui-highlight-color;
+  }
+
+  @include limited-single-column('screen and (max-width: 600px)') {
+    font-size: 16px;
+  }
+
+  &.light {
+    color: $ui-base-color;
+    border-bottom: 2px solid lighten($ui-base-color, 27%);
+
+    &:focus,
+    &:active {
+      color: $ui-base-color;
+      border-bottom-color: $ui-highlight-color;
+    }
+  }
+}
+
+.no-reduce-motion button.icon-button i.fa-retweet {
+  background-position: 0 0;
+  height: 19px;
+  transition: background-position 0.9s steps(10);
+  transition-duration: 0s;
+  vertical-align: middle;
+  width: 22px;
+
+  &::before {
+    display: none !important;
+  }
+}
+
+.no-reduce-motion button.icon-button.active i.fa-retweet {
+  transition-duration: 0.9s;
+  background-position: 0 100%;
+}
+
+.reduce-motion button.icon-button i.fa-retweet {
+  color: $ui-base-lighter-color;
+  transition: color 100ms ease-in;
+}
+
+.reduce-motion button.icon-button.active i.fa-retweet {
+  color: $ui-highlight-color;
+}
+
+.load-more {
+  display: block;
+  color: $ui-base-lighter-color;
+  background-color: transparent;
+  border: 0;
+  font-size: inherit;
+  text-align: center;
+  line-height: inherit;
+  margin: 0;
+  padding: 15px;
+  width: 100%;
+  clear: both;
+
+  &:hover {
+    background: lighten($ui-base-color, 2%);
+  }
+}
+
+.missing-indicator {
+  text-align: center;
+  font-size: 16px;
+  font-weight: 500;
+  color: lighten($ui-base-color, 16%);
+  background: $ui-base-color;
+  cursor: default;
+  display: flex;
+  flex: 1 1 auto;
+  align-items: center;
+  justify-content: center;
+
+  & > div {
+    background: url('~images/mastodon-not-found.png') no-repeat center -50px;
+    padding-top: 210px;
+    width: 100%;
+  }
+}
+
+.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {
+  border-top: 1px solid $ui-base-color;
+}
+
+.notification__dismiss-overlay {
+  overflow: hidden;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: -1px;
+  padding-left: 15px; // space for the box shadow to be visible
+
+  z-index: 999;
+  align-items: center;
+  justify-content: flex-end;
+  cursor: pointer;
+
+  display: flex;
+
+  .wrappy {
+    width: $dismiss-overlay-width;
+    align-self: stretch;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    background: lighten($ui-base-color, 8%);
+    border-left: 1px solid lighten($ui-base-color, 20%);
+    box-shadow: 0 0 5px black;
+    border-bottom: 1px solid $ui-base-color;
+  }
+
+  .ckbox {
+    border: 2px solid $ui-primary-color;
+    border-radius: 2px;
+    width: 30px;
+    height: 30px;
+    font-size: 20px;
+    color: $ui-primary-color;
+    text-shadow: 0 0 5px black;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  &:focus {
+    outline: 0 !important;
+
+    .ckbox {
+      box-shadow: 0 0 1px 1px $ui-highlight-color;
+    }
+  }
+}
+
+.text-btn {
+  display: inline-block;
+  padding: 0;
+  font-family: inherit;
+  font-size: inherit;
+  color: inherit;
+  border: 0;
+  background: transparent;
+  cursor: pointer;
+}
+
+.loading-indicator {
+  color: lighten($ui-base-color, 26%);
+  font-size: 12px;
+  font-weight: 400;
+  text-transform: uppercase;
+  overflow: visible;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+
+  span {
+    display: block;
+    float: left;
+    margin-left: 50%;
+    transform: translateX(-50%);
+    margin: 82px 0 0 50%;
+    white-space: nowrap;
+    animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+  }
+}
+
+.loading-indicator__figure {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 0;
+  height: 0;
+  box-sizing: border-box;
+  border: 0 solid lighten($ui-base-color, 26%);
+  border-radius: 50%;
+  animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+}
+
+@keyframes loader-figure {
+  0% {
+    width: 0;
+    height: 0;
+    background-color: lighten($ui-base-color, 26%);
+  }
+
+  29% {
+    background-color: lighten($ui-base-color, 26%);
+  }
+
+  30% {
+    width: 42px;
+    height: 42px;
+    background-color: transparent;
+    border-width: 21px;
+    opacity: 1;
+  }
+
+  100% {
+    width: 42px;
+    height: 42px;
+    border-width: 0;
+    opacity: 0;
+    background-color: transparent;
+  }
+}
+
+@keyframes loader-label {
+  0% { opacity: 0.25; }
+  30% { opacity: 1; }
+  100% { opacity: 0.25; }
+}
+
+.spoiler-button {
+  display: none;
+  left: 4px;
+  position: absolute;
+  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+  top: 4px;
+  z-index: 100;
+
+  &.spoiler-button--visible {
+    display: block;
+  }
+}
+
+.setting-toggle {
+  display: block;
+  line-height: 24px;
+}
+
+.setting-toggle__label,
+.setting-meta__label {
+  color: $ui-primary-color;
+  display: inline-block;
+  margin-bottom: 14px;
+  margin-left: 8px;
+  vertical-align: middle;
+}
+
+.setting-meta__label {
+  color: $ui-primary-color;
+  float: right;
+}
+
+@keyframes heartbeat {
+  from {
+    transform: scale(1);
+    transform-origin: center center;
+    animation-timing-function: ease-out;
+  }
+
+  10% {
+    transform: scale(0.91);
+    animation-timing-function: ease-in;
+  }
+
+  17% {
+    transform: scale(0.98);
+    animation-timing-function: ease-out;
+  }
+
+  33% {
+    transform: scale(0.87);
+    animation-timing-function: ease-in;
+  }
+
+  45% {
+    transform: scale(1);
+    animation-timing-function: ease-out;
+  }
+}
+
+.pulse-loading {
+  animation: heartbeat 1.5s ease-in-out infinite both;
+}
+
+.upload-area {
+  align-items: center;
+  background: rgba($base-overlay-background, 0.8);
+  display: flex;
+  height: 100%;
+  justify-content: center;
+  left: 0;
+  opacity: 0;
+  position: absolute;
+  top: 0;
+  visibility: hidden;
+  width: 100%;
+  z-index: 2000;
+
+  * {
+    pointer-events: none;
+  }
+}
+
+.upload-area__drop {
+  width: 320px;
+  height: 160px;
+  display: flex;
+  box-sizing: border-box;
+  position: relative;
+  padding: 8px;
+}
+
+.upload-area__background {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: -1;
+  border-radius: 4px;
+  background: $ui-base-color;
+  box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
+}
+
+.upload-area__content {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: $ui-secondary-color;
+  font-size: 18px;
+  font-weight: 500;
+  border: 2px dashed $ui-base-lighter-color;
+  border-radius: 4px;
+}
+
+.dropdown--active .emoji-button img {
+  opacity: 1;
+  filter: none;
+}
+
+.loading-bar {
+  background-color: $ui-highlight-color;
+  height: 3px;
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+
+::-webkit-scrollbar-thumb {
+  border-radius: 0;
+}
+
+noscript {
+  text-align: center;
+
+  img {
+    width: 200px;
+    opacity: 0.5;
+    animation: flicker 4s infinite;
+  }
+
+  div {
+    font-size: 14px;
+    margin: 30px auto;
+    color: $ui-secondary-color;
+    max-width: 400px;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+
+      &:hover {
+        text-decoration: none;
+      }
+    }
+  }
+}
+
+@keyframes flicker {
+  0% { opacity: 1; }
+  30% { opacity: 0.75; }
+  100% { opacity: 1; }
+}
+
+@import 'boost';
+@import 'accounts';
+@import 'status';
+@import 'modal';
+@import 'metadata';
+@import 'composer';
+@import 'columns';
+@import 'search';
+@import 'emoji';
+@import 'doodle';
+@import 'drawer';
+@import 'media';
+@import 'sensitive';
+@import 'lists';
+@import 'emoji_picker';
+@import 'local_settings';
diff --git a/app/javascript/flavours/glitch/styles/components/lists.scss b/app/javascript/flavours/glitch/styles/components/lists.scss
new file mode 100644
index 000000000..de0783b6d
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/lists.scss
@@ -0,0 +1,103 @@
+.attachment-list {
+  display: flex;
+  font-size: 14px;
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-radius: 4px;
+  margin-top: 14px;
+  overflow: hidden;
+}
+
+.attachment-list__icon {
+  flex: 0 0 auto;
+  color: $ui-base-lighter-color;
+  padding: 8px 18px;
+  cursor: default;
+  border-right: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  font-size: 26px;
+
+  .fa {
+    display: block;
+  }
+}
+
+.attachment-list__list {
+  list-style: none;
+  padding: 4px 0;
+  padding-left: 8px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+
+  li {
+    display: block;
+    padding: 4px 0;
+  }
+
+  a {
+    text-decoration: none;
+    color: $ui-base-lighter-color;
+    font-weight: 500;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.list-editor {
+  background: $ui-base-color;
+  flex-direction: column;
+  border-radius: 8px;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  width: 380px;
+  overflow: hidden;
+
+  @media screen and (max-width: 420px) {
+    width: 90%;
+  }
+
+  h4 {
+    padding: 15px 0;
+    background: lighten($ui-base-color, 13%);
+    font-weight: 500;
+    font-size: 16px;
+    text-align: center;
+    border-radius: 8px 8px 0 0;
+  }
+
+  .drawer__pager {
+    height: 50vh;
+  }
+
+  .drawer__inner {
+    border-radius: 0 0 8px 8px;
+
+    &.backdrop {
+      width: calc(100% - 60px);
+      box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+      border-radius: 0 0 0 8px;
+    }
+  }
+
+  &__accounts {
+    overflow-y: auto;
+  }
+
+  .account__display-name {
+    &:hover strong {
+      text-decoration: none;
+    }
+  }
+
+  .account__avatar {
+    cursor: default;
+  }
+
+  .search {
+    margin-bottom: 0;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/local_settings.scss b/app/javascript/flavours/glitch/styles/components/local_settings.scss
new file mode 100644
index 000000000..16c8cf003
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/local_settings.scss
@@ -0,0 +1,81 @@
+.glitch.local-settings {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  background: $ui-secondary-color;
+  color: $ui-base-color;
+  border-radius: 8px;
+  height: 80vh;
+  width: 80vw;
+  max-width: 740px;
+  max-height: 450px;
+  overflow: hidden;
+
+  label {
+    display: block;
+  }
+
+  h1 {
+    font-size: 18px;
+    font-weight: 500;
+    line-height: 24px;
+    margin-bottom: 20px;
+  }
+
+  h2 {
+    font-size: 15px;
+    font-weight: 500;
+    line-height: 20px;
+    margin-top: 20px;
+    margin-bottom: 10px;
+  }
+}
+
+.glitch.local-settings__navigation__item {
+  display: block;
+  padding: 15px 20px;
+  color: inherit;
+  background: $primary-text-color;
+  border-bottom: 1px $ui-primary-color solid;
+  cursor: pointer;
+  text-decoration: none;
+  outline: none;
+  transition: background .3s;
+
+  &:hover {
+    background: $ui-secondary-color;
+  }
+
+  &.active {
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+  }
+
+  &.close, &.close:hover {
+    background: $error-value-color;
+    color: $primary-text-color;
+  }
+}
+
+.glitch.local-settings__navigation {
+  background: $primary-text-color;
+  color: $ui-base-color;
+  width: 200px;
+  font-size: 15px;
+  line-height: 20px;
+  overflow-y: auto;
+}
+
+.glitch.local-settings__page {
+  display: block;
+  flex: auto;
+  padding: 15px 20px 15px 20px;
+  width: 360px;
+  overflow-y: auto;
+}
+
+.glitch.local-settings__page__item {
+  select {
+    margin-bottom: 5px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
new file mode 100644
index 000000000..81d4692a8
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -0,0 +1,485 @@
+.video-error-cover {
+  align-items: center;
+  background: $base-overlay-background;
+  color: $primary-text-color;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  justify-content: center;
+  margin-top: 8px;
+  position: relative;
+  text-align: center;
+  z-index: 100;
+}
+
+.media-spoiler {
+  background: $base-overlay-background;
+  color: $ui-primary-color;
+  border: 0;
+  width: 100%;
+  height: 100%;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: lighten($ui-primary-color, 8%);
+  }
+
+  .status__content > & {
+    margin-top: 15px; // Add margin when used bare for NSFW video player
+  }
+  @include fullwidth-gallery;
+}
+
+.media-spoiler__warning {
+  display: block;
+  font-size: 14px;
+}
+
+.media-spoiler__trigger {
+  display: block;
+  font-size: 11px;
+  font-weight: 500;
+}
+
+.media-gallery__gifv__label {
+  display: block;
+  position: absolute;
+  color: $primary-text-color;
+  background: rgba($base-overlay-background, 0.5);
+  bottom: 6px;
+  left: 6px;
+  padding: 2px 6px;
+  border-radius: 2px;
+  font-size: 11px;
+  font-weight: 600;
+  z-index: 1;
+  pointer-events: none;
+  opacity: 0.9;
+  transition: opacity 0.1s ease;
+}
+
+.media-gallery__gifv {
+  &.autoplay {
+    .media-gallery__gifv__label {
+      display: none;
+    }
+  }
+
+  &:hover {
+    .media-gallery__gifv__label {
+      opacity: 1;
+    }
+  }
+}
+
+.media-gallery {
+  box-sizing: border-box;
+  margin-top: 8px;
+  overflow: hidden;
+  position: relative;
+  background: $base-shadow-color;
+  width: 100%;
+  height: 110px;
+
+  .detailed-status & {
+    margin-left: -22px;
+    width: calc(100% + 44px);
+    height: 250px;
+  }
+
+  @include fullwidth-gallery;
+}
+
+.media-gallery__item {
+  border: none;
+  box-sizing: border-box;
+  display: block;
+  float: left;
+  position: relative;
+
+  &.standalone {
+    .media-gallery__item-gifv-thumbnail {
+      transform: none;
+    }
+  }
+}
+
+.media-gallery__item-thumbnail {
+  cursor: zoom-in;
+  display: block;
+  text-decoration: none;
+  height: 100%;
+  line-height: 0;
+
+  &,
+  img {
+    height: 100%;
+    width: 100%;
+    object-fit: contain;
+
+    &:not(.letterbox) {
+      height: 100%;
+      object-fit: cover;
+    }
+  }
+}
+
+.media-gallery__gifv {
+  height: 100%;
+  overflow: hidden;
+  position: relative;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+}
+
+.media-gallery__item-gifv-thumbnail {
+  cursor: zoom-in;
+  height: 100%;
+  width: 100%;
+  position: relative;
+  z-index: 1;
+  object-fit: contain;
+
+  &:not(.letterbox) {
+    height: 100%;
+    object-fit: cover;
+  }
+}
+
+.media-gallery__item-thumbnail-label {
+  clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
+  clip: rect(1px, 1px, 1px, 1px);
+  overflow: hidden;
+  position: absolute;
+}
+
+.media-modal {
+  max-width: 80vw;
+  max-height: 80vh;
+  position: relative;
+
+  .extended-video-player,
+  img,
+  canvas,
+  video {
+    max-width: 80vw;
+    max-height: 80vh;
+    width: auto;
+    height: auto;
+    margin: auto;
+  }
+
+  .extended-video-player,
+  video {
+    display: flex;
+    width: 80vw;
+    height: 80vh;
+  }
+
+  img,
+  canvas {
+    display: block;
+    background: url('~images/void.png') repeat;
+    object-fit: contain;
+  }
+
+  .react-swipeable-view-container {
+    max-width: 80vw;
+  }
+}
+
+.media-modal__content {
+  background: $base-overlay-background;
+}
+
+.media-modal__pagination {
+  width: 100%;
+  text-align: center;
+  position: absolute;
+  left: 0;
+  bottom: -40px;
+}
+
+.media-modal__page-dot {
+  display: inline-block;
+}
+
+.media-modal__button {
+  background-color: $white;
+  height: 12px;
+  width: 12px;
+  border-radius: 6px;
+  margin: 10px;
+  padding: 0;
+  border: 0;
+  font-size: 0;
+}
+
+.media-modal__button--active {
+  background-color: $ui-highlight-color;
+}
+
+.media-modal__close {
+  position: absolute;
+  right: 4px;
+  top: 4px;
+  z-index: 100;
+}
+
+.video-player {
+  overflow: hidden;
+  position: relative;
+  background: $base-shadow-color;
+  max-width: 100%;
+
+  .detailed-status & {
+    width: 100%;
+    height: 100%;
+  }
+
+  @include fullwidth-gallery;
+
+  video {
+    height: 100%;
+    width: 100%;
+    z-index: 1;
+    object-fit: cover;
+    position: relative;
+  }
+
+  &.fullscreen {
+    width: 100% !important;
+    height: 100% !important;
+    margin: 0;
+
+    video {
+      max-width: 100% !important;
+      max-height: 100% !important;
+    }
+  }
+
+  &.inline {
+    video {
+      object-fit: cover;
+      position: relative;
+      top: 50%;
+      transform: translateY(-50%);
+    }
+  }
+
+  &__controls {
+    position: absolute;
+    z-index: 2;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    box-sizing: border-box;
+    background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);
+    padding: 0 15px;
+    opacity: 0;
+    transition: opacity .1s ease;
+
+    &.active {
+      opacity: 1;
+    }
+  }
+
+  &.inactive {
+    video,
+    .video-player__controls {
+      visibility: hidden;
+    }
+  }
+
+  &__spoiler {
+    display: none;
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 4;
+    border: 0;
+    background: $base-shadow-color;
+    color: $ui-primary-color;
+    transition: none;
+    pointer-events: none;
+
+    &.active {
+      display: block;
+      pointer-events: auto;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($ui-primary-color, 8%);
+      }
+    }
+
+    &__title {
+      display: block;
+      font-size: 14px;
+    }
+
+    &__subtitle {
+      display: block;
+      font-size: 11px;
+      font-weight: 500;
+    }
+  }
+
+  &__buttons-bar {
+    display: flex;
+    justify-content: space-between;
+    padding-bottom: 10px;
+  }
+
+  &__buttons {
+    font-size: 16px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+
+    &.left {
+      button {
+        padding-left: 0;
+      }
+    }
+
+    &.right {
+      button {
+        padding-right: 0;
+      }
+    }
+
+    button {
+      background: transparent;
+      padding: 2px 10px;
+      font-size: 16px;
+      border: 0;
+      color: rgba($white, 0.75);
+
+      &:active,
+      &:hover,
+      &:focus {
+        color: $white;
+      }
+    }
+  }
+
+  &__time-sep,
+  &__time-total,
+  &__time-current {
+    font-size: 14px;
+    font-weight: 500;
+  }
+
+  &__time-current {
+    color: $white;
+    margin-left: 10px;
+  }
+
+  &__time-sep {
+    display: inline-block;
+    margin: 0 6px;
+  }
+
+  &__time-sep,
+  &__time-total {
+    color: $white;
+  }
+
+  &__seek {
+    cursor: pointer;
+    height: 24px;
+    position: relative;
+
+    &::before {
+      content: "";
+      width: 100%;
+      background: rgba($white, 0.35);
+      border-radius: 4px;
+      display: block;
+      position: absolute;
+      height: 4px;
+      top: 10px;
+    }
+
+    &__progress,
+    &__buffer {
+      display: block;
+      position: absolute;
+      height: 4px;
+      border-radius: 4px;
+      top: 10px;
+      background: lighten($ui-highlight-color, 8%);
+    }
+
+    &__buffer {
+      background: rgba($white, 0.2);
+    }
+
+    &__handle {
+      position: absolute;
+      z-index: 3;
+      opacity: 0;
+      border-radius: 50%;
+      width: 12px;
+      height: 12px;
+      top: 6px;
+      margin-left: -6px;
+      transition: opacity .1s ease;
+      background: lighten($ui-highlight-color, 8%);
+      box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+      pointer-events: none;
+
+      &.active {
+        opacity: 1;
+      }
+    }
+
+    &:hover {
+      .video-player__seek__handle {
+        opacity: 1;
+      }
+    }
+  }
+
+&.detailed,
+&.fullscreen {
+  .video-player__buttons {
+    button {
+      padding-top: 10px;
+      padding-bottom: 10px;
+    }
+  }
+}
+}
+
+.media-spoiler-video {
+  background-size: cover;
+  background-repeat: no-repeat;
+  background-position: center;
+  cursor: pointer;
+  margin-top: 8px;
+  position: relative;
+
+  @include fullwidth-gallery;
+
+  border: 0;
+  display: block;
+}
+
+.media-spoiler-video-play-icon {
+  border-radius: 100px;
+  color: rgba($primary-text-color, 0.8);
+  font-size: 36px;
+  left: 50%;
+  padding: 5px;
+  position: absolute;
+  top: 50%;
+  transform: translate(-50%, -50%);
+}
diff --git a/app/javascript/flavours/glitch/styles/components/metadata.scss b/app/javascript/flavours/glitch/styles/components/metadata.scss
new file mode 100644
index 000000000..d56de19ea
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/metadata.scss
@@ -0,0 +1,52 @@
+.account__metadata {
+  width: 100%;
+  font-size: 15px;
+  line-height: 20px;
+  overflow: hidden;
+  border-collapse: collapse;
+
+  a {
+    text-decoration: none;
+
+    &:hover{
+      text-decoration: underline;
+    }
+  }
+
+  tr {
+    border-top: 1px solid lighten($ui-base-color, 8%);
+    text-align: center;
+  }
+
+  th, td {
+    padding: 14px 20px;
+    vertical-align: middle;
+
+    & > div {
+      max-height: 40px;
+      overflow-y: auto;
+      white-space: pre-wrap;
+      text-overflow: ellipsis;
+    }
+  }
+
+  th {
+    color: $ui-primary-color;
+    background: lighten($ui-base-color, 13%);
+    max-width: 120px;
+
+    a {
+      color: $primary-text-color;
+    }
+  }
+
+  td {
+    flex: auto;
+    color: $primary-text-color;
+    background: $ui-base-color;
+
+    a {
+      color: $ui-highlight-color;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
new file mode 100644
index 000000000..c12f56828
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -0,0 +1,668 @@
+.modal-container--preloader {
+  background: lighten($ui-base-color, 8%);
+}
+
+.modal-container__nav {
+  align-items: center;
+  background: rgba($base-overlay-background, 0.5);
+  box-sizing: border-box;
+  border: 0;
+  color: $primary-text-color;
+  cursor: pointer;
+  display: flex;
+  font-size: 24px;
+  height: 100%;
+  padding: 30px 15px;
+  position: absolute;
+  top: 0;
+}
+
+.modal-container__nav--left {
+  left: -61px;
+}
+
+.modal-container__nav--right {
+  right: -61px;
+}
+
+.modal-root {
+  transition: opacity 0.3s linear;
+  will-change: opacity;
+  z-index: 9999;
+}
+
+.modal-root__overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba($base-overlay-background, 0.7);
+}
+
+.modal-root__container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  align-content: space-around;
+  z-index: 9999;
+  pointer-events: none;
+  user-select: none;
+}
+
+.modal-root__modal {
+  pointer-events: auto;
+  display: flex;
+  z-index: 9999;
+}
+
+.onboarding-modal,
+.error-modal,
+.embed-modal {
+  background: $ui-secondary-color;
+  color: $ui-base-color;
+  border-radius: 8px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.onboarding-modal__pager {
+  height: 80vh;
+  width: 80vw;
+  max-width: 520px;
+  max-height: 420px;
+
+  .react-swipeable-view-container > div {
+    width: 100%;
+    height: 100%;
+    box-sizing: border-box;
+    padding: 25px;
+    display: none;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    display: flex;
+    user-select: text;
+  }
+}
+
+.error-modal__body {
+  height: 80vh;
+  width: 80vw;
+  max-width: 520px;
+  max-height: 420px;
+  position: relative;
+
+  & > div {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    box-sizing: border-box;
+    padding: 25px;
+    display: none;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    display: flex;
+    opacity: 0;
+    user-select: text;
+  }
+}
+
+.error-modal__body {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  text-align: center;
+}
+
+@media screen and (max-width: 550px) {
+  .onboarding-modal {
+    width: 100%;
+    height: 100%;
+    border-radius: 0;
+  }
+
+  .onboarding-modal__pager {
+    width: 100%;
+    height: auto;
+    max-width: none;
+    max-height: none;
+    flex: 1 1 auto;
+  }
+}
+
+.onboarding-modal__paginator,
+.error-modal__footer {
+  flex: 0 0 auto;
+  background: darken($ui-secondary-color, 8%);
+  display: flex;
+  padding: 25px;
+
+  & > div {
+    min-width: 33px;
+  }
+
+  .onboarding-modal__nav,
+  .error-modal__nav {
+    color: darken($ui-secondary-color, 34%);
+    background-color: transparent;
+    border: 0;
+    font-size: 14px;
+    font-weight: 500;
+    padding: 0;
+    line-height: inherit;
+    height: auto;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: darken($ui-secondary-color, 38%);
+    }
+
+    &.onboarding-modal__done,
+    &.onboarding-modal__next {
+      color: $ui-highlight-color;
+    }
+  }
+}
+
+.error-modal__footer {
+  justify-content: center;
+}
+
+.onboarding-modal__dots {
+  flex: 1 1 auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.onboarding-modal__dot {
+  width: 14px;
+  height: 14px;
+  border-radius: 14px;
+  background: darken($ui-secondary-color, 16%);
+  margin: 0 3px;
+  cursor: pointer;
+
+  &:hover {
+    background: darken($ui-secondary-color, 18%);
+  }
+
+  &.active {
+    cursor: default;
+    background: darken($ui-secondary-color, 24%);
+  }
+}
+
+.onboarding-modal__page__wrapper {
+  pointer-events: none;
+
+  &.onboarding-modal__page__wrapper--active {
+    pointer-events: auto;
+  }
+}
+
+.onboarding-modal__page {
+  cursor: default;
+  line-height: 21px;
+
+  h1 {
+    font-size: 18px;
+    font-weight: 500;
+    color: $ui-base-color;
+    margin-bottom: 20px;
+  }
+
+  a {
+    color: $ui-highlight-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: lighten($ui-highlight-color, 4%);
+    }
+  }
+
+  p {
+    font-size: 16px;
+    color: lighten($ui-base-color, 8%);
+    margin-top: 10px;
+    margin-bottom: 10px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    strong {
+      font-weight: 500;
+      background: $ui-base-color;
+      color: $ui-secondary-color;
+      border-radius: 4px;
+      font-size: 14px;
+      padding: 3px 6px;
+
+      @each $lang in $cjk-langs {
+        &:lang(#{$lang}) {
+          font-weight: 700;
+        }
+      }
+    }
+  }
+}
+
+.onboarding-modal__page-one {
+  display: flex;
+  align-items: center;
+}
+
+.onboarding-modal__page-one__elephant-friend {
+  background: url('~images/elephant-friend-1.png') no-repeat center center / contain;
+  width: 155px;
+  height: 193px;
+  margin-right: 15px;
+}
+
+@media screen and (max-width: 400px) {
+  .onboarding-modal__page-one {
+    flex-direction: column;
+    align-items: normal;
+  }
+
+  .onboarding-modal__page-one__elephant-friend {
+    width: 100%;
+    height: 30vh;
+    max-height: 160px;
+    margin-bottom: 5vh;
+  }
+}
+
+.onboarding-modal__page-two,
+.onboarding-modal__page-three,
+.onboarding-modal__page-four,
+.onboarding-modal__page-five {
+  p {
+    text-align: left;
+  }
+
+  .figure {
+    background: darken($ui-base-color, 8%);
+    color: $ui-secondary-color;
+    margin-bottom: 20px;
+    border-radius: 4px;
+    padding: 10px;
+    text-align: center;
+    font-size: 14px;
+    box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);
+
+    .onboarding-modal__image {
+      border-radius: 4px;
+      margin-bottom: 10px;
+    }
+
+    &.non-interactive {
+      pointer-events: none;
+      text-align: left;
+    }
+  }
+}
+
+.onboarding-modal__page-four__columns {
+  .row {
+    display: flex;
+    margin-bottom: 20px;
+
+    & > div {
+      flex: 1 1 0;
+      margin: 0 10px;
+
+      &:first-child {
+        margin-left: 0;
+      }
+
+      &:last-child {
+        margin-right: 0;
+      }
+
+      p {
+        text-align: center;
+      }
+    }
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .column-header {
+    color: $primary-text-color;
+  }
+}
+
+@media screen and (max-width: 320px) and (max-height: 600px) {
+  .onboarding-modal__page p {
+    font-size: 14px;
+    line-height: 20px;
+  }
+
+  .onboarding-modal__page-two .figure,
+  .onboarding-modal__page-three .figure,
+  .onboarding-modal__page-four .figure,
+  .onboarding-modal__page-five .figure {
+    font-size: 12px;
+    margin-bottom: 10px;
+  }
+
+  .onboarding-modal__page-four__columns .row {
+    margin-bottom: 10px;
+  }
+
+  .onboarding-modal__page-four__columns .column-header {
+    padding: 5px;
+    font-size: 12px;
+  }
+}
+
+.onboarding-modal__image {
+  border-radius: 8px;
+  width: 70vw;
+  max-width: 450px;
+  max-height: auto;
+  display: block;
+  margin: auto;
+  margin-bottom: 20px;
+}
+
+.onboard-sliders {
+  display: inline-block;
+  max-width: 30px;
+  max-height: auto;
+  margin-left: 10px;
+}
+
+.boost-modal,
+.favourite-modal,
+.confirmation-modal,
+.report-modal,
+.actions-modal,
+.mute-modal {
+  background: lighten($ui-secondary-color, 8%);
+  color: $ui-base-color;
+  border-radius: 8px;
+  overflow: hidden;
+  max-width: 90vw;
+  width: 480px;
+  position: relative;
+  flex-direction: column;
+
+  .status__display-name {
+    display: flex;
+  }
+
+  .status__avatar {
+    height: 28px;
+    left: 10px;
+    top: 10px;
+    width: 48px;
+  }
+}
+
+.actions-modal {
+  .status {
+    background: $white;
+    border-bottom-color: $ui-secondary-color;
+    padding-top: 10px;
+    padding-bottom: 10px;
+  }
+
+  .dropdown-menu__separator {
+    border-bottom-color: $ui-secondary-color;
+  }
+}
+
+.boost-modal__container,
+.favourite-modal__container {
+  overflow-x: scroll;
+  padding: 10px;
+
+  .status {
+    user-select: text;
+    border-bottom: 0;
+  }
+}
+
+.boost-modal__action-bar,
+.favourite-modal__action-bar,
+.confirmation-modal__action-bar,
+.mute-modal__action-bar,
+.report-modal__action-bar {
+  display: flex;
+  justify-content: space-between;
+  background: $ui-secondary-color;
+  padding: 10px;
+  line-height: 36px;
+
+  & > div {
+    flex: 1 1 auto;
+    text-align: right;
+    color: lighten($ui-base-color, 33%);
+    padding-right: 10px;
+  }
+
+  .button {
+    flex: 0 0 auto;
+  }
+}
+
+.boost-modal__status-header,
+.favourite-modal__status-header {
+  font-size: 15px;
+}
+
+.boost-modal__status-time,
+.favourite-modal__status-time {
+  float: right;
+  font-size: 14px;
+}
+
+.confirmation-modal {
+  max-width: 85vw;
+
+  @media screen and (min-width: 480px) {
+    max-width: 380px;
+  }
+}
+
+.mute-modal {
+  line-height: 24px;
+}
+
+.mute-modal .react-toggle {
+  vertical-align: middle;
+}
+
+.report-modal__statuses,
+.report-modal__comment {
+  padding: 10px;
+}
+
+.report-modal__statuses {
+  min-height: 20vh;
+  max-height: 40vh;
+  overflow-y: auto;
+  overflow-x: hidden;
+}
+
+.report-modal__comment {
+  .setting-text {
+    margin-top: 10px;
+  }
+}
+
+.actions-modal {
+  .status {
+    overflow-y: auto;
+    max-height: 300px;
+  }
+
+  strong {
+    display: block;
+    font-weight: 500;
+  }
+
+  max-height: 80vh;
+  max-width: 80vw;
+
+  .actions-modal__item-label {
+    font-weight: 500;
+  }
+
+  ul {
+    overflow-y: auto;
+    flex-shrink: 0;
+
+    li:empty {
+      margin: 0;
+    }
+
+    li:not(:empty) {
+      a {
+        color: $ui-base-color;
+        display: flex;
+        padding: 12px 16px;
+        font-size: 15px;
+        align-items: center;
+        text-decoration: none;
+
+        &,
+        button {
+          transition: none;
+        }
+
+        &.active,
+        &:hover,
+        &:active,
+        &:focus {
+          &,
+          button {
+          background: $ui-highlight-color;
+          color: $primary-text-color;
+          }
+        }
+
+        & > .react-toggle,
+        & > .icon,
+        button:first-child {
+          margin-right: 10px;
+        }
+      }
+    }
+  }
+}
+
+.confirmation-modal__action-bar,
+.mute-modal__action-bar {
+  .confirmation-modal__cancel-button,
+  .mute-modal__cancel-button {
+    background-color: transparent;
+    color: darken($ui-secondary-color, 34%);
+    font-size: 14px;
+    font-weight: 500;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: darken($ui-secondary-color, 38%);
+    }
+  }
+}
+
+.confirmation-modal__container,
+.mute-modal__container,
+.report-modal__target {
+  padding: 30px;
+  font-size: 16px;
+  text-align: center;
+
+  strong {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+}
+
+.embed-modal {
+  max-width: 80vw;
+  max-height: 80vh;
+
+  h4 {
+    padding: 30px;
+    font-weight: 500;
+    font-size: 16px;
+    text-align: center;
+  }
+
+  .embed-modal__container {
+    padding: 10px;
+
+    .hint {
+      margin-bottom: 15px;
+    }
+
+    .embed-modal__html {
+      color: $ui-secondary-color;
+      outline: 0;
+      box-sizing: border-box;
+      display: block;
+      width: 100%;
+      border: none;
+      padding: 10px;
+      font-family: 'mastodon-font-monospace', monospace;
+      background: $ui-base-color;
+      color: $ui-primary-color;
+      font-size: 14px;
+      margin: 0;
+      margin-bottom: 15px;
+
+      &::-moz-focus-inner {
+        border: 0;
+      }
+
+      &::-moz-focus-inner,
+      &:focus,
+      &:active {
+        outline: 0 !important;
+      }
+
+      &:focus {
+        background: lighten($ui-base-color, 4%);
+      }
+
+      @media screen and (max-width: 600px) {
+        font-size: 16px;
+      }
+    }
+
+    .embed-modal__iframe {
+      width: 400px;
+      max-width: 100%;
+      overflow: hidden;
+      border: 0;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
new file mode 100644
index 000000000..546697176
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -0,0 +1,105 @@
+.search {
+  position: relative;
+}
+
+.search__input {
+  outline: 0;
+  box-sizing: border-box;
+  display: block;
+  width: 100%;
+  border: none;
+  padding: 10px;
+  padding-right: 30px;
+  font-family: inherit;
+  background: $ui-base-color;
+  color: $ui-primary-color;
+  font-size: 14px;
+  margin: 0;
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus,
+  &:active {
+    outline: 0 !important;
+  }
+
+  &:focus {
+    background: lighten($ui-base-color, 4%);
+  }
+
+  @media screen and (max-width: 600px) {
+    font-size: 16px;
+  }
+}
+
+.search__icon {
+  .fa {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    z-index: 2;
+    display: inline-block;
+    opacity: 0;
+    transition: all 100ms linear;
+    font-size: 18px;
+    width: 18px;
+    height: 18px;
+    color: $ui-secondary-color;
+    cursor: default;
+    pointer-events: none;
+
+    &.active {
+      pointer-events: auto;
+      opacity: 0.3;
+    }
+  }
+
+  .fa-search {
+    transform: rotate(90deg);
+
+    &.active {
+      pointer-events: none;
+      transform: rotate(0deg);
+    }
+  }
+
+  .fa-times-circle {
+    top: 11px;
+    transform: rotate(0deg);
+    cursor: pointer;
+
+    &.active {
+      transform: rotate(90deg);
+    }
+
+    &:hover {
+      color: $primary-text-color;
+    }
+  }
+}
+
+.search-results__header {
+  color: $ui-base-lighter-color;
+  background: lighten($ui-base-color, 2%);
+  border-bottom: 1px solid darken($ui-base-color, 4%);
+  padding: 15px 10px;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.search-results__hashtag {
+  display: block;
+  padding: 10px;
+  color: $ui-secondary-color;
+  text-decoration: none;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: lighten($ui-secondary-color, 4%);
+    text-decoration: underline;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/sensitive.scss b/app/javascript/flavours/glitch/styles/components/sensitive.scss
new file mode 100644
index 000000000..b0a7dfe03
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/sensitive.scss
@@ -0,0 +1,24 @@
+.sensitive-info {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  position: absolute;
+  top: 4px;
+  left: 4px;
+  z-index: 100;
+}
+
+.sensitive-marker {
+  margin: 0 3px;
+  border-radius: 2px;
+  padding: 2px 6px;
+  color: rgba($primary-text-color, 0.8);
+  background: rgba($base-overlay-background, 0.5);
+  font-size: 12px;
+  line-height: 15px;
+  text-transform: uppercase;
+  opacity: .9;
+  transition: opacity .1s ease;
+
+  .media-gallery:hover & { opacity: 1 }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
new file mode 100644
index 000000000..1ee5cba9e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -0,0 +1,698 @@
+.status__content--with-action {
+  cursor: pointer;
+}
+
+.status__content {
+  position: relative;
+  margin: 10px 0;
+  padding: 0 12px;
+  font-size: 15px;
+  line-height: 20px;
+  color: $primary-text-color;
+  word-wrap: break-word;
+  font-weight: 400;
+  overflow: visible;
+  padding-top: 5px;
+
+  &:focus {
+    outline: 0;
+  }
+
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -3px 0 0;
+  }
+
+  p {
+    margin-bottom: 20px;
+    white-space: pre-wrap;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: $ui-secondary-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+
+      .fa {
+        color: lighten($ui-base-color, 40%);
+      }
+    }
+
+    &.mention {
+      &:hover {
+        text-decoration: none;
+
+        span {
+          text-decoration: underline;
+        }
+      }
+    }
+
+    .fa {
+      color: lighten($ui-base-color, 30%);
+    }
+  }
+
+  .status__content__spoiler {
+    display: none;
+
+    &.status__content__spoiler--visible {
+      display: block;
+    }
+  }
+
+  .status__content__spoiler-link {
+    background: lighten($ui-base-color, 30%);
+
+    &:hover {
+      background: lighten($ui-base-color, 33%);
+      text-decoration: none;
+    }
+  }
+}
+
+.status__content__spoiler-link {
+  display: inline-block;
+  border-radius: 2px;
+  background: lighten($ui-base-color, 30%);
+  border: none;
+  color: lighten($ui-base-color, 8%);
+  font-weight: 500;
+  font-size: 11px;
+  padding: 0 5px;
+  text-transform: uppercase;
+  line-height: inherit;
+  cursor: pointer;
+  vertical-align: bottom;
+
+  &:hover {
+    background: lighten($ui-base-color, 33%);
+    text-decoration: none;
+  }
+
+  .status__content__spoiler-icon {
+    display: inline-block;
+    margin: 0 0 0 5px;
+    border-left: 1px solid currentColor;
+    padding: 0 0 0 4px;
+    font-size: 16px;
+    vertical-align: -2px;
+  }
+}
+
+.notif-cleaning {
+  .status, .notification-follow {
+    padding-right: ($dismiss-overlay-width + 0.5rem);
+  }
+}
+
+.status__prepend-icon-wrapper {
+  float: left;
+  margin: 0 10px 0 -58px;
+  width: 48px;
+  text-align: right;
+}
+
+.notification-follow {
+  position: relative;
+
+  // same like Status
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  .account {
+    border-bottom: 0 none;
+  }
+}
+
+.focusable {
+  &:focus {
+    outline: 0;
+    background: lighten($ui-base-color, 4%);
+
+    .status.status-direct {
+      background: lighten($ui-base-color, 12%);
+
+      &.muted {
+        background: transparent;
+      }
+    }
+
+    .detailed-status,
+    .detailed-status__action-bar {
+      background: lighten($ui-base-color, 8%);
+    }
+  }
+}
+
+.status {
+  padding: 8px 10px;
+  position: relative;
+  height: auto;
+  min-height: 48px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  cursor: default;
+
+  @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {
+    // Add margin to avoid Edge auto-hiding scrollbar appearing over content.
+    // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.
+    padding-right: 26px; // 10px + 16px
+  }
+
+  @keyframes fade {
+    0% { opacity: 0; }
+    100% { opacity: 1; }
+  }
+
+  opacity: 1;
+  animation: fade 150ms linear;
+
+  .video-player {
+    margin-top: 8px;
+  }
+
+  &.status-direct {
+    background: lighten($ui-base-color, 8%);
+
+    .icon-button.disabled {
+      color: lighten($ui-base-color, 16%);
+    }
+  }
+
+  &.light {
+    .status__relative-time {
+      color: $ui-primary-color;
+    }
+
+    .status__display-name {
+      color: $ui-base-color;
+    }
+
+    .display-name {
+      strong {
+        color: $ui-base-color;
+      }
+
+      span {
+        color: $ui-primary-color;
+      }
+    }
+
+    .status__content {
+      color: $ui-base-color;
+
+      a {
+        color: $ui-highlight-color;
+      }
+
+      a.status__content__spoiler-link {
+        color: $primary-text-color;
+        background: $ui-primary-color;
+
+        &:hover {
+          background: lighten($ui-primary-color, 8%);
+        }
+      }
+    }
+  }
+
+  &.collapsed {
+    background-position: center;
+    background-size: cover;
+    user-select: none;
+
+    &.has-background::before {
+      display: block;
+      position: absolute;
+      left: 0;
+      right: 0;
+      top: 0;
+      bottom: 0;
+      background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));
+      content: "";
+    }
+
+    .display-name:hover .display-name__html {
+      text-decoration: none;
+    }
+
+    .status__content {
+      height: 20px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+
+      a:hover {
+        text-decoration: none;
+      }
+    }
+  }
+
+  .notification__message {
+    margin: -10px 0px 10px 32px;
+  }
+}
+
+.notification-favourite {
+  .status.status-direct {
+    background: transparent;
+
+    .icon-button.disabled {
+      color: lighten($ui-base-color, 13%);
+    }
+  }
+}
+
+.status__relative-time {
+  display: inline-block;
+  margin-left: auto;
+  padding-left: 18px;
+  width: 120px;
+  color: $ui-base-lighter-color;
+  font-size: 14px;
+  text-align: right;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.status__display-name {
+  margin: 0 auto 0 0;
+  color: $ui-base-lighter-color;
+  overflow: hidden;
+}
+
+.status__info .status__display-name {
+  display: block;
+  max-width: 100%;
+  padding-right: 25px;
+}
+
+.status__info {
+  display: flex;
+  margin: 2px 0 5px;
+  font-size: 15px;
+  line-height: 24px;
+}
+
+.status__info__icons {
+  flex: none;
+  position: relative;
+  color: lighten($ui-base-color, 26%);
+
+  .status__visibility-icon {
+    padding-left: 6px;
+  }
+}
+
+.status-check-box {
+  border-bottom: 1px solid $ui-secondary-color;
+  display: flex;
+
+  .status__content {
+    flex: 1 1 auto;
+    padding: 10px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    .status__content {
+      color: #3a3a3a;
+      a {
+        color: #005aa9;
+      }
+    }
+  }
+}
+
+.status-check-box-toggle {
+  align-items: center;
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: center;
+  padding: 10px;
+}
+
+.status__prepend {
+  margin: -10px -10px 10px;
+  color: $ui-base-lighter-color;
+  padding: 8px 10px 0 68px;
+  font-size: 14px;
+  position: relative;
+
+  .status__display-name strong {
+    color: $ui-base-lighter-color;
+  }
+
+  > span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.status__action-bar {
+  align-items: center;
+  display: flex;
+  margin-top: 8px;
+}
+
+.status__action-bar-button {
+  float: left;
+  margin-right: 18px;
+}
+
+.status__action-bar-dropdown {
+  float: left;
+  height: 23.15px;
+  width: 23.15px;
+}
+
+.detailed-status__action-bar-dropdown {
+  flex: 1 1 auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+}
+
+.detailed-status {
+  background: lighten($ui-base-color, 4%);
+  padding: 14px 10px;
+
+  .status__content {
+    font-size: 19px;
+    line-height: 24px;
+
+    .emojione {
+      width: 24px;
+      height: 24px;
+      margin: -1px 0 0;
+    }
+  }
+
+  .video-player {
+    margin-top: 8px;
+  }
+}
+
+.detailed-status__meta {
+  margin-top: 15px;
+  color: $ui-base-lighter-color;
+  font-size: 14px;
+  line-height: 18px;
+}
+
+.detailed-status__action-bar {
+  background: lighten($ui-base-color, 4%);
+  border-top: 1px solid lighten($ui-base-color, 8%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: row;
+  padding: 10px 0;
+}
+
+.detailed-status__link {
+  color: inherit;
+  text-decoration: none;
+}
+
+.detailed-status__favorites,
+.detailed-status__reblogs {
+  display: inline-block;
+  font-weight: 500;
+  font-size: 12px;
+  margin-left: 6px;
+}
+
+.status__display-name,
+.status__relative-time,
+.detailed-status__display-name,
+.detailed-status__datetime,
+.detailed-status__application,
+.account__display-name {
+  text-decoration: none;
+}
+
+.status__display-name,
+.account__display-name {
+  strong {
+    color: $primary-text-color;
+  }
+}
+
+.muted {
+  .emojione {
+    opacity: 0.5;
+  }
+}
+
+.status__display-name,
+.reply-indicator__display-name,
+.detailed-status__display-name,
+.account__display-name {
+  &:hover strong {
+    text-decoration: underline;
+  }
+}
+
+.account__display-name strong {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.detailed-status__application,
+.detailed-status__datetime {
+  color: inherit;
+}
+
+.detailed-status__display-name {
+  color: $ui-secondary-color;
+  display: block;
+  line-height: 24px;
+  margin-bottom: 15px;
+  overflow: hidden;
+
+  strong,
+  span {
+    display: block;
+    text-overflow: ellipsis;
+    overflow: hidden;
+  }
+
+  strong {
+    font-size: 16px;
+    color: $primary-text-color;
+  }
+}
+
+.detailed-status__display-avatar {
+  float: left;
+  margin-right: 10px;
+}
+
+.status__avatar {
+  flex: none;
+  margin: 0 10px 0 0;
+  height: 48px;
+  width: 48px;
+}
+
+.muted {
+  .status__content p,
+  .status__content a {
+    color: $ui-base-lighter-color;
+  }
+
+  .status__display-name strong {
+    color: $ui-base-lighter-color;
+  }
+
+  .status__avatar {
+    opacity: 0.5;
+  }
+
+  a.status__content__spoiler-link {
+    background: $ui-base-lighter-color;
+    color: lighten($ui-base-color, 4%);
+
+    &:hover {
+      background: lighten($ui-base-color, 29%);
+      text-decoration: none;
+    }
+  }
+}
+
+.status__relative-time,
+.detailed-status__datetime {
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+.status-card {
+  display: flex;
+  cursor: pointer;
+  font-size: 14px;
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-radius: 4px;
+  color: $ui-base-lighter-color;
+  margin-top: 14px;
+  text-decoration: none;
+  overflow: hidden;
+
+  &:hover {
+    background: lighten($ui-base-color, 8%);
+  }
+}
+
+.status-card-video,
+.status-card-rich,
+.status-card-photo {
+  margin-top: 14px;
+  overflow: hidden;
+
+  iframe {
+    width: 100%;
+    height: auto;
+  }
+}
+
+.status-card-photo {
+  cursor: zoom-in;
+  display: block;
+  text-decoration: none;
+    width: 100%;
+    height: auto;
+    margin: 0;
+}
+
+.status-card-video {
+  iframe {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.status-card__title {
+  display: block;
+  font-weight: 500;
+  margin-bottom: 5px;
+  color: $ui-primary-color;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-card__content {
+  flex: 1 1 auto;
+  overflow: hidden;
+  padding: 14px 14px 14px 8px;
+}
+
+.status-card__description {
+  color: $ui-primary-color;
+}
+
+.status-card__host {
+  display: block;
+  margin-top: 5px;
+  font-size: 13px;
+}
+
+.status-card__image {
+  flex: 0 0 100px;
+  background: lighten($ui-base-color, 8%);
+}
+
+.status-card.horizontal {
+  display: block;
+
+  .status-card__image {
+    width: 100%;
+  }
+
+  .status-card__image-image {
+    border-radius: 4px 4px 0 0;
+  }
+
+  .status-card__title {
+    white-space: inherit;
+  }
+}
+
+.status-card__image-image {
+  border-radius: 4px 0 0 4px;
+  display: block;
+  margin: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.status__video-player {
+  display: flex;
+  align-items: center;
+  background: $base-shadow-color;
+  box-sizing: border-box;
+  cursor: default; /* May not be needed */
+  margin-top: 8px;
+  overflow: hidden;
+  position: relative;
+
+  @include fullwidth-gallery;
+}
+
+.status__video-player-video {
+  height: 100%;
+  object-fit: cover;
+  position: relative;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 100%;
+  z-index: 1;
+
+  &:not(.letterbox) {
+    height: 100%;
+    object-fit: cover;
+  }
+}
+
+.status__video-player-expand,
+.status__video-player-mute {
+  color: $primary-text-color;
+  opacity: 0.8;
+  position: absolute;
+  right: 4px;
+  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+}
+
+.status__video-player-spoiler {
+  display: none;
+  color: $primary-text-color;
+  left: 4px;
+  position: absolute;
+  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+  top: 4px;
+  z-index: 100;
+
+  &.status__video-player-spoiler--visible {
+    display: block;
+  }
+}
+
+.status__video-player-expand {
+  bottom: 4px;
+  z-index: 100;
+}
+
+.status__video-player-mute {
+  top: 4px;
+  z-index: 5;
+}
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
new file mode 100644
index 000000000..af2589e23
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -0,0 +1,116 @@
+.container {
+  width: 700px;
+  margin: 0 auto;
+  margin-top: 40px;
+
+  @media screen and (max-width: 740px) {
+    width: 100%;
+    margin: 0;
+  }
+}
+
+.logo-container {
+  margin: 100px auto;
+  margin-bottom: 50px;
+
+  @media screen and (max-width: 400px) {
+    margin: 30px auto;
+    margin-bottom: 20px;
+  }
+
+  h1 {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    img {
+      height: 42px;
+      margin-right: 10px;
+    }
+
+    a {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      color: $primary-text-color;
+      text-decoration: none;
+      outline: 0;
+      padding: 12px 16px;
+      line-height: 32px;
+      font-family: 'mastodon-font-display', sans-serif;
+      font-weight: 500;
+      font-size: 14px;
+    }
+  }
+}
+
+.compose-standalone {
+  .compose-form {
+    width: 400px;
+    margin: 0 auto;
+    padding: 20px 0;
+    margin-top: 40px;
+    box-sizing: border-box;
+
+    @media screen and (max-width: 400px) {
+      width: 100%;
+      margin-top: 0;
+      padding: 20px;
+    }
+  }
+}
+
+.account-header {
+  width: 400px;
+  margin: 0 auto;
+  display: flex;
+  font-size: 13px;
+  line-height: 18px;
+  box-sizing: border-box;
+  padding: 20px 0;
+  padding-bottom: 0;
+  margin-bottom: -30px;
+  margin-top: 40px;
+
+  @media screen and (max-width: 440px) {
+    width: 100%;
+    margin: 0;
+    margin-bottom: 10px;
+    padding: 20px;
+    padding-bottom: 0;
+  }
+
+  .avatar {
+    width: 40px;
+    height: 40px;
+    margin-right: 8px;
+
+    img {
+      width: 100%;
+      height: 100%;
+      display: block;
+      margin: 0;
+      border-radius: 4px;
+    }
+  }
+
+  .name {
+    flex: 1 1 auto;
+    color: $ui-secondary-color;
+    width: calc(100% - 88px);
+
+    .username {
+      display: block;
+      font-weight: 500;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+  }
+
+  .logout-link {
+    display: block;
+    font-size: 32px;
+    line-height: 40px;
+    margin-left: 8px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/footer.scss b/app/javascript/flavours/glitch/styles/footer.scss
new file mode 100644
index 000000000..2d953b34e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/footer.scss
@@ -0,0 +1,30 @@
+.footer {
+  text-align: center;
+  margin-top: 30px;
+  font-size: 12px;
+  color: darken($ui-secondary-color, 25%);
+
+  .domain {
+    font-weight: 500;
+
+    a {
+      color: inherit;
+      text-decoration: none;
+    }
+  }
+
+  .powered-by,
+  .single-user-login {
+    font-weight: 400;
+
+    a {
+      color: inherit;
+      text-decoration: underline;
+      font-weight: 500;
+
+      &:hover {
+        text-decoration: none;
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
new file mode 100644
index 000000000..2bef53cff
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -0,0 +1,570 @@
+code {
+  font-family: 'mastodon-font-monospace', monospace;
+  font-weight: 400;
+}
+
+.form-container {
+  max-width: 400px;
+  padding: 20px;
+  margin: 0 auto;
+}
+
+.simple_form {
+  .input {
+    margin-bottom: 15px;
+    overflow: hidden;
+  }
+
+  span.hint {
+    display: block;
+    color: $ui-primary-color;
+    font-size: 12px;
+    margin-top: 4px;
+  }
+
+  h4 {
+    text-transform: uppercase;
+    font-size: 13px;
+    font-weight: 500;
+    color: $ui-primary-color;
+    padding-bottom: 8px;
+    margin-bottom: 8px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
+  p.hint {
+    margin-bottom: 15px;
+    color: $ui-primary-color;
+
+    &.subtle-hint {
+      text-align: center;
+      font-size: 12px;
+      line-height: 18px;
+      margin-top: 15px;
+      margin-bottom: 0;
+      color: $ui-primary-color;
+
+      a {
+        color: $ui-highlight-color;
+      }
+    }
+  }
+
+  .card {
+    margin-bottom: 15px;
+  }
+
+  strong {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  .label_input {
+    display: flex;
+
+    label {
+      flex: 0 0 auto;
+    }
+
+    input {
+      flex: 1 1 auto;
+    }
+  }
+
+  .input.with_label {
+    padding: 15px 0;
+    margin-bottom: 0;
+
+    .label_input {
+      flex-wrap: wrap;
+      align-items: flex-start;
+    }
+
+    &.select .label_input {
+      align-items: initial;
+    }
+
+    .label_input > label {
+      font-family: inherit;
+      font-size: 16px;
+      color: $primary-text-color;
+      display: block;
+      padding-top: 5px;
+      margin-bottom: 5px;
+      flex: 1;
+      min-width: 150px;
+      word-wrap: break-word;
+
+      &.select {
+        flex: 0;
+      }
+
+      & ~ * {
+        margin-left: 10px;
+      }
+    }
+
+    ul {
+      flex: 390px;
+    }
+
+    &.boolean {
+      padding: initial;
+      margin-bottom: initial;
+
+      .label_input > label {
+        font-family: inherit;
+        font-size: 14px;
+        color: $primary-text-color;
+        display: block;
+        width: auto;
+      }
+
+      label.checkbox {
+        position: relative;
+        padding-left: 25px;
+        flex: 1 1 auto;
+      }
+    }
+  }
+
+  .input.with_block_label {
+    & > label {
+      font-family: inherit;
+      font-size: 16px;
+      color: $primary-text-color;
+      display: block;
+      padding-top: 5px;
+    }
+
+    .hint {
+      margin-bottom: 15px;
+    }
+
+    li {
+      float: left;
+      width: 50%;
+    }
+  }
+
+  .fields-group {
+    margin-bottom: 25px;
+  }
+
+  .input.radio_buttons .radio label {
+    margin-bottom: 5px;
+    font-family: inherit;
+    font-size: 14px;
+    color: $primary-text-color;
+    display: block;
+    width: auto;
+  }
+
+  .input.boolean {
+    margin-bottom: 5px;
+
+    label {
+      font-family: inherit;
+      font-size: 14px;
+      color: $primary-text-color;
+      display: block;
+      width: auto;
+    }
+
+    label.checkbox {
+      position: relative;
+      padding-left: 25px;
+      flex: 1 1 auto;
+    }
+
+    input[type=checkbox] {
+      position: absolute;
+      left: 0;
+      top: 5px;
+      margin: 0;
+    }
+
+    .hint {
+      padding-left: 25px;
+      margin-left: 0;
+    }
+  }
+
+  .check_boxes {
+    .checkbox {
+      label {
+        font-family: inherit;
+        font-size: 14px;
+        color: $primary-text-color;
+        display: block;
+        width: auto;
+        position: relative;
+        padding-top: 5px;
+        padding-left: 25px;
+        flex: 1 1 auto;
+      }
+
+      input[type=checkbox] {
+        position: absolute;
+        left: 0;
+        top: 5px;
+        margin: 0;
+      }
+    }
+  }
+
+  input[type=text],
+  input[type=number],
+  input[type=email],
+  input[type=password],
+  textarea {
+    background: transparent;
+    box-sizing: border-box;
+    border: 0;
+    border-bottom: 2px solid $ui-primary-color;
+    border-radius: 2px 2px 0 0;
+    padding: 7px 4px;
+    font-size: 16px;
+    color: $primary-text-color;
+    display: block;
+    width: 100%;
+    outline: 0;
+    font-family: inherit;
+    resize: vertical;
+
+    &:invalid {
+      box-shadow: none;
+    }
+
+    &:focus:invalid {
+      border-bottom-color: $error-value-color;
+    }
+
+    &:required:valid {
+      border-bottom-color: $valid-value-color;
+    }
+
+    &:active,
+    &:focus {
+      border-bottom-color: $ui-highlight-color;
+      background: rgba($base-overlay-background, 0.1);
+    }
+  }
+
+  .input.field_with_errors {
+    label {
+      color: $error-value-color;
+    }
+
+    input[type=text],
+    input[type=email],
+    input[type=password] {
+      border-bottom-color: $error-value-color;
+    }
+
+    .error {
+      display: block;
+      font-weight: 500;
+      color: $error-value-color;
+      margin-top: 4px;
+    }
+  }
+
+  .actions {
+    margin-top: 30px;
+    display: flex;
+  }
+
+  button,
+  .button,
+  .block-button {
+    display: block;
+    width: 100%;
+    border: 0;
+    border-radius: 4px;
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+    font-size: 18px;
+    line-height: inherit;
+    height: auto;
+    padding: 10px;
+    text-transform: uppercase;
+    text-decoration: none;
+    text-align: center;
+    box-sizing: border-box;
+    cursor: pointer;
+    font-weight: 500;
+    outline: 0;
+    margin-bottom: 10px;
+    margin-right: 10px;
+
+    &:last-child {
+      margin-right: 0;
+    }
+
+    &:hover {
+      background-color: lighten($ui-highlight-color, 5%);
+    }
+
+    &:active,
+    &:focus {
+      background-color: darken($ui-highlight-color, 5%);
+    }
+
+    &.negative {
+      background: $error-value-color;
+
+      &:hover {
+        background-color: lighten($error-value-color, 5%);
+      }
+
+      &:active,
+      &:focus {
+        background-color: darken($error-value-color, 5%);
+      }
+    }
+  }
+
+  select {
+    font-size: 16px;
+    max-height: 29px;
+  }
+
+  .input-with-append {
+    position: relative;
+
+    .input input {
+      padding-right: 127px;
+    }
+
+    .append {
+      position: absolute;
+      right: 0;
+      top: 0;
+      padding: 7px 4px;
+      padding-bottom: 9px;
+      font-size: 16px;
+      color: $ui-base-lighter-color;
+      font-family: inherit;
+      pointer-events: none;
+      cursor: default;
+    }
+  }
+}
+
+.flash-message {
+  background: lighten($ui-base-color, 8%);
+  color: $ui-primary-color;
+  border-radius: 4px;
+  padding: 15px 10px;
+  margin-bottom: 30px;
+  box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
+  text-align: center;
+
+  p {
+    margin-bottom: 15px;
+  }
+
+  .oauth-code {
+    color: $ui-secondary-color;
+    outline: 0;
+    box-sizing: border-box;
+    display: block;
+    width: 100%;
+    border: none;
+    padding: 10px;
+    font-family: 'mastodon-font-monospace', monospace;
+    background: $ui-base-color;
+    color: $ui-primary-color;
+    font-size: 14px;
+    margin: 0;
+
+    &::-moz-focus-inner {
+      border: 0;
+    }
+
+    &::-moz-focus-inner,
+    &:focus,
+    &:active {
+      outline: 0 !important;
+    }
+
+    &:focus {
+      background: lighten($ui-base-color, 4%);
+    }
+  }
+
+  strong {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  @media screen and (max-width: 740px) and (min-width: 441px) {
+    margin-top: 40px;
+  }
+}
+
+.form-footer {
+  margin-top: 30px;
+  text-align: center;
+
+  a {
+    color: $ui-primary-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.oauth-prompt,
+.follow-prompt {
+  margin-bottom: 30px;
+  text-align: center;
+  color: $ui-primary-color;
+
+  h2 {
+    font-size: 16px;
+    margin-bottom: 30px;
+  }
+
+  strong {
+    color: $ui-secondary-color;
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  @media screen and (max-width: 740px) and (min-width: 441px) {
+    margin-top: 40px;
+  }
+}
+
+.qr-wrapper {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+}
+
+.qr-code {
+  flex: 0 0 auto;
+  background: $simple-background-color;
+  padding: 4px;
+  margin: 0 10px 20px 0;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  display: inline-block;
+
+  svg {
+    display: block;
+    margin: 0;
+  }
+}
+
+.qr-alternative {
+  margin-bottom: 20px;
+  color: $ui-secondary-color;
+  flex: 150px;
+
+  samp {
+    display: block;
+    font-size: 14px;
+  }
+}
+
+.table-form {
+  p {
+    margin-bottom: 15px;
+
+    strong {
+      font-weight: 500;
+
+      @each $lang in $cjk-langs {
+        &:lang(#{$lang}) {
+          font-weight: 700;
+        }
+      }
+    }
+  }
+}
+
+.simple_form,
+.table-form {
+  .warning {
+    box-sizing: border-box;
+    background: rgba($error-value-color, 0.5);
+    color: $primary-text-color;
+    text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);
+    box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);
+    border-radius: 4px;
+    padding: 10px;
+    margin-bottom: 15px;
+
+    a {
+      color: $primary-text-color;
+      text-decoration: underline;
+
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: none;
+      }
+    }
+
+    strong {
+      font-weight: 600;
+      display: block;
+      margin-bottom: 5px;
+
+      @each $lang in $cjk-langs {
+        &:lang(#{$lang}) {
+          font-weight: 700;
+        }
+      }
+
+      .fa {
+        font-weight: 400;
+      }
+    }
+  }
+}
+
+.action-pagination {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+
+  .actions,
+  .pagination {
+    flex: 1 1 auto;
+  }
+
+  .actions {
+    padding: 30px 0;
+    padding-right: 20px;
+    flex: 0 0 auto;
+  }
+}
+
+.post-follow-actions {
+  text-align: center;
+  color: $ui-primary-color;
+
+  div {
+    margin-bottom: 4px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss
new file mode 100644
index 000000000..a8ce12953
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/index.scss
@@ -0,0 +1,22 @@
+@import 'mixins';
+@import 'variables';
+@import 'styles/fonts/roboto';
+@import 'styles/fonts/roboto-mono';
+@import 'styles/fonts/montserrat';
+
+@import 'reset';
+@import 'basics';
+@import 'containers';
+@import 'lists';
+@import 'modal';
+@import 'footer';
+@import 'compact_header';
+@import 'landing_strip';
+@import 'forms';
+@import 'accounts';
+@import 'stream_entries';
+@import 'components/index';
+@import 'about';
+@import 'tables';
+@import 'admin';
+@import 'rtl';
diff --git a/app/javascript/flavours/glitch/styles/landing_strip.scss b/app/javascript/flavours/glitch/styles/landing_strip.scss
new file mode 100644
index 000000000..ffa1e149d
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/landing_strip.scss
@@ -0,0 +1,111 @@
+.landing-strip,
+.memoriam-strip {
+  background: rgba(darken($ui-base-color, 7%), 0.8);
+  color: $ui-primary-color;
+  font-weight: 400;
+  padding: 14px;
+  border-radius: 4px;
+  margin-bottom: 20px;
+  display: flex;
+  align-items: center;
+
+  strong,
+  a {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  a {
+    color: inherit;
+    text-decoration: underline;
+  }
+
+  .logo {
+    width: 30px;
+    height: 30px;
+    flex: 0 0 auto;
+    margin-right: 15px;
+  }
+
+  @media screen and (max-width: 740px) {
+    margin-bottom: 0;
+  }
+}
+
+.memoriam-strip {
+  background: rgba($base-shadow-color, 0.7);
+}
+
+.moved-strip {
+  padding: 14px;
+  border-radius: 4px;
+  background: rgba(darken($ui-base-color, 7%), 0.8);
+  color: $ui-secondary-color;
+  font-weight: 400;
+  margin-bottom: 20px;
+
+  strong,
+  a {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  a {
+    color: inherit;
+    text-decoration: underline;
+
+    &.mention {
+      text-decoration: none;
+
+      span {
+        text-decoration: none;
+      }
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: none;
+
+        span {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+
+  &__message {
+    margin-bottom: 15px;
+
+    .fa {
+      margin-right: 5px;
+      color: $ui-primary-color;
+    }
+  }
+
+  &__card {
+    .detailed-status__display-avatar {
+      position: relative;
+      cursor: pointer;
+    }
+
+    .detailed-status__display-name {
+      margin-bottom: 0;
+      text-decoration: none;
+
+      span {
+        color: $ui-highlight-color;
+        font-weight: 400;
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/lists.scss b/app/javascript/flavours/glitch/styles/lists.scss
new file mode 100644
index 000000000..6019cd800
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/lists.scss
@@ -0,0 +1,19 @@
+.no-list {
+  list-style: none;
+
+  li {
+    display: inline-block;
+    margin: 0 5px;
+  }
+}
+
+.recovery-codes {
+  list-style: none;
+  margin: 0 auto;
+
+  li {
+    font-size: 125%;
+    line-height: 1.5;
+    letter-spacing: 1px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/metadata.scss b/app/javascript/flavours/glitch/styles/metadata.scss
new file mode 100644
index 000000000..484410bef
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/metadata.scss
@@ -0,0 +1,43 @@
+.metadata {
+  $meta-table-border: lighten($ui-base-color, 8%);
+
+  border-collapse: collapse;
+  padding: 0;
+  margin: 15px -15px -15px -15px;
+  border: 0 none;
+  border-top: 1px solid $meta-table-border;
+  border-bottom: 1px solid $meta-table-border;
+
+  td, th {
+    padding: 15px;
+    border: 0 none;
+    border-bottom: 1px solid $meta-table-border;
+    vertical-align: middle;
+  }
+
+  tr:last-child {
+    td, th {
+      border-bottom: 0 none;
+    }
+  }
+
+  td {
+    color: $ui-primary-color;
+    text-align: center;
+    width:100%;
+    padding-left: 0;
+  }
+
+  th {
+    padding-left: 15px;
+    font-weight: bold;
+    text-align: center;
+    width: 94px;
+    color: $ui-secondary-color;
+    background: darken($ui-base-color, 8%);
+  }
+
+  a {
+    color: $classic-highlight-color;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/modal.scss b/app/javascript/flavours/glitch/styles/modal.scss
new file mode 100644
index 000000000..a17476ccb
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/modal.scss
@@ -0,0 +1,20 @@
+.modal-layout {
+  background: $ui-base-color url('~images/wave-modal.png') repeat-x bottom fixed;
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  padding: 0;
+}
+
+.modal-layout__mastodon {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  justify-content: flex-end;
+
+  > * {
+    flex: 1;
+    max-height: 235px;
+    background: url('~images/mastodon-ui.png') no-repeat left bottom / contain;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/reset.scss b/app/javascript/flavours/glitch/styles/reset.scss
new file mode 100644
index 000000000..cc5ba9d7c
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/reset.scss
@@ -0,0 +1,91 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+   v2.0 | 20110126
+   License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+  margin: 0;
+  padding: 0;
+  border: 0;
+  font-size: 100%;
+  font: inherit;
+  vertical-align: baseline;
+}
+
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+  display: block;
+}
+
+body {
+  line-height: 1;
+}
+
+ol, ul {
+  list-style: none;
+}
+
+blockquote, q {
+  quotes: none;
+}
+
+blockquote:before, blockquote:after,
+q:before, q:after {
+  content: '';
+  content: none;
+}
+
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+  background: lighten($ui-base-color, 4%);
+  border: 0px none $base-border-color;
+  border-radius: 50px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: lighten($ui-base-color, 6%);
+}
+
+::-webkit-scrollbar-thumb:active {
+  background: lighten($ui-base-color, 4%);
+}
+
+::-webkit-scrollbar-track {
+  border: 0px none $base-border-color;
+  border-radius: 0;
+  background: rgba($base-overlay-background, 0.1);
+}
+
+::-webkit-scrollbar-track:hover {
+  background: $ui-base-color;
+}
+
+::-webkit-scrollbar-track:active {
+  background: $ui-base-color;
+}
+
+::-webkit-scrollbar-corner {
+  background: transparent;
+}
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
new file mode 100644
index 000000000..77420c84b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -0,0 +1,266 @@
+body.rtl {
+  direction: rtl;
+
+  .column-link__icon,
+  .column-header__icon {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {
+    margin-right: 0;
+    margin-left: 4px;
+  }
+
+  .navigation-bar__profile {
+    margin-left: 0;
+    margin-right: 8px;
+  }
+
+  .search__input {
+    padding-right: 10px;
+    padding-left: 30px;
+  }
+
+  .search__icon .fa {
+    right: auto;
+    left: 10px;
+  }
+
+  .column-header__buttons {
+    left: 0;
+    right: auto;
+    margin-left: -15px;
+    margin-right: 0;
+  }
+
+  .column-inline-form .icon-button {
+    margin-left: 0;
+    margin-right: 5px;
+  }
+
+  .column-header__links .text-btn {
+    margin-left: 10px;
+    margin-right: 0;
+  }
+
+  .account__avatar-wrapper {
+    float: right;
+  }
+
+  .column-header__back-button {
+    padding-left: 5px;
+    padding-right: 0;
+  }
+
+  .column-header__setting-arrows {
+    float: left;
+  }
+
+  .setting-toggle {
+    margin-left: 0;
+    margin-right: 8px;
+  }
+
+  .setting-meta__label {
+    float: left;
+  }
+
+  .status__avatar {
+    left: auto;
+    right: 10px;
+  }
+
+  .status,
+  .activity-stream .status.light {
+    padding-left: 10px;
+    padding-right: 68px;
+  }
+
+  .status__info .status__display-name,
+  .activity-stream .status.light .status__display-name {
+    padding-left: 25px;
+    padding-right: 0;
+  }
+
+  .activity-stream .pre-header {
+    padding-right: 68px;
+    padding-left: 0;
+  }
+
+  .status__prepend {
+    margin-left: 0;
+    margin-right: 68px;
+  }
+
+  .status__prepend-icon-wrapper {
+    left: auto;
+    right: -26px;
+  }
+
+  .activity-stream .pre-header .pre-header__icon {
+    left: auto;
+    right: 42px;
+  }
+
+  .account__avatar-overlay-overlay {
+    right: auto;
+    left: 0;
+  }
+
+  .column-back-button--slim-button {
+    right: auto;
+    left: 0;
+  }
+
+  .status__relative-time,
+  .activity-stream .status.light .status__header .status__meta {
+    float: left;
+  }
+
+  .activity-stream .detailed-status.light .detailed-status__display-name > div {
+    float: right;
+    margin-right: 0;
+    margin-left: 10px;
+  }
+
+  .activity-stream .detailed-status.light .detailed-status__meta span > span {
+    margin-left: 0;
+    margin-right: 6px;
+  }
+
+  .status__action-bar-button {
+    float: right;
+    margin-right: 0;
+    margin-left: 18px;
+  }
+
+  .status__action-bar-dropdown {
+    float: right;
+  }
+
+  .privacy-dropdown__dropdown {
+    margin-left: 0;
+    margin-right: 40px;
+  }
+
+  .privacy-dropdown__option__icon {
+    margin-left: 10px;
+    margin-right: 0;
+  }
+
+  .detailed-status__display-avatar {
+    margin-right: 0;
+    margin-left: 10px;
+    float: right;
+  }
+
+  .detailed-status__favorites,
+  .detailed-status__reblogs {
+    margin-left: 0;
+    margin-right: 6px;
+  }
+
+  .fa-ul {
+    margin-left: 0;
+    margin-left: 2.14285714em;
+  }
+
+  .fa-li {
+    left: auto;
+    right: -2.14285714em;
+  }
+
+  .admin-wrapper .sidebar ul a i.fa,
+  a.table-action-link i.fa {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .simple_form .check_boxes .checkbox label,
+  .simple_form .input.with_label.boolean label.checkbox {
+    padding-left: 0;
+    padding-right: 25px;
+  }
+
+  .simple_form .check_boxes .checkbox input[type="checkbox"],
+  .simple_form .input.boolean input[type="checkbox"] {
+    left: auto;
+    right: 0;
+  }
+
+  .simple_form .input-with-append .input input {
+    padding-left: 127px;
+    padding-right: 0;
+  }
+
+  .simple_form .input-with-append .append {
+    right: auto;
+    left: 0;
+  }
+
+  .table th,
+  .table td {
+    text-align: right;
+  }
+
+  .filters .filter-subset {
+    margin-right: 0;
+    margin-left: 45px;
+  }
+
+  .landing-page .header-wrapper .mascot {
+    right: 60px;
+    left: auto;
+  }
+
+  .landing-page .header .hero .floats .float-1 {
+    left: -120px;
+    right: auto;
+  }
+
+  .landing-page .header .hero .floats .float-2 {
+    left: 210px;
+    right: auto;
+  }
+
+  .landing-page .header .hero .floats .float-3 {
+    left: 110px;
+    right: auto;
+  }
+
+  .landing-page .header .links .brand img {
+    left: 0;
+  }
+
+  .landing-page .fa-external-link {
+    padding-right: 5px;
+    padding-left: 0 !important;
+  }
+
+  .landing-page .features #mastodon-timeline {
+    margin-right: 0;
+    margin-left: 30px;
+  }
+
+  @media screen and (min-width: 631px) {
+    .column,
+    .drawer {
+      padding-left: 5px;
+      padding-right: 5px;
+
+      &:first-child {
+        padding-left: 5px;
+        padding-right: 10px;
+      }
+    }
+
+    .columns-area > div {
+      .column,
+      .drawer {
+        padding-left: 5px;
+        padding-right: 5px;
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/stream_entries.scss b/app/javascript/flavours/glitch/styles/stream_entries.scss
new file mode 100644
index 000000000..343e3591f
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/stream_entries.scss
@@ -0,0 +1,356 @@
+.activity-stream {
+  clear: both;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+  .entry {
+    background: $simple-background-color;
+
+    .detailed-status.light,
+    .status.light {
+      border-bottom: 1px solid $ui-secondary-color;
+      animation: none;
+    }
+
+    &:last-child {
+      &,
+      .detailed-status.light,
+      .status.light {
+        border-bottom: 0;
+        border-radius: 0 0 4px 4px;
+      }
+    }
+
+    &:first-child {
+      &,
+      .detailed-status.light,
+      .status.light {
+        border-radius: 4px 4px 0 0;
+      }
+
+      &:last-child {
+        &,
+        .detailed-status.light,
+        .status.light {
+          border-radius: 4px;
+        }
+      }
+    }
+
+    @media screen and (max-width: 740px) {
+      &,
+      .detailed-status.light,
+      .status.light {
+        border-radius: 0 !important;
+      }
+    }
+  }
+
+  &.with-header {
+    .entry {
+      &:first-child {
+        &,
+        .detailed-status.light,
+        .status.light {
+          border-radius: 0;
+        }
+
+        &:last-child {
+          &,
+          .detailed-status.light,
+          .status.light {
+            border-radius: 0 0 4px 4px;
+          }
+        }
+      }
+    }
+  }
+
+  .status.light {
+    padding: 14px 14px 14px (48px + 14px * 2);
+    position: relative;
+    min-height: 48px;
+    cursor: default;
+
+    .status__header {
+      font-size: 15px;
+
+      .status__meta {
+        float: right;
+        font-size: 14px;
+
+        .status__relative-time {
+          color: $ui-primary-color;
+        }
+      }
+    }
+
+    .status__display-name {
+      display: block;
+      max-width: 100%;
+      padding-right: 25px;
+      color: $ui-base-color;
+    }
+
+    .status__avatar {
+      position: absolute;
+      left: 14px;
+      top: 14px;
+      width: 48px;
+      height: 48px;
+      @include avatar-size(48px);
+
+      & > div {
+        width: 48px;
+        height: 48px;
+        @include avatar-size(48px);
+      }
+
+      img {
+        display: block;
+        border-radius: 4px;
+        @include avatar-radius();
+      }
+    }
+
+    .display-name {
+      display: block;
+      max-width: 100%;
+      //overflow: hidden;
+      //white-space: nowrap;
+      //text-overflow: ellipsis;
+
+      strong {
+        font-weight: 500;
+        color: $ui-base-color;
+
+        @each $lang in $cjk-langs {
+          &:lang(#{$lang}) {
+            font-weight: 700;
+          }
+        }
+      }
+
+      span {
+        font-size: 14px;
+        color: $ui-primary-color;
+      }
+    }
+
+    .status__content {
+      color: $ui-base-color;
+
+      a {
+        color: $ui-highlight-color;
+      }
+
+      a.status__content__spoiler-link {
+        color: $primary-text-color;
+        background: $ui-primary-color;
+
+        &:hover {
+          background: lighten($ui-primary-color, 8%);
+        }
+      }
+    }
+  }
+
+  .detailed-status.light {
+    padding: 14px;
+    background: $simple-background-color;
+    cursor: default;
+
+    .detailed-status__display-name {
+      display: block;
+      overflow: hidden;
+      margin-bottom: 15px;
+
+      & > div {
+        float: left;
+        margin-right: 10px;
+      }
+
+      .display-name {
+        display: block;
+        max-width: 100%;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+
+        strong {
+          font-weight: 500;
+          color: $ui-base-color;
+
+          @each $lang in $cjk-langs {
+            &:lang(#{$lang}) {
+              font-weight: 700;
+            }
+          }
+        }
+
+        span {
+          font-size: 14px;
+          color: $ui-primary-color;
+        }
+      }
+    }
+
+    .avatar {
+      width: 48px;
+      height: 48px;
+      @include avatar-size(48px);
+
+      img {
+        display: block;
+        border-radius: 4px;
+        @include avatar-radius();
+      }
+    }
+
+    .status__content {
+      color: $ui-base-color;
+
+      a {
+        color: $ui-highlight-color;
+      }
+
+      a.status__content__spoiler-link {
+        color: $primary-text-color;
+        background: $ui-primary-color;
+
+        &:hover {
+          background: lighten($ui-primary-color, 8%);
+        }
+      }
+    }
+
+    .detailed-status__meta {
+      margin-top: 15px;
+      color: $ui-primary-color;
+      font-size: 14px;
+      line-height: 18px;
+
+      a {
+        color: inherit;
+      }
+
+      span > span {
+        font-weight: 500;
+        font-size: 12px;
+        margin-left: 6px;
+        display: inline-block;
+      }
+    }
+
+    .status-card {
+      border-color: lighten($ui-secondary-color, 4%);
+      color: darken($ui-primary-color, 4%);
+
+      &:hover {
+        background: lighten($ui-secondary-color, 4%);
+      }
+    }
+
+    .status-card__title,
+    .status-card__description {
+      color: $ui-base-color;
+    }
+
+    .status-card__image {
+      background: $ui-secondary-color;
+    }
+  }
+
+  .media-spoiler {
+    background: $ui-primary-color;
+    color: $white;
+    transition: all 100ms linear;
+
+    &:hover,
+    &:active,
+    &:focus {
+      background: darken($ui-primary-color, 5%);
+      color: unset;
+    }
+  }
+
+  .pre-header {
+    padding: 14px 0;
+    padding-left: (48px + 14px * 2);
+    padding-bottom: 0;
+    margin-bottom: -4px;
+    color: $ui-primary-color;
+    font-size: 14px;
+    position: relative;
+
+    .pre-header__icon {
+      position: absolute;
+      left: (48px + 14px * 2 - 30px);
+    }
+
+    .status__display-name.muted strong {
+      color: $ui-primary-color;
+    }
+  }
+
+  .open-in-web-link {
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.embed {
+  .activity-stream {
+    box-shadow: none;
+
+    .entry {
+
+      .detailed-status.light {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: space-between;
+        align-items: flex-start;
+
+        .detailed-status__display-name {
+          flex: 1;
+          margin: 0 5px 15px 0;
+        }
+
+        .button.button-secondary.logo-button {
+          flex: 0 auto;
+          font-size: 14px;
+
+          svg {
+            width: 20px;
+            height: auto;
+            vertical-align: middle;
+            margin-right: 5px;
+
+            path:first-child {
+              fill: $ui-primary-color;
+            }
+
+            path:last-child {
+              fill: $simple-background-color;
+            }
+          }
+
+          &:active,
+          &:focus,
+          &:hover {
+            svg path:first-child {
+              fill: lighten($ui-primary-color, 4%);
+            }
+          }
+        }
+
+        .status__content,
+        .detailed-status__meta {
+          flex: 100%;
+        }
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
new file mode 100644
index 000000000..92870e679
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -0,0 +1,82 @@
+.table {
+  width: 100%;
+  max-width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+
+  th,
+  td {
+    padding: 8px;
+    line-height: 18px;
+    vertical-align: top;
+    border-top: 1px solid $ui-base-color;
+    text-align: left;
+  }
+
+  & > thead > tr > th {
+    vertical-align: bottom;
+    border-bottom: 2px solid $ui-base-color;
+    border-top: 0;
+    font-weight: 500;
+  }
+
+  & > tbody > tr > th {
+    font-weight: 500;
+  }
+
+  & > tbody > tr:nth-child(odd) > td,
+  & > tbody > tr:nth-child(odd) > th {
+    background: $ui-base-color;
+  }
+
+  a {
+    color: $ui-highlight-color;
+    text-decoration: underline;
+
+    &:hover {
+      text-decoration: none;
+    }
+  }
+
+  strong {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  &.inline-table > tbody > tr:nth-child(odd) > td,
+  &.inline-table > tbody > tr:nth-child(odd) > th {
+    background: transparent;
+  }
+}
+
+.table-wrapper {
+  overflow: auto;
+  margin-bottom: 20px;
+}
+
+samp {
+  font-family: 'mastodon-font-monospace', monospace;
+}
+
+a.table-action-link {
+  text-decoration: none;
+  display: inline-block;
+  margin-right: 5px;
+  padding: 0 10px;
+  color: rgba($primary-text-color, 0.7);
+  font-weight: 500;
+
+  &:hover {
+    color: $primary-text-color;
+  }
+
+  i.fa {
+    font-weight: 400;
+    margin-right: 5px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss
new file mode 100644
index 000000000..e8e2bc9e3
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/variables.scss
@@ -0,0 +1,38 @@
+// Commonly used web colors
+$black: #000000;            // Black
+$white: #ffffff;            // White
+$success-green: #79bd9a;    // Padua
+$error-red: #df405a;        // Cerise
+$warning-red: #ff5050;      // Sunset Orange
+$gold-star: #ca8f04;        // Dark Goldenrod
+
+// Values from the classic Mastodon UI
+$classic-base-color: #282c37;         // Midnight Express
+$classic-primary-color: #9baec8;      // Echo Blue
+$classic-secondary-color: #d9e1e8;    // Pattens Blue
+$classic-highlight-color: #2b90d9;    // Summer Sky
+
+// Variables for defaults in UI
+$base-shadow-color: $black !default;
+$base-overlay-background: $black !default;
+$base-border-color: $white !default;
+$simple-background-color: $white !default;
+$primary-text-color: $white !default;
+$valid-value-color: $success-green !default;
+$error-value-color: $error-red !default;
+
+// Tell UI to use selected colors
+$ui-base-color: $classic-base-color !default;                  // Darkest
+$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
+$ui-primary-color: $classic-primary-color !default;            // Lighter
+$ui-secondary-color: $classic-secondary-color !default;        // Lightest
+$ui-highlight-color: $classic-highlight-color !default;        // Vibrant
+
+// Language codes that uses CJK fonts
+$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
+
+// Avatar border size (8% default, 100% for rounded avatars)
+$ui-avatar-border-size: 8%;
+
+// More variables
+$dismiss-overlay-width: 4rem;
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
new file mode 100644
index 000000000..0c8342c44
--- /dev/null
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -0,0 +1,46 @@
+#  (REQUIRED) The location of the pack files.
+pack:
+  about: packs/about.js
+  admin: packs/public.js
+  auth:
+  common:
+    filename: packs/common.js
+    stylesheet: true
+  embed: packs/public.js
+  error:
+  home:
+    filename: packs/home.js
+    preload:
+    - flavours/glitch/async/drawer
+    - flavours/glitch/async/getting_started
+    - flavours/glitch/async/home_timeline
+    - flavours/glitch/async/notifications
+  mailer:
+  modal:
+  public: packs/public.js
+  settings:
+  share: packs/share.js
+
+#  (OPTIONAL) The directory which contains localization files for
+#  the flavour, relative to this directory. The contents of this
+#  directory must be `.js` or `.json` files whose names correspond to
+#  language tags and whose default exports are a messages object.
+locales: locales
+
+#  (OPTIONAL) A file to use as the preview screenshot for the flavour,
+#  or an array thereof. These filenames must be unique across all
+#  images (regardless of path), so it's a good idea to namespace them
+#  to your theme. It's up to you to let webpack know to compile them.
+screenshot: glitch-preview.jpg
+
+#  (OPTIONAL) The directory which contains the pack files.
+#  Defaults to the theme directory (`app/javascript/themes/[theme]`),
+#  which should be sufficient for like 99% of use-cases lol.
+
+#      pack_directory: app/javascript/packs
+
+#  (OPTIONAL) By default the theme will fallback to the default theme
+#  if a particular pack is not provided. You can specify different
+#  fallbacks here, or disable fallback behaviours altogether by
+#  specifying a `null` value.
+fallback:
diff --git a/app/javascript/flavours/glitch/util/api.js b/app/javascript/flavours/glitch/util/api.js
new file mode 100644
index 000000000..0be08d7fd
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/api.js
@@ -0,0 +1,34 @@
+import axios from 'axios';
+import ready from './ready';
+import LinkHeader from './link_header';
+
+export const getLinks = response => {
+  const value = response.headers.link;
+
+  if (!value) {
+    return { refs: [] };
+  }
+
+  return LinkHeader.parse(value);
+};
+
+let csrfHeader = {};
+function setCSRFHeader() {
+  const csrfToken = document.querySelector('meta[name=csrf-token]').content;
+  csrfHeader['X-CSRF-Token'] = csrfToken;
+}
+ready(setCSRFHeader);
+
+export default getState => axios.create({
+  headers: Object.assign(csrfHeader, getState ? {
+    'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
+  } : {}),
+
+  transformResponse: [function (data) {
+    try {
+      return JSON.parse(data);
+    } catch(Exception) {
+      return data;
+    }
+  }],
+});
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
new file mode 100644
index 000000000..2aa9659e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -0,0 +1,135 @@
+export function EmojiPicker () {
+  return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/util/emoji/emoji_picker');
+}
+
+export function Drawer () {
+  return import(/* webpackChunkName: "flavours/glitch/async/drawer" */'flavours/glitch/features/drawer');
+}
+
+export function Notifications () {
+  return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'flavours/glitch/features/notifications');
+}
+
+export function HomeTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/home_timeline" */'flavours/glitch/features/home_timeline');
+}
+
+export function PublicTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/public_timeline" */'flavours/glitch/features/public_timeline');
+}
+
+export function CommunityTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'flavours/glitch/features/community_timeline');
+}
+
+export function HashtagTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/hashtag_timeline" */'flavours/glitch/features/hashtag_timeline');
+}
+
+export function ListTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/list_timeline" */'flavours/glitch/features/list_timeline');
+}
+
+export function Lists () {
+  return import(/* webpackChunkName: "flavours/glitch/async/lists" */'flavours/glitch/features/lists');
+}
+
+export function ListEditor () {
+  return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'flavours/glitch/features/list_editor');
+}
+
+export function DirectTimeline() {
+  return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'flavours/glitch/features/direct_timeline');
+}
+
+export function Status () {
+  return import(/* webpackChunkName: "flavours/glitch/async/status" */'flavours/glitch/features/status');
+}
+
+export function GettingStarted () {
+  return import(/* webpackChunkName: "flavours/glitch/async/getting_started" */'flavours/glitch/features/getting_started');
+}
+
+export function KeyboardShortcuts () {
+  return import(/* webpackChunkName: "flavours/glitch/async/keyboard_shortcuts" */'flavours/glitch/features/keyboard_shortcuts');
+}
+
+export function PinnedStatuses () {
+  return import(/* webpackChunkName: "flavours/glitch/async/pinned_statuses" */'flavours/glitch/features/pinned_statuses');
+}
+
+export function AccountTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/account_timeline" */'flavours/glitch/features/account_timeline');
+}
+
+export function AccountGallery () {
+  return import(/* webpackChunkName: "flavours/glitch/async/account_gallery" */'flavours/glitch/features/account_gallery');
+}
+
+export function Followers () {
+  return import(/* webpackChunkName: "flavours/glitch/async/followers" */'flavours/glitch/features/followers');
+}
+
+export function Following () {
+  return import(/* webpackChunkName: "flavours/glitch/async/following" */'flavours/glitch/features/following');
+}
+
+export function Reblogs () {
+  return import(/* webpackChunkName: "flavours/glitch/async/reblogs" */'flavours/glitch/features/reblogs');
+}
+
+export function Favourites () {
+  return import(/* webpackChunkName: "flavours/glitch/async/favourites" */'flavours/glitch/features/favourites');
+}
+
+export function FollowRequests () {
+  return import(/* webpackChunkName: "flavours/glitch/async/follow_requests" */'flavours/glitch/features/follow_requests');
+}
+
+export function GenericNotFound () {
+  return import(/* webpackChunkName: "flavours/glitch/async/generic_not_found" */'flavours/glitch/features/generic_not_found');
+}
+
+export function FavouritedStatuses () {
+  return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses');
+}
+
+export function Blocks () {
+  return import(/* webpackChunkName: "flavours/glitch/async/blocks" */'flavours/glitch/features/blocks');
+}
+
+export function Mutes () {
+  return import(/* webpackChunkName: "flavours/glitch/async/mutes" */'flavours/glitch/features/mutes');
+}
+
+export function OnboardingModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/onboarding_modal" */'flavours/glitch/features/ui/components/onboarding_modal');
+}
+
+export function MuteModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/mute_modal" */'flavours/glitch/features/ui/components/mute_modal');
+}
+
+export function ReportModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/report_modal" */'flavours/glitch/features/ui/components/report_modal');
+}
+
+export function SettingsModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'flavours/glitch/features/local_settings');
+}
+
+export function MediaGallery () {
+  return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'flavours/glitch/components/media_gallery');
+}
+
+export function Video () {
+  return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video');
+}
+
+export function EmbedModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal');
+}
+
+export function GettingStartedMisc () {
+  return import(/* webpackChunkName: "flavours/glitch/async/getting_started_misc" */'flavours/glitch/features/getting_started_misc');
+}
diff --git a/app/javascript/flavours/glitch/util/base_polyfills.js b/app/javascript/flavours/glitch/util/base_polyfills.js
new file mode 100644
index 000000000..7856b26f9
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/base_polyfills.js
@@ -0,0 +1,18 @@
+import 'intl';
+import 'intl/locale-data/jsonp/en';
+import 'es6-symbol/implement';
+import includes from 'array-includes';
+import assign from 'object-assign';
+import isNaN from 'is-nan';
+
+if (!Array.prototype.includes) {
+  includes.shim();
+}
+
+if (!Object.assign) {
+  Object.assign = assign;
+}
+
+if (!Number.isNaN) {
+  Number.isNaN = isNaN;
+}
diff --git a/app/javascript/flavours/glitch/util/bio_metadata.js b/app/javascript/flavours/glitch/util/bio_metadata.js
new file mode 100644
index 000000000..3c9c2b8b1
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/bio_metadata.js
@@ -0,0 +1,331 @@
+/*
+
+`util/bio_metadata`
+===================
+
+>   For more information on the contents of this file, please contact:
+>
+>   - kibigo! [@kibi@glitch.social]
+
+This file provides two functions for dealing with bio metadata. The
+functions are:
+
+ -  __`processBio(content)` :__
+    Processes `content` to extract any frontmatter. The returned
+    object has two properties: `text`, which contains the text of
+    `content` sans-frontmatter, and `metadata`, which is an array
+    of key-value pairs (in two-element array format). If no
+    frontmatter was provided in `content`, then `metadata` will be
+    an empty array.
+
+ -  __`createBio(note, data)` :__
+    Reverses the process in `processBio()`; takes a `note` and an
+    array of two-element arrays (which should give keys and values)
+    and outputs a string containing a well-formed bio with
+    frontmatter.
+
+*/
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*********************************************************************\
+
+                                       To my lovely code maintainers,
+
+  The syntax recognized by the Mastodon frontend for its bio metadata
+  feature is a subset of that provided by the YAML 1.2 specification.
+  In particular, Mastodon recognizes metadata which is provided as an
+  implicit YAML map, where each key-value pair takes up only a single
+  line (no multi-line values are permitted). To simplify the level of
+  processing required, Mastodon metadata frontmatter has been limited
+  to only allow those characters in the `c-printable` set, as defined
+  by the YAML 1.2 specification, instead of permitting those from the
+  `nb-json` characters inside double-quoted strings like YAML proper.
+    ¶ It is important to note that Mastodon only borrows the *syntax*
+  of YAML, not its semantics. This is to say, Mastodon won't make any
+  attempt to interpret the data it receives. `true` will not become a
+  boolean; `56` will not be interpreted as a number. Rather, each key
+  and every value will be read as a string, and as a string they will
+  remain. The order of the pairs is unchanged, and any duplicate keys
+  are preserved. However, YAML escape sequences will be replaced with
+  the proper interpretations according to the YAML 1.2 specification.
+    ¶ The implementation provided below interprets `<br>` as `\n` and
+  allows for an open <p> tag at the beginning of the bio. It replaces
+  the escaped character entities `&apos;` and `&quot;` with single or
+  double quotes, respectively, prior to processing. However, no other
+  escaped characters are replaced, not even those which might have an
+  impact on the syntax otherwise. These minor allowances are provided
+  because the Mastodon backend will insert these things automatically
+  into a bio before sending it through the API, so it is important we
+  account for them. Aside from this, the YAML frontmatter must be the
+  very first thing in the bio, leading with three consecutive hyphen-
+  minues (`---`), and ending with the same or, alternatively, instead
+  with three periods (`...`). No limits have been set with respect to
+  the number of characters permitted in the frontmatter, although one
+  should note that only limited space is provided for them in the UI.
+    ¶ The regular expression used to check the existence of, and then
+  process, the YAML frontmatter has been split into a number of small
+  components in the code below, in the vain hope that it will be much
+  easier to read and to maintain. I leave it to the future readers of
+  this code to determine the extent of my successes in this endeavor.
+
+  UPDATE 19 Oct 2017: We no longer allow character escapes inside our
+  double-quoted strings for ease of processing. We now internally use
+  the name "ƔAML" in our code to clarify that this is Not Quite YAML.
+
+                                       Sending love + warmth eternal,
+                                       - kibigo [@kibi@glitch.social]
+
+\*********************************************************************/
+
+/*  "u" FLAG COMPATABILITY  */
+
+let compat_mode = false;
+try {
+  new RegExp('.', 'u');
+} catch (e) {
+  compat_mode = true;
+}
+
+/*  CONVENIENCE FUNCTIONS  */
+
+const unirex = str => compat_mode ? new RegExp(str) : new RegExp(str, 'u');
+const rexstr = exp => '(?:' + exp.source + ')';
+
+/*  CHARACTER CLASSES  */
+
+const DOCUMENT_START    = /^/;
+const DOCUMENT_END      = /$/;
+const ALLOWED_CHAR      =  unirex( //  `c-printable` in the YAML 1.2 spec.
+  compat_mode ? '[\t\n\r\x20-\x7e\x85\xa0-\ufffd]' : '[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]'
+);
+const WHITE_SPACE       = /[ \t]/;
+const LINE_BREAK        = /\r?\n|\r|<br\s*\/?>/;
+const INDICATOR         = /[-?:,[\]{}&#*!|>'"%@`]/;
+const FLOW_CHAR         = /[,[\]{}]/;
+
+/*  NEGATED CHARACTER CLASSES  */
+
+const NOT_WHITE_SPACE   = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]');
+const NOT_LINE_BREAK    = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]');
+const NOT_INDICATOR     = unirex('(?!' + rexstr(INDICATOR) + ')[^]');
+const NOT_FLOW_CHAR     = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]');
+const NOT_ALLOWED_CHAR  = unirex(
+  '(?!' + rexstr(ALLOWED_CHAR) + ')[^]'
+);
+
+/*  BASIC CONSTRUCTS  */
+
+const ANY_WHITE_SPACE   = unirex(rexstr(WHITE_SPACE) + '*');
+const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*');
+const NEW_LINE          = unirex(
+  rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
+);
+const SOME_NEW_LINES    = unirex(
+  '(?:' + rexstr(NEW_LINE) + ')+'
+);
+const POSSIBLE_STARTS   = unirex(
+  rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
+);
+const POSSIBLE_ENDS     = unirex(
+  rexstr(SOME_NEW_LINES) + '|' +
+  rexstr(DOCUMENT_END) + '|' +
+  rexstr(/<\/p>/)
+);
+const QUOTE_CHAR         = unirex(
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')[^"]'
+);
+const ANY_QUOTE_CHAR    = unirex(
+  rexstr(QUOTE_CHAR) + '*'
+);
+
+const ESCAPED_APOS      = unirex(
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
+);
+const ANY_ESCAPED_APOS  = unirex(
+  rexstr(ESCAPED_APOS) + '*'
+);
+const FIRST_KEY_CHAR    = unirex(
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  rexstr(NOT_INDICATOR) + '|' +
+  rexstr(/[?:-]/) +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  '(?=' + rexstr(NOT_FLOW_CHAR) + ')'
+);
+const FIRST_VALUE_CHAR  = unirex(
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  rexstr(NOT_INDICATOR) + '|' +
+  rexstr(/[?:-]/) +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+  //  Flow indicators are allowed in values.
+);
+const LATER_KEY_CHAR    = unirex(
+  rexstr(WHITE_SPACE) + '|' +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
+  rexstr(/[^:#]#?/) + '|' +
+  rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+);
+const LATER_VALUE_CHAR  = unirex(
+  rexstr(WHITE_SPACE) + '|' +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  //  Flow indicators are allowed in values.
+  rexstr(/[^:#]#?/) + '|' +
+  rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+);
+
+/*  YAML CONSTRUCTS  */
+
+const ƔAML_START        = unirex(
+  rexstr(ANY_WHITE_SPACE) + '---'
+);
+const ƔAML_END          = unirex(
+  rexstr(ANY_WHITE_SPACE) + '(?:---|\.\.\.)'
+);
+const ƔAML_LOOKAHEAD    = unirex(
+  '(?=' +
+    rexstr(ƔAML_START) +
+    rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
+    rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS) +
+  ')'
+);
+const ƔAML_DOUBLE_QUOTE = unirex(
+  '"' + rexstr(ANY_QUOTE_CHAR) + '"'
+);
+const ƔAML_SINGLE_QUOTE = unirex(
+  '\'' + rexstr(ANY_ESCAPED_APOS) + '\''
+);
+const ƔAML_SIMPLE_KEY   = unirex(
+  rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
+);
+const ƔAML_SIMPLE_VALUE = unirex(
+  rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
+);
+const ƔAML_KEY          = unirex(
+  rexstr(ƔAML_DOUBLE_QUOTE) + '|' +
+  rexstr(ƔAML_SINGLE_QUOTE) + '|' +
+  rexstr(ƔAML_SIMPLE_KEY)
+);
+const ƔAML_VALUE        = unirex(
+  rexstr(ƔAML_DOUBLE_QUOTE) + '|' +
+  rexstr(ƔAML_SINGLE_QUOTE) + '|' +
+  rexstr(ƔAML_SIMPLE_VALUE)
+);
+const ƔAML_SEPARATOR    = unirex(
+  rexstr(ANY_WHITE_SPACE) +
+  ':' + rexstr(WHITE_SPACE) +
+  rexstr(ANY_WHITE_SPACE)
+);
+const ƔAML_LINE         = unirex(
+  '(' + rexstr(ƔAML_KEY) + ')' +
+  rexstr(ƔAML_SEPARATOR) +
+  '(' + rexstr(ƔAML_VALUE) + ')'
+);
+
+/*  FRONTMATTER REGEX  */
+
+const ƔAML_FRONTMATTER  = unirex(
+  rexstr(POSSIBLE_STARTS) +
+  rexstr(ƔAML_LOOKAHEAD) +
+  rexstr(ƔAML_START) + rexstr(SOME_NEW_LINES) +
+  '(?:' +
+    rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE) + rexstr(SOME_NEW_LINES) +
+  '){0,5}' +
+  rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS)
+);
+
+/*  SEARCHES  */
+
+const FIND_ƔAML_LINE    = unirex(
+  rexstr(NEW_LINE) + rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE)
+);
+
+/*  STRING PROCESSING  */
+
+function processString (str) {
+  switch (str.charAt(0)) {
+  case '"':
+    return str.substring(1, str.length - 1);
+  case '\'':
+    return str
+      .substring(1, str.length - 1)
+      .replace(/''/g, '\'');
+  default:
+    return str;
+  }
+}
+
+/*  BIO PROCESSING  */
+
+export function processBio(content) {
+  content = content.replace(/&quot;/g, '"').replace(/&apos;/g, '\'');
+  let result = {
+    text: content,
+    metadata: [],
+  };
+  let ɣaml = content.match(ƔAML_FRONTMATTER);
+  if (!ɣaml) {
+    return result;
+  } else {
+    ɣaml = ɣaml[0];
+  }
+  const start = content.search(ƔAML_START);
+  const end = start + ɣaml.length - ɣaml.search(ƔAML_START);
+  result.text = content.substr(end);
+  let metadata = null;
+  let query = new RegExp(rexstr(FIND_ƔAML_LINE), 'g');  //  Some browsers don't allow flags unless both args are strings
+  while ((metadata = query.exec(ɣaml))) {
+    result.metadata.push([
+      processString(metadata[1]),
+      processString(metadata[2]),
+    ]);
+  }
+  return result;
+}
+
+/*  BIO CREATION  */
+
+export function createBio(note, data) {
+  if (!note) note = '';
+  let frontmatter = '';
+  if ((data && data.length) || note.match(/^\s*---\s+/)) {
+    if (!data) frontmatter = '---\n...\n';
+    else {
+      frontmatter += '---\n';
+      for (let i = 0; i < data.length; i++) {
+        let key = '' + data[i][0];
+        let val = '' + data[i][1];
+
+        //  Key processing
+        if (key === (key.match(ƔAML_SIMPLE_KEY) || [])[0]) /*  do nothing  */;
+        else if (key === (key.match(ANY_QUOTE_CHAR) || [])[0]) key = '"' + key + '"';
+        else {
+          key = key
+            .replace(/'/g, '\'\'')
+            .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�');
+          key = '\'' + key + '\'';
+        }
+
+        //  Value processing
+        if (val === (val.match(ƔAML_SIMPLE_VALUE) || [])[0]) /*  do nothing  */;
+        else if (val === (val.match(ANY_QUOTE_CHAR) || [])[0]) val = '"' + val + '"';
+        else {
+          key = key
+            .replace(/'/g, '\'\'')
+            .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�');
+          key = '\'' + key + '\'';
+        }
+
+        frontmatter += key + ': ' + val + '\n';
+      }
+      frontmatter += '...\n';
+    }
+  }
+  return frontmatter + note;
+}
diff --git a/app/javascript/flavours/glitch/util/counter.js b/app/javascript/flavours/glitch/util/counter.js
new file mode 100644
index 000000000..700ba2163
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/counter.js
@@ -0,0 +1,9 @@
+import { urlRegex } from './url_regex';
+
+const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
+
+export function countableText(inputText) {
+  return inputText
+    .replace(urlRegex, urlPlaceholder)
+    .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3');
+};
diff --git a/app/javascript/flavours/glitch/util/dom_helpers.js b/app/javascript/flavours/glitch/util/dom_helpers.js
new file mode 100644
index 000000000..3e1f4a26d
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/dom_helpers.js
@@ -0,0 +1,14 @@
+//  Package imports.
+import detectPassiveEvents from 'detect-passive-events';
+
+//  This will either be a passive lister options object (if passive
+//  events are supported), or `false`.
+export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+//  Focuses the root element.
+export function focusRoot () {
+  let e;
+  if (document && (e = document.querySelector('.ui')) && (e = e.parentElement)) {
+    e.focus();
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_compressed.js b/app/javascript/flavours/glitch/util/emoji/emoji_compressed.js
new file mode 100644
index 000000000..e5b834a74
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/emoji_compressed.js
@@ -0,0 +1,93 @@
+// @preval
+// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
+// This file contains the compressed version of the emoji data from
+// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
+// It's designed to be emitted in an array format to take up less space
+// over the wire.
+
+const { unicodeToFilename } = require('./unicode_to_filename');
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const emojiMap         = require('./emoji_map.json');
+const { emojiIndex } = require('emoji-mart');
+const { default: emojiMartData } = require('emoji-mart/dist/data');
+
+const excluded       = ['®', '©', '™'];
+const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿'];
+const shortcodeMap   = {};
+
+const shortCodesToEmojiData = {};
+const emojisWithoutShortCodes = [];
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+  shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
+});
+
+const stripModifiers = unicode => {
+  skins.forEach(tone => {
+    unicode = unicode.replace(tone, '');
+  });
+
+  return unicode;
+};
+
+Object.keys(emojiMap).forEach(key => {
+  if (excluded.includes(key)) {
+    delete emojiMap[key];
+    return;
+  }
+
+  const normalizedKey = stripModifiers(key);
+  let shortcode       = shortcodeMap[normalizedKey];
+
+  if (!shortcode) {
+    shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
+  }
+
+  const filename = emojiMap[key];
+
+  const filenameData = [key];
+
+  if (unicodeToFilename(key) !== filename) {
+    // filename can't be derived using unicodeToFilename
+    filenameData.push(filename);
+  }
+
+  if (typeof shortcode === 'undefined') {
+    emojisWithoutShortCodes.push(filenameData);
+  } else {
+    if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
+      shortCodesToEmojiData[shortcode] = [[]];
+    }
+    shortCodesToEmojiData[shortcode][0].push(filenameData);
+  }
+});
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+  const { native } = emojiIndex.emojis[key];
+  let { short_names, search, unified } = emojiMartData.emojis[key];
+  if (short_names[0] !== key) {
+    throw new Error('The compresser expects the first short_code to be the ' +
+      'key. It may need to be rewritten if the emoji change such that this ' +
+      'is no longer the case.');
+  }
+
+  short_names = short_names.slice(1); // first short name can be inferred from the key
+
+  const searchData = [native, short_names, search];
+  if (unicodeToUnifiedName(native) !== unified) {
+    // unified name can't be derived from unicodeToUnifiedName
+    searchData.push(unified);
+  }
+
+  shortCodesToEmojiData[key].push(searchData);
+});
+
+// JSON.parse/stringify is to emulate what @preval is doing and avoid any
+// inconsistent behavior in dev mode
+module.exports = JSON.parse(JSON.stringify([
+  shortCodesToEmojiData,
+  emojiMartData.skins,
+  emojiMartData.categories,
+  emojiMartData.short_names,
+  emojisWithoutShortCodes,
+]));
diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_map.json b/app/javascript/flavours/glitch/util/emoji/emoji_map.json
new file mode 100644
index 000000000..13753ba84
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/emoji_map.json
@@ -0,0 +1 @@
+{"😀":"1f600","😁":"1f601","😂":"1f602","🤣":"1f923","😃":"1f603","😄":"1f604","😅":"1f605","😆":"1f606","😉":"1f609","😊":"1f60a","😋":"1f60b","😎":"1f60e","😍":"1f60d","😘":"1f618","😗":"1f617","😙":"1f619","😚":"1f61a","☺":"263a","🙂":"1f642","🤗":"1f917","🤩":"1f929","🤔":"1f914","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🙄":"1f644","😏":"1f60f","😣":"1f623","😥":"1f625","😮":"1f62e","🤐":"1f910","😯":"1f62f","😪":"1f62a","😫":"1f62b","😴":"1f634","😌":"1f60c","😛":"1f61b","😜":"1f61c","😝":"1f61d","🤤":"1f924","😒":"1f612","😓":"1f613","😔":"1f614","😕":"1f615","🙃":"1f643","🤑":"1f911","😲":"1f632","☹":"2639","🙁":"1f641","😖":"1f616","😞":"1f61e","😟":"1f61f","😤":"1f624","😢":"1f622","😭":"1f62d","😦":"1f626","😧":"1f627","😨":"1f628","😩":"1f629","🤯":"1f92f","😬":"1f62c","😰":"1f630","😱":"1f631","😳":"1f633","🤪":"1f92a","😵":"1f635","😡":"1f621","😠":"1f620","🤬":"1f92c","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","😇":"1f607","🤠":"1f920","🤡":"1f921","🤥":"1f925","🤫":"1f92b","🤭":"1f92d","🧐":"1f9d0","🤓":"1f913","😈":"1f608","👿":"1f47f","👹":"1f479","👺":"1f47a","💀":"1f480","☠":"2620","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","💩":"1f4a9","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👨":"1f468","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🧔":"1f9d4","👱":"1f471","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🙇":"1f647","🤦":"1f926","🤷":"1f937","💆":"1f486","💇":"1f487","🚶":"1f6b6","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","🕴":"1f574","🗣":"1f5e3","👤":"1f464","👥":"1f465","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🏎":"1f3ce","🏍":"1f3cd","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","👫":"1f46b","👬":"1f46c","👭":"1f46d","💏":"1f48f","💑":"1f491","👪":"1f46a","🤳":"1f933","💪":"1f4aa","👈":"1f448","👉":"1f449","☝":"261d","👆":"1f446","🖕":"1f595","👇":"1f447","✌":"270c","🤞":"1f91e","🖖":"1f596","🤘":"1f918","🤙":"1f919","🖐":"1f590","✋":"270b","👌":"1f44c","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","🤚":"1f91a","👋":"1f44b","🤟":"1f91f","✍":"270d","👏":"1f44f","👐":"1f450","🙌":"1f64c","🤲":"1f932","🙏":"1f64f","🤝":"1f91d","💅":"1f485","👂":"1f442","👃":"1f443","👣":"1f463","👀":"1f440","👁":"1f441","🧠":"1f9e0","👅":"1f445","👄":"1f444","💋":"1f48b","💘":"1f498","❤":"2764","💓":"1f493","💔":"1f494","💕":"1f495","💖":"1f496","💗":"1f497","💙":"1f499","💚":"1f49a","💛":"1f49b","🧡":"1f9e1","💜":"1f49c","🖤":"1f5a4","💝":"1f49d","💞":"1f49e","💟":"1f49f","❣":"2763","💌":"1f48c","💤":"1f4a4","💢":"1f4a2","💣":"1f4a3","💥":"1f4a5","💦":"1f4a6","💨":"1f4a8","💫":"1f4ab","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","🕳":"1f573","👓":"1f453","🕶":"1f576","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","👠":"1f460","👡":"1f461","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🐶":"1f436","🐕":"1f415","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦒":"1f992","🐘":"1f418","🦏":"1f98f","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦉":"1f989","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🦀":"1f980","🦐":"1f990","🦑":"1f991","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🐞":"1f41e","🦗":"1f997","🕷":"1f577","🕸":"1f578","🦂":"1f982","💐":"1f490","🌸":"1f338","💮":"1f4ae","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🥝":"1f95d","🍅":"1f345","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🥒":"1f952","🥦":"1f966","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥞":"1f95e","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🥤":"1f964","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🏘":"1f3d8","🏙":"1f3d9","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🌌":"1f30c","🎠":"1f3a0","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🎰":"1f3b0","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🚲":"1f6b2","🛴":"1f6f4","🛵":"1f6f5","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🚧":"1f6a7","🛑":"1f6d1","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","⭐":"2b50","🌟":"1f31f","🌠":"1f320","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🎱":"1f3b1","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","🎯":"1f3af","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎮":"1f3ae","🕹":"1f579","🎲":"1f3b2","♠":"2660","♥":"2665","♦":"2666","♣":"2663","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🥁":"1f941","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","💹":"1f4b9","💱":"1f4b1","💲":"1f4b2","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚗":"2697","⚖":"2696","🔗":"1f517","⛓":"26d3","💉":"1f489","💊":"1f48a","🚬":"1f6ac","⚰":"26b0","⚱":"26b1","🗿":"1f5ff","🛢":"1f6e2","🔮":"1f52e","🛒":"1f6d2","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","✖":"2716","❌":"274c","❎":"274e","➕":"2795","➖":"2796","➗":"2797","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","©":"a9","®":"ae","™":"2122","🔟":"1f51f","💯":"1f4af","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","▪":"25aa","▫":"25ab","◻":"25fb","◼":"25fc","◽":"25fd","◾":"25fe","⬛":"2b1b","⬜":"2b1c","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔲":"1f532","🔳":"1f533","⚪":"26aa","⚫":"26ab","🔴":"1f534","🔵":"1f535","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🗣️":"1f5e3","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🏎️":"1f3ce","🏍️":"1f3cd","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","❤️":"2764","❣️":"2763","🗨️":"1f5e8","🗯️":"1f5ef","🕳️":"1f573","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏙️":"1f3d9","🏚️":"1f3da","⛩️":"26e9","♨️":"2668","🖼️":"1f5bc","🛣️":"1f6e3","🛤️":"1f6e4","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","🛏️":"1f6cf","🛋️":"1f6cb","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚗️":"2697","⚖️":"2696","⛓️":"26d3","⚰️":"26b0","⚱️":"26b1","🛢️":"1f6e2","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","✖️":"2716","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","‼️":"203c","⁉️":"2049","〰️":"3030","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","▪️":"25aa","▫️":"25ab","◻️":"25fb","◼️":"25fc","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","👨‍⚕":"1f468-200d-2695-fe0f","👩‍⚕":"1f469-200d-2695-fe0f","👨‍🎓":"1f468-200d-1f393","👩‍🎓":"1f469-200d-1f393","👨‍🏫":"1f468-200d-1f3eb","👩‍🏫":"1f469-200d-1f3eb","👨‍⚖":"1f468-200d-2696-fe0f","👩‍⚖":"1f469-200d-2696-fe0f","👨‍🌾":"1f468-200d-1f33e","👩‍🌾":"1f469-200d-1f33e","👨‍🍳":"1f468-200d-1f373","👩‍🍳":"1f469-200d-1f373","👨‍🔧":"1f468-200d-1f527","👩‍🔧":"1f469-200d-1f527","👨‍🏭":"1f468-200d-1f3ed","👩‍🏭":"1f469-200d-1f3ed","👨‍💼":"1f468-200d-1f4bc","👩‍💼":"1f469-200d-1f4bc","👨‍🔬":"1f468-200d-1f52c","👩‍🔬":"1f469-200d-1f52c","👨‍💻":"1f468-200d-1f4bb","👩‍💻":"1f469-200d-1f4bb","👨‍🎤":"1f468-200d-1f3a4","👩‍🎤":"1f469-200d-1f3a4","👨‍🎨":"1f468-200d-1f3a8","👩‍🎨":"1f469-200d-1f3a8","👨‍✈":"1f468-200d-2708-fe0f","👩‍✈":"1f469-200d-2708-fe0f","👨‍🚀":"1f468-200d-1f680","👩‍🚀":"1f469-200d-1f680","👨‍🚒":"1f468-200d-1f692","👩‍🚒":"1f469-200d-1f692","👮‍♂":"1f46e-200d-2642-fe0f","👮‍♀":"1f46e-200d-2640-fe0f","🕵‍♂":"1f575-fe0f-200d-2642-fe0f","🕵‍♀":"1f575-fe0f-200d-2640-fe0f","💂‍♂":"1f482-200d-2642-fe0f","💂‍♀":"1f482-200d-2640-fe0f","👷‍♂":"1f477-200d-2642-fe0f","👷‍♀":"1f477-200d-2640-fe0f","👳‍♂":"1f473-200d-2642-fe0f","👳‍♀":"1f473-200d-2640-fe0f","👱‍♂":"1f471-200d-2642-fe0f","👱‍♀":"1f471-200d-2640-fe0f","🧙‍♀":"1f9d9-200d-2640-fe0f","🧙‍♂":"1f9d9-200d-2642-fe0f","🧚‍♀":"1f9da-200d-2640-fe0f","🧚‍♂":"1f9da-200d-2642-fe0f","🧛‍♀":"1f9db-200d-2640-fe0f","🧛‍♂":"1f9db-200d-2642-fe0f","🧜‍♀":"1f9dc-200d-2640-fe0f","🧜‍♂":"1f9dc-200d-2642-fe0f","🧝‍♀":"1f9dd-200d-2640-fe0f","🧝‍♂":"1f9dd-200d-2642-fe0f","🧞‍♀":"1f9de-200d-2640-fe0f","🧞‍♂":"1f9de-200d-2642-fe0f","🧟‍♀":"1f9df-200d-2640-fe0f","🧟‍♂":"1f9df-200d-2642-fe0f","🙍‍♂":"1f64d-200d-2642-fe0f","🙍‍♀":"1f64d-200d-2640-fe0f","🙎‍♂":"1f64e-200d-2642-fe0f","🙎‍♀":"1f64e-200d-2640-fe0f","🙅‍♂":"1f645-200d-2642-fe0f","🙅‍♀":"1f645-200d-2640-fe0f","🙆‍♂":"1f646-200d-2642-fe0f","🙆‍♀":"1f646-200d-2640-fe0f","💁‍♂":"1f481-200d-2642-fe0f","💁‍♀":"1f481-200d-2640-fe0f","🙋‍♂":"1f64b-200d-2642-fe0f","🙋‍♀":"1f64b-200d-2640-fe0f","🙇‍♂":"1f647-200d-2642-fe0f","🙇‍♀":"1f647-200d-2640-fe0f","🤦‍♂":"1f926-200d-2642-fe0f","🤦‍♀":"1f926-200d-2640-fe0f","🤷‍♂":"1f937-200d-2642-fe0f","🤷‍♀":"1f937-200d-2640-fe0f","💆‍♂":"1f486-200d-2642-fe0f","💆‍♀":"1f486-200d-2640-fe0f","💇‍♂":"1f487-200d-2642-fe0f","💇‍♀":"1f487-200d-2640-fe0f","🚶‍♂":"1f6b6-200d-2642-fe0f","🚶‍♀":"1f6b6-200d-2640-fe0f","🏃‍♂":"1f3c3-200d-2642-fe0f","🏃‍♀":"1f3c3-200d-2640-fe0f","👯‍♂":"1f46f-200d-2642-fe0f","👯‍♀":"1f46f-200d-2640-fe0f","🧖‍♀":"1f9d6-200d-2640-fe0f","🧖‍♂":"1f9d6-200d-2642-fe0f","🧗‍♀":"1f9d7-200d-2640-fe0f","🧗‍♂":"1f9d7-200d-2642-fe0f","🧘‍♀":"1f9d8-200d-2640-fe0f","🧘‍♂":"1f9d8-200d-2642-fe0f","🏌‍♂":"1f3cc-fe0f-200d-2642-fe0f","🏌‍♀":"1f3cc-fe0f-200d-2640-fe0f","🏄‍♂":"1f3c4-200d-2642-fe0f","🏄‍♀":"1f3c4-200d-2640-fe0f","🚣‍♂":"1f6a3-200d-2642-fe0f","🚣‍♀":"1f6a3-200d-2640-fe0f","🏊‍♂":"1f3ca-200d-2642-fe0f","🏊‍♀":"1f3ca-200d-2640-fe0f","⛹‍♂":"26f9-fe0f-200d-2642-fe0f","⛹‍♀":"26f9-fe0f-200d-2640-fe0f","🏋‍♂":"1f3cb-fe0f-200d-2642-fe0f","🏋‍♀":"1f3cb-fe0f-200d-2640-fe0f","🚴‍♂":"1f6b4-200d-2642-fe0f","🚴‍♀":"1f6b4-200d-2640-fe0f","🚵‍♂":"1f6b5-200d-2642-fe0f","🚵‍♀":"1f6b5-200d-2640-fe0f","🤸‍♂":"1f938-200d-2642-fe0f","🤸‍♀":"1f938-200d-2640-fe0f","🤼‍♂":"1f93c-200d-2642-fe0f","🤼‍♀":"1f93c-200d-2640-fe0f","🤽‍♂":"1f93d-200d-2642-fe0f","🤽‍♀":"1f93d-200d-2640-fe0f","🤾‍♂":"1f93e-200d-2642-fe0f","🤾‍♀":"1f93e-200d-2640-fe0f","🤹‍♂":"1f939-200d-2642-fe0f","🤹‍♀":"1f939-200d-2640-fe0f","👨‍👦":"1f468-200d-1f466","👨‍👧":"1f468-200d-1f467","👩‍👦":"1f469-200d-1f466","👩‍👧":"1f469-200d-1f467","👁‍🗨":"1f441-200d-1f5e8","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳‍🌈":"1f3f3-fe0f-200d-1f308","👨‍⚕️":"1f468-200d-2695-fe0f","👨🏻‍⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼‍⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽‍⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾‍⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿‍⚕":"1f468-1f3ff-200d-2695-fe0f","👩‍⚕️":"1f469-200d-2695-fe0f","👩🏻‍⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼‍⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽‍⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾‍⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿‍⚕":"1f469-1f3ff-200d-2695-fe0f","👨🏻‍🎓":"1f468-1f3fb-200d-1f393","👨🏼‍🎓":"1f468-1f3fc-200d-1f393","👨🏽‍🎓":"1f468-1f3fd-200d-1f393","👨🏾‍🎓":"1f468-1f3fe-200d-1f393","👨🏿‍🎓":"1f468-1f3ff-200d-1f393","👩🏻‍🎓":"1f469-1f3fb-200d-1f393","👩🏼‍🎓":"1f469-1f3fc-200d-1f393","👩🏽‍🎓":"1f469-1f3fd-200d-1f393","👩🏾‍🎓":"1f469-1f3fe-200d-1f393","👩🏿‍🎓":"1f469-1f3ff-200d-1f393","👨🏻‍🏫":"1f468-1f3fb-200d-1f3eb","👨🏼‍🏫":"1f468-1f3fc-200d-1f3eb","👨🏽‍🏫":"1f468-1f3fd-200d-1f3eb","👨🏾‍🏫":"1f468-1f3fe-200d-1f3eb","👨🏿‍🏫":"1f468-1f3ff-200d-1f3eb","👩🏻‍🏫":"1f469-1f3fb-200d-1f3eb","👩🏼‍🏫":"1f469-1f3fc-200d-1f3eb","👩🏽‍🏫":"1f469-1f3fd-200d-1f3eb","👩🏾‍🏫":"1f469-1f3fe-200d-1f3eb","👩🏿‍🏫":"1f469-1f3ff-200d-1f3eb","👨‍⚖️":"1f468-200d-2696-fe0f","👨🏻‍⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼‍⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽‍⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾‍⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿‍⚖":"1f468-1f3ff-200d-2696-fe0f","👩‍⚖️":"1f469-200d-2696-fe0f","👩🏻‍⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼‍⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽‍⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾‍⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿‍⚖":"1f469-1f3ff-200d-2696-fe0f","👨🏻‍🌾":"1f468-1f3fb-200d-1f33e","👨🏼‍🌾":"1f468-1f3fc-200d-1f33e","👨🏽‍🌾":"1f468-1f3fd-200d-1f33e","👨🏾‍🌾":"1f468-1f3fe-200d-1f33e","👨🏿‍🌾":"1f468-1f3ff-200d-1f33e","👩🏻‍🌾":"1f469-1f3fb-200d-1f33e","👩🏼‍🌾":"1f469-1f3fc-200d-1f33e","👩🏽‍🌾":"1f469-1f3fd-200d-1f33e","👩🏾‍🌾":"1f469-1f3fe-200d-1f33e","👩🏿‍🌾":"1f469-1f3ff-200d-1f33e","👨🏻‍🍳":"1f468-1f3fb-200d-1f373","👨🏼‍🍳":"1f468-1f3fc-200d-1f373","👨🏽‍🍳":"1f468-1f3fd-200d-1f373","👨🏾‍🍳":"1f468-1f3fe-200d-1f373","👨🏿‍🍳":"1f468-1f3ff-200d-1f373","👩🏻‍🍳":"1f469-1f3fb-200d-1f373","👩🏼‍🍳":"1f469-1f3fc-200d-1f373","👩🏽‍🍳":"1f469-1f3fd-200d-1f373","👩🏾‍🍳":"1f469-1f3fe-200d-1f373","👩🏿‍🍳":"1f469-1f3ff-200d-1f373","👨🏻‍🔧":"1f468-1f3fb-200d-1f527","👨🏼‍🔧":"1f468-1f3fc-200d-1f527","👨🏽‍🔧":"1f468-1f3fd-200d-1f527","👨🏾‍🔧":"1f468-1f3fe-200d-1f527","👨🏿‍🔧":"1f468-1f3ff-200d-1f527","👩🏻‍🔧":"1f469-1f3fb-200d-1f527","👩🏼‍🔧":"1f469-1f3fc-200d-1f527","👩🏽‍🔧":"1f469-1f3fd-200d-1f527","👩🏾‍🔧":"1f469-1f3fe-200d-1f527","👩🏿‍🔧":"1f469-1f3ff-200d-1f527","👨🏻‍🏭":"1f468-1f3fb-200d-1f3ed","👨🏼‍🏭":"1f468-1f3fc-200d-1f3ed","👨🏽‍🏭":"1f468-1f3fd-200d-1f3ed","👨🏾‍🏭":"1f468-1f3fe-200d-1f3ed","👨🏿‍🏭":"1f468-1f3ff-200d-1f3ed","👩🏻‍🏭":"1f469-1f3fb-200d-1f3ed","👩🏼‍🏭":"1f469-1f3fc-200d-1f3ed","👩🏽‍🏭":"1f469-1f3fd-200d-1f3ed","👩🏾‍🏭":"1f469-1f3fe-200d-1f3ed","👩🏿‍🏭":"1f469-1f3ff-200d-1f3ed","👨🏻‍💼":"1f468-1f3fb-200d-1f4bc","👨🏼‍💼":"1f468-1f3fc-200d-1f4bc","👨🏽‍💼":"1f468-1f3fd-200d-1f4bc","👨🏾‍💼":"1f468-1f3fe-200d-1f4bc","👨🏿‍💼":"1f468-1f3ff-200d-1f4bc","👩🏻‍💼":"1f469-1f3fb-200d-1f4bc","👩🏼‍💼":"1f469-1f3fc-200d-1f4bc","👩🏽‍💼":"1f469-1f3fd-200d-1f4bc","👩🏾‍💼":"1f469-1f3fe-200d-1f4bc","👩🏿‍💼":"1f469-1f3ff-200d-1f4bc","👨🏻‍🔬":"1f468-1f3fb-200d-1f52c","👨🏼‍🔬":"1f468-1f3fc-200d-1f52c","👨🏽‍🔬":"1f468-1f3fd-200d-1f52c","👨🏾‍🔬":"1f468-1f3fe-200d-1f52c","👨🏿‍🔬":"1f468-1f3ff-200d-1f52c","👩🏻‍🔬":"1f469-1f3fb-200d-1f52c","👩🏼‍🔬":"1f469-1f3fc-200d-1f52c","👩🏽‍🔬":"1f469-1f3fd-200d-1f52c","👩🏾‍🔬":"1f469-1f3fe-200d-1f52c","👩🏿‍🔬":"1f469-1f3ff-200d-1f52c","👨🏻‍💻":"1f468-1f3fb-200d-1f4bb","👨🏼‍💻":"1f468-1f3fc-200d-1f4bb","👨🏽‍💻":"1f468-1f3fd-200d-1f4bb","👨🏾‍💻":"1f468-1f3fe-200d-1f4bb","👨🏿‍💻":"1f468-1f3ff-200d-1f4bb","👩🏻‍💻":"1f469-1f3fb-200d-1f4bb","👩🏼‍💻":"1f469-1f3fc-200d-1f4bb","👩🏽‍💻":"1f469-1f3fd-200d-1f4bb","👩🏾‍💻":"1f469-1f3fe-200d-1f4bb","👩🏿‍💻":"1f469-1f3ff-200d-1f4bb","👨🏻‍🎤":"1f468-1f3fb-200d-1f3a4","👨🏼‍🎤":"1f468-1f3fc-200d-1f3a4","👨🏽‍🎤":"1f468-1f3fd-200d-1f3a4","👨🏾‍🎤":"1f468-1f3fe-200d-1f3a4","👨🏿‍🎤":"1f468-1f3ff-200d-1f3a4","👩🏻‍🎤":"1f469-1f3fb-200d-1f3a4","👩🏼‍🎤":"1f469-1f3fc-200d-1f3a4","👩🏽‍🎤":"1f469-1f3fd-200d-1f3a4","👩🏾‍🎤":"1f469-1f3fe-200d-1f3a4","👩🏿‍🎤":"1f469-1f3ff-200d-1f3a4","👨🏻‍🎨":"1f468-1f3fb-200d-1f3a8","👨🏼‍🎨":"1f468-1f3fc-200d-1f3a8","👨🏽‍🎨":"1f468-1f3fd-200d-1f3a8","👨🏾‍🎨":"1f468-1f3fe-200d-1f3a8","👨🏿‍🎨":"1f468-1f3ff-200d-1f3a8","👩🏻‍🎨":"1f469-1f3fb-200d-1f3a8","👩🏼‍🎨":"1f469-1f3fc-200d-1f3a8","👩🏽‍🎨":"1f469-1f3fd-200d-1f3a8","👩🏾‍🎨":"1f469-1f3fe-200d-1f3a8","👩🏿‍🎨":"1f469-1f3ff-200d-1f3a8","👨‍✈️":"1f468-200d-2708-fe0f","👨🏻‍✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼‍✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽‍✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾‍✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿‍✈":"1f468-1f3ff-200d-2708-fe0f","👩‍✈️":"1f469-200d-2708-fe0f","👩🏻‍✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼‍✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽‍✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾‍✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿‍✈":"1f469-1f3ff-200d-2708-fe0f","👨🏻‍🚀":"1f468-1f3fb-200d-1f680","👨🏼‍🚀":"1f468-1f3fc-200d-1f680","👨🏽‍🚀":"1f468-1f3fd-200d-1f680","👨🏾‍🚀":"1f468-1f3fe-200d-1f680","👨🏿‍🚀":"1f468-1f3ff-200d-1f680","👩🏻‍🚀":"1f469-1f3fb-200d-1f680","👩🏼‍🚀":"1f469-1f3fc-200d-1f680","👩🏽‍🚀":"1f469-1f3fd-200d-1f680","👩🏾‍🚀":"1f469-1f3fe-200d-1f680","👩🏿‍🚀":"1f469-1f3ff-200d-1f680","👨🏻‍🚒":"1f468-1f3fb-200d-1f692","👨🏼‍🚒":"1f468-1f3fc-200d-1f692","👨🏽‍🚒":"1f468-1f3fd-200d-1f692","👨🏾‍🚒":"1f468-1f3fe-200d-1f692","👨🏿‍🚒":"1f468-1f3ff-200d-1f692","👩🏻‍🚒":"1f469-1f3fb-200d-1f692","👩🏼‍🚒":"1f469-1f3fc-200d-1f692","👩🏽‍🚒":"1f469-1f3fd-200d-1f692","👩🏾‍🚒":"1f469-1f3fe-200d-1f692","👩🏿‍🚒":"1f469-1f3ff-200d-1f692","👮‍♂️":"1f46e-200d-2642-fe0f","👮🏻‍♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼‍♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽‍♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾‍♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿‍♂":"1f46e-1f3ff-200d-2642-fe0f","👮‍♀️":"1f46e-200d-2640-fe0f","👮🏻‍♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼‍♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽‍♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾‍♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿‍♀":"1f46e-1f3ff-200d-2640-fe0f","🕵‍♂️":"1f575-fe0f-200d-2642-fe0f","🕵️‍♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻‍♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼‍♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽‍♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾‍♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿‍♂":"1f575-1f3ff-200d-2642-fe0f","🕵‍♀️":"1f575-fe0f-200d-2640-fe0f","🕵️‍♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻‍♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼‍♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽‍♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾‍♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿‍♀":"1f575-1f3ff-200d-2640-fe0f","💂‍♂️":"1f482-200d-2642-fe0f","💂🏻‍♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼‍♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽‍♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾‍♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿‍♂":"1f482-1f3ff-200d-2642-fe0f","💂‍♀️":"1f482-200d-2640-fe0f","💂🏻‍♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼‍♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽‍♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾‍♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿‍♀":"1f482-1f3ff-200d-2640-fe0f","👷‍♂️":"1f477-200d-2642-fe0f","👷🏻‍♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼‍♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽‍♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾‍♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿‍♂":"1f477-1f3ff-200d-2642-fe0f","👷‍♀️":"1f477-200d-2640-fe0f","👷🏻‍♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼‍♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽‍♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾‍♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿‍♀":"1f477-1f3ff-200d-2640-fe0f","👳‍♂️":"1f473-200d-2642-fe0f","👳🏻‍♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼‍♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽‍♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾‍♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿‍♂":"1f473-1f3ff-200d-2642-fe0f","👳‍♀️":"1f473-200d-2640-fe0f","👳🏻‍♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼‍♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽‍♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾‍♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿‍♀":"1f473-1f3ff-200d-2640-fe0f","👱‍♂️":"1f471-200d-2642-fe0f","👱🏻‍♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼‍♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽‍♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾‍♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿‍♂":"1f471-1f3ff-200d-2642-fe0f","👱‍♀️":"1f471-200d-2640-fe0f","👱🏻‍♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼‍♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽‍♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾‍♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿‍♀":"1f471-1f3ff-200d-2640-fe0f","🧙‍♀️":"1f9d9-200d-2640-fe0f","🧙🏻‍♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼‍♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽‍♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾‍♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿‍♀":"1f9d9-1f3ff-200d-2640-fe0f","🧙‍♂️":"1f9d9-200d-2642-fe0f","🧙🏻‍♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼‍♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽‍♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾‍♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿‍♂":"1f9d9-1f3ff-200d-2642-fe0f","🧚‍♀️":"1f9da-200d-2640-fe0f","🧚🏻‍♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼‍♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽‍♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾‍♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿‍♀":"1f9da-1f3ff-200d-2640-fe0f","🧚‍♂️":"1f9da-200d-2642-fe0f","🧚🏻‍♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼‍♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽‍♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾‍♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿‍♂":"1f9da-1f3ff-200d-2642-fe0f","🧛‍♀️":"1f9db-200d-2640-fe0f","🧛🏻‍♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼‍♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽‍♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾‍♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿‍♀":"1f9db-1f3ff-200d-2640-fe0f","🧛‍♂️":"1f9db-200d-2642-fe0f","🧛🏻‍♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼‍♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽‍♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾‍♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿‍♂":"1f9db-1f3ff-200d-2642-fe0f","🧜‍♀️":"1f9dc-200d-2640-fe0f","🧜🏻‍♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼‍♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽‍♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾‍♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿‍♀":"1f9dc-1f3ff-200d-2640-fe0f","🧜‍♂️":"1f9dc-200d-2642-fe0f","🧜🏻‍♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼‍♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽‍♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾‍♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿‍♂":"1f9dc-1f3ff-200d-2642-fe0f","🧝‍♀️":"1f9dd-200d-2640-fe0f","🧝🏻‍♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼‍♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽‍♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾‍♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿‍♀":"1f9dd-1f3ff-200d-2640-fe0f","🧝‍♂️":"1f9dd-200d-2642-fe0f","🧝🏻‍♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼‍♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽‍♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾‍♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿‍♂":"1f9dd-1f3ff-200d-2642-fe0f","🧞‍♀️":"1f9de-200d-2640-fe0f","🧞‍♂️":"1f9de-200d-2642-fe0f","🧟‍♀️":"1f9df-200d-2640-fe0f","🧟‍♂️":"1f9df-200d-2642-fe0f","🙍‍♂️":"1f64d-200d-2642-fe0f","🙍🏻‍♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼‍♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽‍♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾‍♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿‍♂":"1f64d-1f3ff-200d-2642-fe0f","🙍‍♀️":"1f64d-200d-2640-fe0f","🙍🏻‍♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼‍♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽‍♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾‍♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿‍♀":"1f64d-1f3ff-200d-2640-fe0f","🙎‍♂️":"1f64e-200d-2642-fe0f","🙎🏻‍♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼‍♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽‍♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾‍♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿‍♂":"1f64e-1f3ff-200d-2642-fe0f","🙎‍♀️":"1f64e-200d-2640-fe0f","🙎🏻‍♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼‍♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽‍♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾‍♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿‍♀":"1f64e-1f3ff-200d-2640-fe0f","🙅‍♂️":"1f645-200d-2642-fe0f","🙅🏻‍♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼‍♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽‍♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾‍♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿‍♂":"1f645-1f3ff-200d-2642-fe0f","🙅‍♀️":"1f645-200d-2640-fe0f","🙅🏻‍♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼‍♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽‍♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾‍♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿‍♀":"1f645-1f3ff-200d-2640-fe0f","🙆‍♂️":"1f646-200d-2642-fe0f","🙆🏻‍♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼‍♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽‍♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾‍♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿‍♂":"1f646-1f3ff-200d-2642-fe0f","🙆‍♀️":"1f646-200d-2640-fe0f","🙆🏻‍♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼‍♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽‍♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾‍♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿‍♀":"1f646-1f3ff-200d-2640-fe0f","💁‍♂️":"1f481-200d-2642-fe0f","💁🏻‍♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼‍♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽‍♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾‍♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿‍♂":"1f481-1f3ff-200d-2642-fe0f","💁‍♀️":"1f481-200d-2640-fe0f","💁🏻‍♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼‍♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽‍♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾‍♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿‍♀":"1f481-1f3ff-200d-2640-fe0f","🙋‍♂️":"1f64b-200d-2642-fe0f","🙋🏻‍♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼‍♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽‍♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾‍♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿‍♂":"1f64b-1f3ff-200d-2642-fe0f","🙋‍♀️":"1f64b-200d-2640-fe0f","🙋🏻‍♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼‍♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽‍♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾‍♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿‍♀":"1f64b-1f3ff-200d-2640-fe0f","🙇‍♂️":"1f647-200d-2642-fe0f","🙇🏻‍♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼‍♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽‍♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾‍♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿‍♂":"1f647-1f3ff-200d-2642-fe0f","🙇‍♀️":"1f647-200d-2640-fe0f","🙇🏻‍♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼‍♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽‍♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾‍♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿‍♀":"1f647-1f3ff-200d-2640-fe0f","🤦‍♂️":"1f926-200d-2642-fe0f","🤦🏻‍♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼‍♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽‍♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾‍♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿‍♂":"1f926-1f3ff-200d-2642-fe0f","🤦‍♀️":"1f926-200d-2640-fe0f","🤦🏻‍♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼‍♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽‍♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾‍♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿‍♀":"1f926-1f3ff-200d-2640-fe0f","🤷‍♂️":"1f937-200d-2642-fe0f","🤷🏻‍♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼‍♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽‍♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾‍♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿‍♂":"1f937-1f3ff-200d-2642-fe0f","🤷‍♀️":"1f937-200d-2640-fe0f","🤷🏻‍♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼‍♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽‍♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾‍♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿‍♀":"1f937-1f3ff-200d-2640-fe0f","💆‍♂️":"1f486-200d-2642-fe0f","💆🏻‍♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼‍♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽‍♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾‍♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿‍♂":"1f486-1f3ff-200d-2642-fe0f","💆‍♀️":"1f486-200d-2640-fe0f","💆🏻‍♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼‍♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽‍♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾‍♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿‍♀":"1f486-1f3ff-200d-2640-fe0f","💇‍♂️":"1f487-200d-2642-fe0f","💇🏻‍♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼‍♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽‍♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾‍♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿‍♂":"1f487-1f3ff-200d-2642-fe0f","💇‍♀️":"1f487-200d-2640-fe0f","💇🏻‍♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼‍♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽‍♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾‍♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿‍♀":"1f487-1f3ff-200d-2640-fe0f","🚶‍♂️":"1f6b6-200d-2642-fe0f","🚶🏻‍♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼‍♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽‍♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾‍♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿‍♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶‍♀️":"1f6b6-200d-2640-fe0f","🚶🏻‍♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼‍♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽‍♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾‍♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿‍♀":"1f6b6-1f3ff-200d-2640-fe0f","🏃‍♂️":"1f3c3-200d-2642-fe0f","🏃🏻‍♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼‍♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽‍♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾‍♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿‍♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃‍♀️":"1f3c3-200d-2640-fe0f","🏃🏻‍♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼‍♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽‍♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾‍♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿‍♀":"1f3c3-1f3ff-200d-2640-fe0f","👯‍♂️":"1f46f-200d-2642-fe0f","👯‍♀️":"1f46f-200d-2640-fe0f","🧖‍♀️":"1f9d6-200d-2640-fe0f","🧖🏻‍♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼‍♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽‍♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾‍♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿‍♀":"1f9d6-1f3ff-200d-2640-fe0f","🧖‍♂️":"1f9d6-200d-2642-fe0f","🧖🏻‍♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼‍♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽‍♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾‍♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿‍♂":"1f9d6-1f3ff-200d-2642-fe0f","🧗‍♀️":"1f9d7-200d-2640-fe0f","🧗🏻‍♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼‍♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽‍♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾‍♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿‍♀":"1f9d7-1f3ff-200d-2640-fe0f","🧗‍♂️":"1f9d7-200d-2642-fe0f","🧗🏻‍♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼‍♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽‍♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾‍♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿‍♂":"1f9d7-1f3ff-200d-2642-fe0f","🧘‍♀️":"1f9d8-200d-2640-fe0f","🧘🏻‍♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼‍♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽‍♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾‍♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿‍♀":"1f9d8-1f3ff-200d-2640-fe0f","🧘‍♂️":"1f9d8-200d-2642-fe0f","🧘🏻‍♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼‍♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽‍♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾‍♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿‍♂":"1f9d8-1f3ff-200d-2642-fe0f","🏌‍♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️‍♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻‍♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼‍♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽‍♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾‍♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿‍♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌‍♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️‍♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻‍♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼‍♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽‍♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾‍♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿‍♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄‍♂️":"1f3c4-200d-2642-fe0f","🏄🏻‍♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼‍♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽‍♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾‍♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿‍♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄‍♀️":"1f3c4-200d-2640-fe0f","🏄🏻‍♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼‍♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽‍♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾‍♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿‍♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣‍♂️":"1f6a3-200d-2642-fe0f","🚣🏻‍♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼‍♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽‍♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾‍♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿‍♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣‍♀️":"1f6a3-200d-2640-fe0f","🚣🏻‍♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼‍♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽‍♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾‍♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿‍♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊‍♂️":"1f3ca-200d-2642-fe0f","🏊🏻‍♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼‍♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽‍♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾‍♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿‍♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊‍♀️":"1f3ca-200d-2640-fe0f","🏊🏻‍♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼‍♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽‍♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾‍♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿‍♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹‍♂️":"26f9-fe0f-200d-2642-fe0f","⛹️‍♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻‍♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼‍♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽‍♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾‍♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿‍♂":"26f9-1f3ff-200d-2642-fe0f","⛹‍♀️":"26f9-fe0f-200d-2640-fe0f","⛹️‍♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻‍♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼‍♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽‍♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾‍♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿‍♀":"26f9-1f3ff-200d-2640-fe0f","🏋‍♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️‍♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻‍♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼‍♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽‍♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾‍♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿‍♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋‍♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️‍♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻‍♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼‍♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽‍♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾‍♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿‍♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴‍♂️":"1f6b4-200d-2642-fe0f","🚴🏻‍♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼‍♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽‍♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾‍♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿‍♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴‍♀️":"1f6b4-200d-2640-fe0f","🚴🏻‍♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼‍♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽‍♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾‍♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿‍♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵‍♂️":"1f6b5-200d-2642-fe0f","🚵🏻‍♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼‍♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽‍♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾‍♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿‍♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵‍♀️":"1f6b5-200d-2640-fe0f","🚵🏻‍♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼‍♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽‍♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾‍♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿‍♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸‍♂️":"1f938-200d-2642-fe0f","🤸🏻‍♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼‍♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽‍♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾‍♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿‍♂":"1f938-1f3ff-200d-2642-fe0f","🤸‍♀️":"1f938-200d-2640-fe0f","🤸🏻‍♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼‍♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽‍♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾‍♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿‍♀":"1f938-1f3ff-200d-2640-fe0f","🤼‍♂️":"1f93c-200d-2642-fe0f","🤼‍♀️":"1f93c-200d-2640-fe0f","🤽‍♂️":"1f93d-200d-2642-fe0f","🤽🏻‍♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼‍♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽‍♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾‍♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿‍♂":"1f93d-1f3ff-200d-2642-fe0f","🤽‍♀️":"1f93d-200d-2640-fe0f","🤽🏻‍♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼‍♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽‍♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾‍♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿‍♀":"1f93d-1f3ff-200d-2640-fe0f","🤾‍♂️":"1f93e-200d-2642-fe0f","🤾🏻‍♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼‍♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽‍♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾‍♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿‍♂":"1f93e-1f3ff-200d-2642-fe0f","🤾‍♀️":"1f93e-200d-2640-fe0f","🤾🏻‍♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼‍♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽‍♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾‍♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿‍♀":"1f93e-1f3ff-200d-2640-fe0f","🤹‍♂️":"1f939-200d-2642-fe0f","🤹🏻‍♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼‍♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽‍♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾‍♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿‍♂":"1f939-1f3ff-200d-2642-fe0f","🤹‍♀️":"1f939-200d-2640-fe0f","🤹🏻‍♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼‍♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽‍♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾‍♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿‍♀":"1f939-1f3ff-200d-2640-fe0f","👁‍🗨️":"1f441-200d-1f5e8","👁️‍🗨":"1f441-200d-1f5e8","🏳️‍🌈":"1f3f3-fe0f-200d-1f308","👨🏻‍⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼‍⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽‍⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾‍⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿‍⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻‍⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼‍⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽‍⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾‍⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿‍⚕️":"1f469-1f3ff-200d-2695-fe0f","👨🏻‍⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼‍⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽‍⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾‍⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿‍⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻‍⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼‍⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽‍⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾‍⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿‍⚖️":"1f469-1f3ff-200d-2696-fe0f","👨🏻‍✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼‍✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽‍✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾‍✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿‍✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻‍✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼‍✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽‍✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾‍✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿‍✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻‍♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼‍♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽‍♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾‍♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿‍♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻‍♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼‍♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽‍♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾‍♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿‍♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️‍♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻‍♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼‍♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽‍♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾‍♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿‍♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️‍♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻‍♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼‍♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽‍♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾‍♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿‍♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻‍♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼‍♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽‍♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾‍♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿‍♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻‍♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼‍♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽‍♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾‍♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿‍♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻‍♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼‍♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽‍♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾‍♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿‍♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻‍♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼‍♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽‍♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾‍♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿‍♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻‍♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼‍♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽‍♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾‍♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿‍♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻‍♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼‍♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽‍♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾‍♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿‍♀️":"1f473-1f3ff-200d-2640-fe0f","👱🏻‍♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼‍♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽‍♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾‍♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿‍♂️":"1f471-1f3ff-200d-2642-fe0f","👱🏻‍♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼‍♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽‍♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾‍♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿‍♀️":"1f471-1f3ff-200d-2640-fe0f","🧙🏻‍♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼‍♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽‍♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾‍♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿‍♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧙🏻‍♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼‍♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽‍♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾‍♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿‍♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧚🏻‍♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼‍♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽‍♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾‍♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿‍♀️":"1f9da-1f3ff-200d-2640-fe0f","🧚🏻‍♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼‍♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽‍♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾‍♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿‍♂️":"1f9da-1f3ff-200d-2642-fe0f","🧛🏻‍♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼‍♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽‍♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾‍♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿‍♀️":"1f9db-1f3ff-200d-2640-fe0f","🧛🏻‍♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼‍♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽‍♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾‍♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿‍♂️":"1f9db-1f3ff-200d-2642-fe0f","🧜🏻‍♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼‍♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽‍♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾‍♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿‍♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧜🏻‍♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼‍♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽‍♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾‍♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿‍♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧝🏻‍♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼‍♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽‍♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾‍♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿‍♀️":"1f9dd-1f3ff-200d-2640-fe0f","🧝🏻‍♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼‍♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽‍♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾‍♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿‍♂️":"1f9dd-1f3ff-200d-2642-fe0f","🙍🏻‍♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼‍♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽‍♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾‍♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿‍♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻‍♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼‍♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽‍♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾‍♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿‍♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻‍♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼‍♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽‍♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾‍♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿‍♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻‍♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼‍♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽‍♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾‍♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿‍♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻‍♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼‍♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽‍♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾‍♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿‍♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻‍♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼‍♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽‍♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾‍♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿‍♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻‍♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼‍♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽‍♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾‍♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿‍♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻‍♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼‍♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽‍♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾‍♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿‍♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻‍♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼‍♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽‍♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾‍♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿‍♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻‍♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼‍♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽‍♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾‍♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿‍♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻‍♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼‍♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽‍♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾‍♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿‍♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻‍♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼‍♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽‍♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾‍♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿‍♀️":"1f64b-1f3ff-200d-2640-fe0f","🙇🏻‍♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼‍♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽‍♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾‍♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿‍♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻‍♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼‍♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽‍♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾‍♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿‍♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻‍♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼‍♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽‍♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾‍♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿‍♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻‍♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼‍♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽‍♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾‍♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿‍♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻‍♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼‍♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽‍♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾‍♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿‍♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻‍♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼‍♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽‍♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾‍♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿‍♀️":"1f937-1f3ff-200d-2640-fe0f","💆🏻‍♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼‍♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽‍♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾‍♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿‍♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻‍♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼‍♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽‍♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾‍♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿‍♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻‍♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼‍♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽‍♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾‍♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿‍♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻‍♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼‍♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽‍♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾‍♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿‍♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻‍♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼‍♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽‍♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾‍♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿‍♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻‍♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼‍♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽‍♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾‍♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿‍♀️":"1f6b6-1f3ff-200d-2640-fe0f","🏃🏻‍♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼‍♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽‍♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾‍♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿‍♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻‍♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼‍♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽‍♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾‍♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿‍♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻‍♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼‍♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽‍♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾‍♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿‍♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧖🏻‍♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼‍♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽‍♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾‍♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿‍♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧗🏻‍♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼‍♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽‍♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾‍♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿‍♀️":"1f9d7-1f3ff-200d-2640-fe0f","🧗🏻‍♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼‍♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽‍♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾‍♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿‍♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧘🏻‍♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼‍♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽‍♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾‍♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿‍♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧘🏻‍♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼‍♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽‍♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾‍♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿‍♂️":"1f9d8-1f3ff-200d-2642-fe0f","🏌️‍♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻‍♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼‍♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽‍♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾‍♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿‍♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️‍♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻‍♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼‍♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽‍♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾‍♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿‍♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻‍♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼‍♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽‍♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾‍♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿‍♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻‍♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼‍♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽‍♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾‍♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿‍♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻‍♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼‍♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽‍♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾‍♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿‍♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻‍♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼‍♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽‍♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾‍♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿‍♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻‍♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼‍♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽‍♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾‍♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿‍♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻‍♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼‍♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽‍♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾‍♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿‍♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️‍♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻‍♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼‍♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽‍♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾‍♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿‍♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️‍♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻‍♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼‍♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽‍♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾‍♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿‍♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️‍♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻‍♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼‍♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽‍♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾‍♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿‍♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️‍♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻‍♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼‍♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽‍♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾‍♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿‍♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻‍♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼‍♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽‍♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾‍♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿‍♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻‍♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼‍♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽‍♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾‍♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿‍♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻‍♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼‍♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽‍♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾‍♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿‍♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻‍♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼‍♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽‍♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾‍♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿‍♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻‍♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼‍♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽‍♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾‍♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿‍♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻‍♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼‍♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽‍♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾‍♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿‍♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻‍♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼‍♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽‍♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾‍♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿‍♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻‍♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼‍♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽‍♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾‍♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿‍♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻‍♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼‍♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽‍♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾‍♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿‍♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻‍♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼‍♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽‍♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾‍♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿‍♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻‍♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼‍♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽‍♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾‍♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿‍♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻‍♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼‍♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽‍♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾‍♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿‍♀️":"1f939-1f3ff-200d-2640-fe0f","👩‍❤‍👨":"1f469-200d-2764-fe0f-200d-1f468","👨‍❤‍👨":"1f468-200d-2764-fe0f-200d-1f468","👩‍❤‍👩":"1f469-200d-2764-fe0f-200d-1f469","👨‍👩‍👦":"1f468-200d-1f469-200d-1f466","👨‍👩‍👧":"1f468-200d-1f469-200d-1f467","👨‍👨‍👦":"1f468-200d-1f468-200d-1f466","👨‍👨‍👧":"1f468-200d-1f468-200d-1f467","👩‍👩‍👦":"1f469-200d-1f469-200d-1f466","👩‍👩‍👧":"1f469-200d-1f469-200d-1f467","👨‍👦‍👦":"1f468-200d-1f466-200d-1f466","👨‍👧‍👦":"1f468-200d-1f467-200d-1f466","👨‍👧‍👧":"1f468-200d-1f467-200d-1f467","👩‍👦‍👦":"1f469-200d-1f466-200d-1f466","👩‍👧‍👦":"1f469-200d-1f467-200d-1f466","👩‍👧‍👧":"1f469-200d-1f467-200d-1f467","👁️‍🗨️":"1f441-200d-1f5e8","👩‍❤️‍👨":"1f469-200d-2764-fe0f-200d-1f468","👨‍❤️‍👨":"1f468-200d-2764-fe0f-200d-1f468","👩‍❤️‍👩":"1f469-200d-2764-fe0f-200d-1f469","👩‍❤‍💋‍👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨‍❤‍💋‍👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩‍❤‍💋‍👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","👨‍👩‍👧‍👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨‍👩‍👦‍👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨‍👩‍👧‍👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨‍👨‍👧‍👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨‍👨‍👦‍👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨‍👨‍👧‍👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩‍👩‍👧‍👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩‍👩‍👦‍👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩‍👩‍👧‍👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴󠁧󠁢󠁥󠁮󠁧󠁿":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴󠁧󠁢󠁳󠁣󠁴󠁿":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴󠁧󠁢󠁷󠁬󠁳󠁿":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩‍❤️‍💋‍👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨‍❤️‍💋‍👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩‍❤️‍💋‍👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469"}
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_mart_data_light.js b/app/javascript/flavours/glitch/util/emoji/emoji_mart_data_light.js
new file mode 100644
index 000000000..45086fc4c
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/emoji_mart_data_light.js
@@ -0,0 +1,41 @@
+// The output of this module is designed to mimic emoji-mart's
+// "data" object, such that we can use it for a light version of emoji-mart's
+// emojiIndex.search functionality.
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
+
+const emojis = {};
+
+// decompress
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+  let [
+    filenameData, // eslint-disable-line no-unused-vars
+    searchData,
+  ] = shortCodesToEmojiData[shortCode];
+  let [
+    native,
+    short_names,
+    search,
+    unified,
+  ] = searchData;
+
+  if (!unified) {
+    // unified name can be derived from unicodeToUnifiedName
+    unified = unicodeToUnifiedName(native);
+  }
+
+  short_names = [shortCode].concat(short_names);
+  emojis[shortCode] = {
+    native,
+    search,
+    short_names,
+    unified,
+  };
+});
+
+module.exports = {
+  emojis,
+  skins,
+  categories,
+  short_names,
+};
diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js b/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js
new file mode 100644
index 000000000..5755bf1c4
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js
@@ -0,0 +1,157 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
+
+import data from './emoji_mart_data_light';
+import { getData, getSanitizedData, intersect } from './emoji_utils';
+
+let originalPool = {};
+let index = {};
+let emojisList = {};
+let emoticonsList = {};
+
+for (let emoji in data.emojis) {
+  let emojiData = data.emojis[emoji];
+  let { short_names, emoticons } = emojiData;
+  let id = short_names[0];
+
+  if (emoticons) {
+    emoticons.forEach(emoticon => {
+      if (emoticonsList[emoticon]) {
+        return;
+      }
+
+      emoticonsList[emoticon] = id;
+    });
+  }
+
+  emojisList[id] = getSanitizedData(id);
+  originalPool[id] = emojiData;
+}
+
+function addCustomToPool(custom, pool) {
+  custom.forEach((emoji) => {
+    let emojiId = emoji.id || emoji.short_names[0];
+
+    if (emojiId && !pool[emojiId]) {
+      pool[emojiId] = getData(emoji);
+      emojisList[emojiId] = getSanitizedData(emoji);
+    }
+  });
+}
+
+function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
+  addCustomToPool(custom, originalPool);
+
+  maxResults = maxResults || 75;
+  include = include || [];
+  exclude = exclude || [];
+
+  let results = null,
+    pool = originalPool;
+
+  if (value.length) {
+    if (value === '-' || value === '-1') {
+      return [emojisList['-1']];
+    }
+
+    let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
+      allResults = [];
+
+    if (values.length > 2) {
+      values = [values[0], values[1]];
+    }
+
+    if (include.length || exclude.length) {
+      pool = {};
+
+      data.categories.forEach(category => {
+        let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
+        let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
+        if (!isIncluded || isExcluded) {
+          return;
+        }
+
+        category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
+      });
+
+      if (custom.length) {
+        let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
+        let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
+        if (customIsIncluded && !customIsExcluded) {
+          addCustomToPool(custom, pool);
+        }
+      }
+    }
+
+    allResults = values.map((value) => {
+      let aPool = pool,
+        aIndex = index,
+        length = 0;
+
+      for (let charIndex = 0; charIndex < value.length; charIndex++) {
+        const char = value[charIndex];
+        length++;
+
+        aIndex[char] = aIndex[char] || {};
+        aIndex = aIndex[char];
+
+        if (!aIndex.results) {
+          let scores = {};
+
+          aIndex.results = [];
+          aIndex.pool = {};
+
+          for (let id in aPool) {
+            let emoji = aPool[id],
+              { search } = emoji,
+              sub = value.substr(0, length),
+              subIndex = search.indexOf(sub);
+
+            if (subIndex !== -1) {
+              let score = subIndex + 1;
+              if (sub === id) score = 0;
+
+              aIndex.results.push(emojisList[id]);
+              aIndex.pool[id] = emoji;
+
+              scores[id] = score;
+            }
+          }
+
+          aIndex.results.sort((a, b) => {
+            let aScore = scores[a.id],
+              bScore = scores[b.id];
+
+            return aScore - bScore;
+          });
+        }
+
+        aPool = aIndex.pool;
+      }
+
+      return aIndex.results;
+    }).filter(a => a);
+
+    if (allResults.length > 1) {
+      results = intersect.apply(null, allResults);
+    } else if (allResults.length) {
+      results = allResults[0];
+    } else {
+      results = [];
+    }
+  }
+
+  if (results) {
+    if (emojisToShowFilter) {
+      results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
+    }
+
+    if (results && results.length > maxResults) {
+      results = results.slice(0, maxResults);
+    }
+  }
+
+  return results;
+}
+
+export { search };
diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_picker.js b/app/javascript/flavours/glitch/util/emoji/emoji_picker.js
new file mode 100644
index 000000000..7e145381e
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/emoji_picker.js
@@ -0,0 +1,7 @@
+import Picker from 'emoji-mart/dist-es/components/picker';
+import Emoji from 'emoji-mart/dist-es/components/emoji';
+
+export {
+  Picker,
+  Emoji,
+};
diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_unicode_mapping_light.js b/app/javascript/flavours/glitch/util/emoji/emoji_unicode_mapping_light.js
new file mode 100644
index 000000000..918684c31
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/emoji_unicode_mapping_light.js
@@ -0,0 +1,35 @@
+// A mapping of unicode strings to an object containing the filename
+// (i.e. the svg filename) and a shortCode intended to be shown
+// as a "title" attribute in an HTML element (aka tooltip).
+
+const [
+  shortCodesToEmojiData,
+  skins, // eslint-disable-line no-unused-vars
+  categories, // eslint-disable-line no-unused-vars
+  short_names, // eslint-disable-line no-unused-vars
+  emojisWithoutShortCodes,
+] = require('./emoji_compressed');
+const { unicodeToFilename } = require('./unicode_to_filename');
+
+// decompress
+const unicodeMapping = {};
+
+function processEmojiMapData(emojiMapData, shortCode) {
+  let [ native, filename ] = emojiMapData;
+  if (!filename) {
+    // filename name can be derived from unicodeToFilename
+    filename = unicodeToFilename(native);
+  }
+  unicodeMapping[native] = {
+    shortCode: shortCode,
+    filename: filename,
+  };
+}
+
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+  let [ filenameData ] = shortCodesToEmojiData[shortCode];
+  filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
+});
+emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
+
+module.exports = unicodeMapping;
diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_utils.js b/app/javascript/flavours/glitch/util/emoji/emoji_utils.js
new file mode 100644
index 000000000..dbf725c1f
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/emoji_utils.js
@@ -0,0 +1,258 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
+
+import data from './emoji_mart_data_light';
+
+const buildSearch = (data) => {
+  const search = [];
+
+  let addToSearch = (strings, split) => {
+    if (!strings) {
+      return;
+    }
+
+    (Array.isArray(strings) ? strings : [strings]).forEach((string) => {
+      (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
+        s = s.toLowerCase();
+
+        if (search.indexOf(s) === -1) {
+          search.push(s);
+        }
+      });
+    });
+  };
+
+  addToSearch(data.short_names, true);
+  addToSearch(data.name, true);
+  addToSearch(data.keywords, false);
+  addToSearch(data.emoticons, false);
+
+  return search.join(',');
+};
+
+const _String = String;
+
+const stringFromCodePoint = _String.fromCodePoint || function () {
+  let MAX_SIZE = 0x4000;
+  let codeUnits = [];
+  let highSurrogate;
+  let lowSurrogate;
+  let index = -1;
+  let length = arguments.length;
+  if (!length) {
+    return '';
+  }
+  let result = '';
+  while (++index < length) {
+    let codePoint = Number(arguments[index]);
+    if (
+      !isFinite(codePoint) ||       // `NaN`, `+Infinity`, or `-Infinity`
+      codePoint < 0 ||              // not a valid Unicode code point
+      codePoint > 0x10FFFF ||       // not a valid Unicode code point
+      Math.floor(codePoint) !== codePoint // not an integer
+    ) {
+      throw RangeError('Invalid code point: ' + codePoint);
+    }
+    if (codePoint <= 0xFFFF) { // BMP code point
+      codeUnits.push(codePoint);
+    } else { // Astral code point; split in surrogate halves
+      // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
+      codePoint -= 0x10000;
+      highSurrogate = (codePoint >> 10) + 0xD800;
+      lowSurrogate = (codePoint % 0x400) + 0xDC00;
+      codeUnits.push(highSurrogate, lowSurrogate);
+    }
+    if (index + 1 === length || codeUnits.length > MAX_SIZE) {
+      result += String.fromCharCode.apply(null, codeUnits);
+      codeUnits.length = 0;
+    }
+  }
+  return result;
+};
+
+
+const _JSON = JSON;
+
+const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
+const SKINS = [
+  '1F3FA', '1F3FB', '1F3FC',
+  '1F3FD', '1F3FE', '1F3FF',
+];
+
+function unifiedToNative(unified) {
+  let unicodes = unified.split('-'),
+    codePoints = unicodes.map((u) => `0x${u}`);
+
+  return stringFromCodePoint.apply(null, codePoints);
+}
+
+function sanitize(emoji) {
+  let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
+    id = emoji.id || short_names[0],
+    colons = `:${id}:`;
+
+  if (custom) {
+    return {
+      id,
+      name,
+      colons,
+      emoticons,
+      custom,
+      imageUrl,
+    };
+  }
+
+  if (skin_tone) {
+    colons += `:skin-tone-${skin_tone}:`;
+  }
+
+  return {
+    id,
+    name,
+    colons,
+    emoticons,
+    unified: unified.toLowerCase(),
+    skin: skin_tone || (skin_variations ? 1 : null),
+    native: unifiedToNative(unified),
+  };
+}
+
+function getSanitizedData() {
+  return sanitize(getData(...arguments));
+}
+
+function getData(emoji, skin, set) {
+  let emojiData = {};
+
+  if (typeof emoji === 'string') {
+    let matches = emoji.match(COLONS_REGEX);
+
+    if (matches) {
+      emoji = matches[1];
+
+      if (matches[2]) {
+        skin = parseInt(matches[2]);
+      }
+    }
+
+    if (data.short_names.hasOwnProperty(emoji)) {
+      emoji = data.short_names[emoji];
+    }
+
+    if (data.emojis.hasOwnProperty(emoji)) {
+      emojiData = data.emojis[emoji];
+    }
+  } else if (emoji.id) {
+    if (data.short_names.hasOwnProperty(emoji.id)) {
+      emoji.id = data.short_names[emoji.id];
+    }
+
+    if (data.emojis.hasOwnProperty(emoji.id)) {
+      emojiData = data.emojis[emoji.id];
+      skin = skin || emoji.skin;
+    }
+  }
+
+  if (!Object.keys(emojiData).length) {
+    emojiData = emoji;
+    emojiData.custom = true;
+
+    if (!emojiData.search) {
+      emojiData.search = buildSearch(emoji);
+    }
+  }
+
+  emojiData.emoticons = emojiData.emoticons || [];
+  emojiData.variations = emojiData.variations || [];
+
+  if (emojiData.skin_variations && skin > 1 && set) {
+    emojiData = JSON.parse(_JSON.stringify(emojiData));
+
+    let skinKey = SKINS[skin - 1],
+      variationData = emojiData.skin_variations[skinKey];
+
+    if (!variationData.variations && emojiData.variations) {
+      delete emojiData.variations;
+    }
+
+    if (variationData[`has_img_${set}`]) {
+      emojiData.skin_tone = skin;
+
+      for (let k in variationData) {
+        let v = variationData[k];
+        emojiData[k] = v;
+      }
+    }
+  }
+
+  if (emojiData.variations && emojiData.variations.length) {
+    emojiData = JSON.parse(_JSON.stringify(emojiData));
+    emojiData.unified = emojiData.variations.shift();
+  }
+
+  return emojiData;
+}
+
+function uniq(arr) {
+  return arr.reduce((acc, item) => {
+    if (acc.indexOf(item) === -1) {
+      acc.push(item);
+    }
+    return acc;
+  }, []);
+}
+
+function intersect(a, b) {
+  const uniqA = uniq(a);
+  const uniqB = uniq(b);
+
+  return uniqA.filter(item => uniqB.indexOf(item) >= 0);
+}
+
+function deepMerge(a, b) {
+  let o = {};
+
+  for (let key in a) {
+    let originalValue = a[key],
+      value = originalValue;
+
+    if (b.hasOwnProperty(key)) {
+      value = b[key];
+    }
+
+    if (typeof value === 'object') {
+      value = deepMerge(originalValue, value);
+    }
+
+    o[key] = value;
+  }
+
+  return o;
+}
+
+// https://github.com/sonicdoe/measure-scrollbar
+function measureScrollbar() {
+  const div = document.createElement('div');
+
+  div.style.width = '100px';
+  div.style.height = '100px';
+  div.style.overflow = 'scroll';
+  div.style.position = 'absolute';
+  div.style.top = '-9999px';
+
+  document.body.appendChild(div);
+  const scrollbarWidth = div.offsetWidth - div.clientWidth;
+  document.body.removeChild(div);
+
+  return scrollbarWidth;
+}
+
+export {
+  getData,
+  getSanitizedData,
+  uniq,
+  intersect,
+  deepMerge,
+  unifiedToNative,
+  measureScrollbar,
+};
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
new file mode 100644
index 000000000..c6416db2d
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -0,0 +1,96 @@
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import unicodeMapping from './emoji_unicode_mapping_light';
+import Trie from 'substring-trie';
+
+const trie = new Trie(Object.keys(unicodeMapping));
+
+const assetHost = process.env.CDN_HOST || '';
+
+const emojify = (str, customEmojis = {}) => {
+  const tagCharsWithoutEmojis = '<&';
+  const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
+  let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
+  for (;;) {
+    let match, i = 0, tag;
+    while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) {
+      i += str.codePointAt(i) < 65536 ? 1 : 2;
+    }
+    let rend, replacement = '';
+    if (i === str.length) {
+      break;
+    } else if (str[i] === ':') {
+      if (!(() => {
+        rend = str.indexOf(':', i + 1) + 1;
+        if (!rend) return false; // no pair of ':'
+        const lt = str.indexOf('<', i + 1);
+        if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
+        const shortname = str.slice(i, rend);
+        // now got a replacee as ':shortname:'
+        // if you want additional emoji handler, add statements below which set replacement and return true.
+        if (shortname in customEmojis) {
+          const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
+          replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
+          return true;
+        }
+        return false;
+      })()) rend = ++i;
+    } else if (tag >= 0) { // <, &
+      rend = str.indexOf('>;'[tag], i + 1) + 1;
+      if (!rend) {
+        break;
+      }
+      if (tag === 0) {
+        if (invisible) {
+          if (str[i + 1] === '/') { // closing tag
+            if (!--invisible) {
+              tagChars = tagCharsWithEmojis;
+            }
+          } else if (str[rend - 2] !== '/') { // opening tag
+            invisible++;
+          }
+        } else {
+          if (str.startsWith('<span class="invisible">', i)) {
+            // avoid emojifying on invisible text
+            invisible = 1;
+            tagChars = tagCharsWithoutEmojis;
+          }
+        }
+      }
+      i = rend;
+    } else { // matched to unicode emoji
+      const { filename, shortCode } = unicodeMapping[match];
+      const title = shortCode ? `:${shortCode}:` : '';
+      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
+      rend = i + match.length;
+    }
+    rtn += str.slice(0, i) + replacement;
+    str = str.slice(rend);
+  }
+  return rtn + str;
+};
+
+export default emojify;
+export { unicodeMapping };
+
+export const buildCustomEmojis = (customEmojis) => {
+  const emojis = [];
+
+  customEmojis.forEach(emoji => {
+    const shortcode = emoji.get('shortcode');
+    const url       = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
+    const name      = shortcode.replace(':', '');
+
+    emojis.push({
+      id: name,
+      name,
+      short_names: [name],
+      text: '',
+      emoticons: [],
+      keywords: [name],
+      imageUrl: url,
+      custom: true,
+    });
+  });
+
+  return emojis;
+};
diff --git a/app/javascript/flavours/glitch/util/emoji/unicode_to_filename.js b/app/javascript/flavours/glitch/util/emoji/unicode_to_filename.js
new file mode 100644
index 000000000..c75c4cd7d
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/unicode_to_filename.js
@@ -0,0 +1,26 @@
+// taken from:
+// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
+exports.unicodeToFilename = (str) => {
+  let result = '';
+  let charCode = 0;
+  let p = 0;
+  let i = 0;
+  while (i < str.length) {
+    charCode = str.charCodeAt(i++);
+    if (p) {
+      if (result.length > 0) {
+        result += '-';
+      }
+      result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
+      p = 0;
+    } else if (0xD800 <= charCode && charCode <= 0xDBFF) {
+      p = charCode;
+    } else {
+      if (result.length > 0) {
+        result += '-';
+      }
+      result += charCode.toString(16);
+    }
+  }
+  return result;
+};
diff --git a/app/javascript/flavours/glitch/util/emoji/unicode_to_unified_name.js b/app/javascript/flavours/glitch/util/emoji/unicode_to_unified_name.js
new file mode 100644
index 000000000..808ac197e
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/emoji/unicode_to_unified_name.js
@@ -0,0 +1,17 @@
+function padLeft(str, num) {
+  while (str.length < num) {
+    str = '0' + str;
+  }
+  return str;
+}
+
+exports.unicodeToUnifiedName = (str) => {
+  let output = '';
+  for (let i = 0; i < str.length; i += 2) {
+    if (i > 0) {
+      output += '-';
+    }
+    output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
+  }
+  return output;
+};
diff --git a/app/javascript/flavours/glitch/util/extra_polyfills.js b/app/javascript/flavours/glitch/util/extra_polyfills.js
new file mode 100644
index 000000000..3acc55abd
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/extra_polyfills.js
@@ -0,0 +1,5 @@
+import 'intersection-observer';
+import 'requestidlecallback';
+import objectFitImages  from 'object-fit-images';
+
+objectFitImages();
diff --git a/app/javascript/flavours/glitch/util/fullscreen.js b/app/javascript/flavours/glitch/util/fullscreen.js
new file mode 100644
index 000000000..cf5d0cf98
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/fullscreen.js
@@ -0,0 +1,46 @@
+// APIs for normalizing fullscreen operations. Note that Edge uses
+// the WebKit-prefixed APIs currently (as of Edge 16).
+
+export const isFullscreen = () => document.fullscreenElement ||
+  document.webkitFullscreenElement ||
+  document.mozFullScreenElement;
+
+export const exitFullscreen = () => {
+  if (document.exitFullscreen) {
+    document.exitFullscreen();
+  } else if (document.webkitExitFullscreen) {
+    document.webkitExitFullscreen();
+  } else if (document.mozCancelFullScreen) {
+    document.mozCancelFullScreen();
+  }
+};
+
+export const requestFullscreen = el => {
+  if (el.requestFullscreen) {
+    el.requestFullscreen();
+  } else if (el.webkitRequestFullscreen) {
+    el.webkitRequestFullscreen();
+  } else if (el.mozRequestFullScreen) {
+    el.mozRequestFullScreen();
+  }
+};
+
+export const attachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.addEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.addEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.addEventListener('mozfullscreenchange', listener);
+  }
+};
+
+export const detachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.removeEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.removeEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.removeEventListener('mozfullscreenchange', listener);
+  }
+};
diff --git a/app/javascript/flavours/glitch/util/get_rect_from_entry.js b/app/javascript/flavours/glitch/util/get_rect_from_entry.js
new file mode 100644
index 000000000..c266cd7dc
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/get_rect_from_entry.js
@@ -0,0 +1,21 @@
+
+// Get the bounding client rect from an IntersectionObserver entry.
+// This is to work around a bug in Chrome: https://crbug.com/737228
+
+let hasBoundingRectBug;
+
+function getRectFromEntry(entry) {
+  if (typeof hasBoundingRectBug !== 'boolean') {
+    const boundingRect = entry.target.getBoundingClientRect();
+    const observerRect = entry.boundingClientRect;
+    hasBoundingRectBug = boundingRect.height !== observerRect.height ||
+      boundingRect.top !== observerRect.top ||
+      boundingRect.width !== observerRect.width ||
+      boundingRect.bottom !== observerRect.bottom ||
+      boundingRect.left !== observerRect.left ||
+      boundingRect.right !== observerRect.right;
+  }
+  return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect;
+}
+
+export default getRectFromEntry;
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
new file mode 100644
index 000000000..ab502f9d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -0,0 +1,23 @@
+const element = document.getElementById('initial-state');
+const initialState = element && function () {
+  const result = JSON.parse(element.textContent);
+  try {
+    result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
+  } catch (e) {
+    result.local_settings = {};
+  }
+  return result;
+}();
+
+const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
+
+export const reduceMotion = getMeta('reduce_motion');
+export const autoPlayGif = getMeta('auto_play_gif');
+export const unfollowModal = getMeta('unfollow_modal');
+export const boostModal = getMeta('boost_modal');
+export const favouriteModal = getMeta('favourite_modal');
+export const deleteModal = getMeta('delete_modal');
+export const me = getMeta('me');
+export const maxChars = (initialState && initialState.max_toot_chars) || 500;
+
+export default initialState;
diff --git a/app/javascript/flavours/glitch/util/intersection_observer_wrapper.js b/app/javascript/flavours/glitch/util/intersection_observer_wrapper.js
new file mode 100644
index 000000000..2b24c6583
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/intersection_observer_wrapper.js
@@ -0,0 +1,57 @@
+// Wrapper for IntersectionObserver in order to make working with it
+// a bit easier. We also follow this performance advice:
+// "If you need to observe multiple elements, it is both possible and
+// advised to observe multiple elements using the same IntersectionObserver
+// instance by calling observe() multiple times."
+// https://developers.google.com/web/updates/2016/04/intersectionobserver
+
+class IntersectionObserverWrapper {
+
+  callbacks = {};
+  observerBacklog = [];
+  observer = null;
+
+  connect (options) {
+    const onIntersection = (entries) => {
+      entries.forEach(entry => {
+        const id = entry.target.getAttribute('data-id');
+        if (this.callbacks[id]) {
+          this.callbacks[id](entry);
+        }
+      });
+    };
+
+    this.observer = new IntersectionObserver(onIntersection, options);
+    this.observerBacklog.forEach(([ id, node, callback ]) => {
+      this.observe(id, node, callback);
+    });
+    this.observerBacklog = null;
+  }
+
+  observe (id, node, callback) {
+    if (!this.observer) {
+      this.observerBacklog.push([ id, node, callback ]);
+    } else {
+      this.callbacks[id] = callback;
+      this.observer.observe(node);
+    }
+  }
+
+  unobserve (id, node) {
+    if (this.observer) {
+      delete this.callbacks[id];
+      this.observer.unobserve(node);
+    }
+  }
+
+  disconnect () {
+    if (this.observer) {
+      this.callbacks = {};
+      this.observer.disconnect();
+      this.observer = null;
+    }
+  }
+
+}
+
+export default IntersectionObserverWrapper;
diff --git a/app/javascript/flavours/glitch/util/is_mobile.js b/app/javascript/flavours/glitch/util/is_mobile.js
new file mode 100644
index 000000000..80e8e0a8a
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/is_mobile.js
@@ -0,0 +1,34 @@
+import detectPassiveEvents from 'detect-passive-events';
+
+const LAYOUT_BREAKPOINT = 630;
+
+export function isMobile(width, columns) {
+  switch (columns) {
+  case 'multiple':
+    return false;
+  case 'single':
+    return true;
+  default:
+    return width <= LAYOUT_BREAKPOINT;
+  }
+};
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+let userTouching = false;
+let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+function touchListener() {
+  userTouching = true;
+  window.removeEventListener('touchstart', touchListener, listenerOptions);
+}
+
+window.addEventListener('touchstart', touchListener, listenerOptions);
+
+export function isUserTouching() {
+  return userTouching;
+}
+
+export function isIOS() {
+  return iOS;
+};
diff --git a/app/javascript/flavours/glitch/util/js_helpers.js b/app/javascript/flavours/glitch/util/js_helpers.js
new file mode 100644
index 000000000..2ebd5b6c5
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/js_helpers.js
@@ -0,0 +1,5 @@
+//  This function returns the new value unless it is `null` or
+//  `undefined`, in which case it returns the old one.
+export function overwrite (oldVal, newVal) {
+  return newVal === null || typeof newVal === 'undefined' ? oldVal : newVal;
+}
diff --git a/app/javascript/flavours/glitch/util/link_header.js b/app/javascript/flavours/glitch/util/link_header.js
new file mode 100644
index 000000000..a3e7ccf1c
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/link_header.js
@@ -0,0 +1,33 @@
+import Link from 'http-link-header';
+import querystring from 'querystring';
+
+Link.parseAttrs = (link, parts) => {
+  let match = null;
+  let attr  = '';
+  let value = '';
+  let attrs = '';
+
+  let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts);
+
+  if(uriAttrs) {
+    attrs = uriAttrs[2];
+    link  = Link.parseParams(link, uriAttrs[1]);
+  }
+
+  while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign
+    attr  = match[1].toLowerCase();
+    value = match[4] || match[3] || match[2];
+
+    if( /\*$/.test(attr)) {
+      Link.setAttr(link, attr, Link.parseExtendedValue(value));
+    } else if(/%/.test(value)) {
+      Link.setAttr(link, attr, querystring.decode(value));
+    } else {
+      Link.setAttr(link, attr, value);
+    }
+  }
+
+  return link;
+};
+
+export default Link;
diff --git a/app/javascript/flavours/glitch/util/load_polyfills.js b/app/javascript/flavours/glitch/util/load_polyfills.js
new file mode 100644
index 000000000..8927b7358
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/load_polyfills.js
@@ -0,0 +1,39 @@
+// Convenience function to load polyfills and return a promise when it's done.
+// If there are no polyfills, then this is just Promise.resolve() which means
+// it will execute in the same tick of the event loop (i.e. near-instant).
+
+function importBasePolyfills() {
+  return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
+}
+
+function importExtraPolyfills() {
+  return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
+}
+
+function loadPolyfills() {
+  const needsBasePolyfills = !(
+    window.Intl &&
+    Object.assign &&
+    Number.isNaN &&
+    window.Symbol &&
+    Array.prototype.includes
+  );
+
+  // Latest version of Firefox and Safari do not have IntersectionObserver.
+  // Edge does not have requestIdleCallback and object-fit CSS property.
+  // This avoids shipping them all the polyfills.
+  const needsExtraPolyfills = !(
+    window.IntersectionObserver &&
+    window.IntersectionObserverEntry &&
+    'isIntersecting' in IntersectionObserverEntry.prototype &&
+    window.requestIdleCallback &&
+    'object-fit' in (new Image()).style
+  );
+
+  return Promise.all([
+    needsBasePolyfills && importBasePolyfills(),
+    needsExtraPolyfills && importExtraPolyfills(),
+  ]);
+}
+
+export default loadPolyfills;
diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js
new file mode 100644
index 000000000..c00210677
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/main.js
@@ -0,0 +1,39 @@
+import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
+import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ready from './ready';
+
+const perf = require('./performance');
+
+function main() {
+  perf.start('main()');
+
+  if (window.history && history.replaceState) {
+    const { pathname, search, hash } = window.location;
+    const path = pathname + search + hash;
+    if (!(/^\/web[$/]/).test(path)) {
+      history.replaceState(null, document.title, `/web${path}`);
+    }
+  }
+
+  ready(() => {
+    const mountNode = document.getElementById('mastodon');
+    const props = JSON.parse(mountNode.getAttribute('data-props'));
+
+    ReactDOM.render(<Mastodon {...props} />, mountNode);
+    if (process.env.NODE_ENV === 'production') {
+      // avoid offline in dev mode because it's harder to debug
+      require('offline-plugin/runtime').install();
+      store.dispatch(registerPushNotifications.register());
+    }
+    perf.stop('main()');
+
+    // remember the initial URL
+    if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') {
+      window._mastoInitialHistoryLen = window.history.length;
+    }
+  });
+}
+
+export default main;
diff --git a/app/javascript/flavours/glitch/util/optional_motion.js b/app/javascript/flavours/glitch/util/optional_motion.js
new file mode 100644
index 000000000..eecb6634e
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/optional_motion.js
@@ -0,0 +1,5 @@
+import { reduceMotion } from 'flavours/glitch/util/initial_state';
+import ReducedMotion from './reduced_motion';
+import Motion from 'react-motion/lib/Motion';
+
+export default reduceMotion ? ReducedMotion : Motion;
diff --git a/app/javascript/flavours/glitch/util/performance.js b/app/javascript/flavours/glitch/util/performance.js
new file mode 100644
index 000000000..450a90626
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/performance.js
@@ -0,0 +1,31 @@
+//
+// Tools for performance debugging, only enabled in development mode.
+// Open up Chrome Dev Tools, then Timeline, then User Timing to see output.
+// Also see config/webpack/loaders/mark.js for the webpack loader marks.
+//
+
+let marky;
+
+if (process.env.NODE_ENV === 'development') {
+  if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
+    // Increase Firefox's performance entry limit; otherwise it's capped to 150.
+    // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
+    performance.setResourceTimingBufferSize(Infinity);
+  }
+  marky = require('marky');
+  // allows us to easily do e.g. ReactPerf.printWasted() while debugging
+  //window.ReactPerf = require('react-addons-perf');
+  //window.ReactPerf.start();
+}
+
+export function start(name) {
+  if (process.env.NODE_ENV === 'development') {
+    marky.mark(name);
+  }
+}
+
+export function stop(name) {
+  if (process.env.NODE_ENV === 'development') {
+    marky.stop(name);
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/react_helpers.js b/app/javascript/flavours/glitch/util/react_helpers.js
new file mode 100644
index 000000000..082a58e62
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/react_helpers.js
@@ -0,0 +1,21 @@
+//  This function binds the given `handlers` to the `target`.
+export function assignHandlers (target, handlers) {
+  if (!target || !handlers) {
+    return;
+  }
+
+  //  We just bind each handler to the `target`.
+  const handle = target.handlers = {};
+  Object.keys(handlers).forEach(
+    key => handle[key] = handlers[key].bind(target)
+  );
+}
+
+//  This function only returns the component if the result of calling
+//  `test` with `data` is `true`.  Useful with funciton binding.
+export function conditionalRender (test, data, component) {
+  return test(data) ? component : null;
+}
+
+//  This object provides props to make the component not visible.
+export const hiddenComponent = { style: { display: 'none' } };
diff --git a/app/javascript/flavours/glitch/util/react_router_helpers.js b/app/javascript/flavours/glitch/util/react_router_helpers.js
new file mode 100644
index 000000000..1dba5e9bb
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/react_router_helpers.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Switch, Route } from 'react-router-dom';
+
+import ColumnLoading from 'flavours/glitch/features/ui/components/column_loading';
+import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
+import BundleContainer from 'flavours/glitch/features/ui/containers/bundle_container';
+
+// Small wrapper to pass multiColumn to the route components
+export class WrappedSwitch extends React.PureComponent {
+
+  render () {
+    const { multiColumn, children } = this.props;
+
+    return (
+      <Switch>
+        {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
+      </Switch>
+    );
+  }
+
+}
+
+WrappedSwitch.propTypes = {
+  multiColumn: PropTypes.bool,
+  children: PropTypes.node,
+};
+
+// Small Wraper to extract the params from the route and pass
+// them to the rendered component, together with the content to
+// be rendered inside (the children)
+export class WrappedRoute extends React.Component {
+
+  static propTypes = {
+    component: PropTypes.func.isRequired,
+    content: PropTypes.node,
+    multiColumn: PropTypes.bool,
+  }
+
+  renderComponent = ({ match }) => {
+    const { component, content, multiColumn } = this.props;
+
+    return (
+      <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
+        {Component => <Component params={match.params} multiColumn={multiColumn}>{content}</Component>}
+      </BundleContainer>
+    );
+  }
+
+  renderLoading = () => {
+    return <ColumnLoading />;
+  }
+
+  renderError = (props) => {
+    return <BundleColumnError {...props} />;
+  }
+
+  render () {
+    const { component: Component, content, ...rest } = this.props;
+
+    return <Route {...rest} render={this.renderComponent} />;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/util/ready.js b/app/javascript/flavours/glitch/util/ready.js
new file mode 100644
index 000000000..dd543910b
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/ready.js
@@ -0,0 +1,7 @@
+export default function ready(loaded) {
+  if (['interactive', 'complete'].includes(document.readyState)) {
+    loaded();
+  } else {
+    document.addEventListener('DOMContentLoaded', loaded);
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/reduced_motion.js b/app/javascript/flavours/glitch/util/reduced_motion.js
new file mode 100644
index 000000000..95519042b
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/reduced_motion.js
@@ -0,0 +1,44 @@
+// Like react-motion's Motion, but reduces all animations to cross-fades
+// for the benefit of users with motion sickness.
+import React from 'react';
+import Motion from 'react-motion/lib/Motion';
+import PropTypes from 'prop-types';
+
+const stylesToKeep = ['opacity', 'backgroundOpacity'];
+
+const extractValue = (value) => {
+  // This is either an object with a "val" property or it's a number
+  return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
+};
+
+class ReducedMotion extends React.Component {
+
+  static propTypes = {
+    defaultStyle: PropTypes.object,
+    style: PropTypes.object,
+    children: PropTypes.func,
+  }
+
+  render() {
+
+    const { style, defaultStyle, children } = this.props;
+
+    Object.keys(style).forEach(key => {
+      if (stylesToKeep.includes(key)) {
+        return;
+      }
+      // If it's setting an x or height or scale or some other value, we need
+      // to preserve the end-state value without actually animating it
+      style[key] = defaultStyle[key] = extractValue(style[key]);
+    });
+
+    return (
+      <Motion style={style} defaultStyle={defaultStyle}>
+        {children}
+      </Motion>
+    );
+  }
+
+}
+
+export default ReducedMotion;
diff --git a/app/javascript/flavours/glitch/util/redux_helpers.js b/app/javascript/flavours/glitch/util/redux_helpers.js
new file mode 100644
index 000000000..8eb338da7
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/redux_helpers.js
@@ -0,0 +1,8 @@
+import { injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+
+//  Connects a component.
+export function wrap (Component, mapStateToProps, mapDispatchToProps, options) {
+  const withIntl = typeof options === 'object' ? options.withIntl : !!options;
+  return (withIntl ? injectIntl : i => i)(connect(mapStateToProps, mapDispatchToProps)(Component));
+}
diff --git a/app/javascript/flavours/glitch/util/rtl.js b/app/javascript/flavours/glitch/util/rtl.js
new file mode 100644
index 000000000..00870a15d
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/rtl.js
@@ -0,0 +1,31 @@
+// U+0590  to U+05FF  - Hebrew
+// U+0600  to U+06FF  - Arabic
+// U+0700  to U+074F  - Syriac
+// U+0750  to U+077F  - Arabic Supplement
+// U+0780  to U+07BF  - Thaana
+// U+07C0  to U+07FF  - N'Ko
+// U+0800  to U+083F  - Samaritan
+// U+08A0  to U+08FF  - Arabic Extended-A
+// U+FB1D  to U+FB4F  - Hebrew presentation forms
+// U+FB50  to U+FDFF  - Arabic presentation forms A
+// U+FE70  to U+FEFF  - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+  if (text.length === 0) {
+    return false;
+  }
+
+  text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
+  text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
+  text = text.replace(/\s+/g, '');
+
+  const matches = text.match(rtlChars);
+
+  if (!matches) {
+    return false;
+  }
+
+  return matches.length / text.length > 0.3;
+};
diff --git a/app/javascript/flavours/glitch/util/schedule_idle_task.js b/app/javascript/flavours/glitch/util/schedule_idle_task.js
new file mode 100644
index 000000000..b04d4a8ee
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/schedule_idle_task.js
@@ -0,0 +1,29 @@
+// Wrapper to call requestIdleCallback() to schedule low-priority work.
+// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
+// for a good breakdown of the concepts behind this.
+
+import Queue from 'tiny-queue';
+
+const taskQueue = new Queue();
+let runningRequestIdleCallback = false;
+
+function runTasks(deadline) {
+  while (taskQueue.length && deadline.timeRemaining() > 0) {
+    taskQueue.shift()();
+  }
+  if (taskQueue.length) {
+    requestIdleCallback(runTasks);
+  } else {
+    runningRequestIdleCallback = false;
+  }
+}
+
+function scheduleIdleTask(task) {
+  taskQueue.push(task);
+  if (!runningRequestIdleCallback) {
+    runningRequestIdleCallback = true;
+    requestIdleCallback(runTasks);
+  }
+}
+
+export default scheduleIdleTask;
diff --git a/app/javascript/flavours/glitch/util/scroll.js b/app/javascript/flavours/glitch/util/scroll.js
new file mode 100644
index 000000000..2af07e0fb
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/scroll.js
@@ -0,0 +1,30 @@
+const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
+
+const scroll = (node, key, target) => {
+  const startTime = Date.now();
+  const offset    = node[key];
+  const gap       = target - offset;
+  const duration  = 1000;
+  let interrupt   = false;
+
+  const step = () => {
+    const elapsed    = Date.now() - startTime;
+    const percentage = elapsed / duration;
+
+    if (percentage > 1 || interrupt) {
+      return;
+    }
+
+    node[key] = easingOutQuint(0, elapsed, offset, gap, duration);
+    requestAnimationFrame(step);
+  };
+
+  step();
+
+  return () => {
+    interrupt = true;
+  };
+};
+
+export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/flavours/glitch/util/settings.js b/app/javascript/flavours/glitch/util/settings.js
new file mode 100644
index 000000000..dbd969cb1
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/settings.js
@@ -0,0 +1,46 @@
+export default class Settings {
+
+  constructor(keyBase = null) {
+    this.keyBase = keyBase;
+  }
+
+  generateKey(id) {
+    return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id;
+  }
+
+  set(id, data) {
+    const key = this.generateKey(id);
+    try {
+      const encodedData = JSON.stringify(data);
+      localStorage.setItem(key, encodedData);
+      return data;
+    } catch (e) {
+      return null;
+    }
+  }
+
+  get(id) {
+    const key = this.generateKey(id);
+    try {
+      const rawData = localStorage.getItem(key);
+      return JSON.parse(rawData);
+    } catch (e) {
+      return null;
+    }
+  }
+
+  remove(id) {
+    const data = this.get(id);
+    if (data) {
+      const key = this.generateKey(id);
+      try {
+        localStorage.removeItem(key);
+      } catch (e) {
+      }
+    }
+    return data;
+  }
+
+}
+
+export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');
diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js
new file mode 100644
index 000000000..36c68ffc5
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/stream.js
@@ -0,0 +1,73 @@
+import WebSocketClient from 'websocket.js';
+
+export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
+  return (dispatch, getState) => {
+    const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
+    const accessToken = getState().getIn(['meta', 'access_token']);
+    const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
+    let polling = null;
+
+    const setupPolling = () => {
+      polling = setInterval(() => {
+        pollingRefresh(dispatch);
+      }, 20000);
+    };
+
+    const clearPolling = () => {
+      if (polling) {
+        clearInterval(polling);
+        polling = null;
+      }
+    };
+
+    const subscription = getStream(streamingAPIBaseURL, accessToken, path, {
+      connected () {
+        if (pollingRefresh) {
+          clearPolling();
+        }
+        onConnect();
+      },
+
+      disconnected () {
+        if (pollingRefresh) {
+          setupPolling();
+        }
+        onDisconnect();
+      },
+
+      received (data) {
+        onReceive(data);
+      },
+
+      reconnected () {
+        if (pollingRefresh) {
+          clearPolling();
+          pollingRefresh(dispatch);
+        }
+        onConnect();
+      },
+
+    });
+
+    const disconnect = () => {
+      if (subscription) {
+        subscription.close();
+      }
+      clearPolling();
+    };
+
+    return disconnect;
+  };
+}
+
+
+export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
+  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+
+  ws.onopen      = connected;
+  ws.onmessage   = e => received(JSON.parse(e.data));
+  ws.onclose     = disconnected;
+  ws.onreconnect = reconnected;
+
+  return ws;
+};
diff --git a/app/javascript/flavours/glitch/util/url_regex.js b/app/javascript/flavours/glitch/util/url_regex.js
new file mode 100644
index 000000000..faed22169
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/url_regex.js
@@ -0,0 +1,196 @@
+const regexen = {};
+
+const regexSupplant = function(regex, flags) {
+  flags = flags || '';
+  if (typeof regex !== 'string') {
+    if (regex.global && flags.indexOf('g') < 0) {
+      flags += 'g';
+    }
+    if (regex.ignoreCase && flags.indexOf('i') < 0) {
+      flags += 'i';
+    }
+    if (regex.multiline && flags.indexOf('m') < 0) {
+      flags += 'm';
+    }
+
+    regex = regex.source;
+  }
+  return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
+    var newRegex = regexen[name] || '';
+    if (typeof newRegex !== 'string') {
+      newRegex = newRegex.source;
+    }
+    return newRegex;
+  }), flags);
+};
+
+const stringSupplant = function(str, values) {
+  return str.replace(/#\{(\w+)\}/g, function(match, name) {
+    return values[name] || '';
+  });
+};
+
+export const urlRegex = (function() {
+  regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/;
+  regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
+  regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/;
+  regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/);
+  regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
+  regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
+  regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
+  regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
+  regexen.validGTLD = regexSupplant(RegExp(
+    '(?:(?:' +
+    '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
+    '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
+    'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
+    'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
+    'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
+    'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
+    'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
+    'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
+    'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
+    'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
+    'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
+    'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
+    'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
+    'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
+    'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
+    'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
+    'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
+    'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
+    'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
+    'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
+    'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
+    'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
+    'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
+    'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
+    'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
+    'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
+    'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
+    'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
+    'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
+    'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
+    'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
+    'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
+    'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
+    'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
+    'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
+    'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
+    'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
+    'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
+    'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
+    'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
+    'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
+    'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
+    'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
+    'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
+    'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
+    'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
+    'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
+    'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
+    'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
+    'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
+    'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
+    'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
+    'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
+    'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
+    'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
+    'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
+    'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
+    'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
+    'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
+    'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
+    'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
+    'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
+    'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
+    'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
+    'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
+    'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
+    'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
+    'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
+    'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
+    'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
+    'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
+    'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
+    'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
+    'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
+    'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
+    'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
+    'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
+    'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
+    'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
+    'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
+    'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
+    'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
+    'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
+    'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
+    'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
+    'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
+    'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
+    'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
+  ')(?=[^0-9a-zA-Z@]|$))'));
+  regexen.validCCTLD = regexSupplant(RegExp(
+    '(?:(?:' +
+      '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
+      'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
+      'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
+      'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
+      'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
+      're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
+      'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
+      'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
+      'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
+      'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
+      'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
+  ')(?=[^0-9a-zA-Z@]|$))'));
+  regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
+  regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
+  regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
+  regexen.validPortNumber = /[0-9]+/;
+  regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
+  regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i);
+  // Allow URL paths to contain up to two nested levels of balanced parens
+  //  1. Used in Wikipedia URLs like /Primer_(film)
+  //  2. Used in IIS sessions like /S(dfd346)/
+  //  3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
+  regexen.validUrlBalancedParens = regexSupplant(
+    '\\('                                   +
+      '(?:'                                 +
+        '#{validGeneralUrlPathChars}+'      +
+        '|'                                 +
+        // allow one nested level of balanced parentheses
+        '(?:'                               +
+          '#{validGeneralUrlPathChars}*'    +
+          '\\('                             +
+            '#{validGeneralUrlPathChars}+'  +
+          '\\)'                             +
+          '#{validGeneralUrlPathChars}*'    +
+        ')'                                 +
+      ')'                                   +
+    '\\)'
+    , 'i');
+  // Valid end-of-path chracters (so /foo. does not gobble the period).
+  // 1. Allow =&# for empty URL parameters and other URL-join artifacts
+  regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i);
+  // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
+  regexen.validUrlPath = regexSupplant('(?:' +
+    '(?:' +
+      '#{validGeneralUrlPathChars}*' +
+        '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
+        '#{validUrlPathEndingChars}'+
+      ')|(?:@#{validGeneralUrlPathChars}+\/)'+
+    ')', 'i');
+  regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i;
+  regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i;
+  regexen.validUrl = regexSupplant(
+    '('                                                          + // $1 URL
+      '(https?:\\/\\/)'                                          + // $2 Protocol
+      '(#{validDomain})'                                         + // $3 Domain(s)
+      '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional)
+      '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path
+      '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String
+    ')'
+    , 'gi');
+  return regexen.validUrl;
+}());
diff --git a/app/javascript/flavours/glitch/util/uuid.js b/app/javascript/flavours/glitch/util/uuid.js
new file mode 100644
index 000000000..be1899305
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/uuid.js
@@ -0,0 +1,3 @@
+export default function uuid(a) {
+  return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid);
+};
diff --git a/app/javascript/flavours/vanilla/names.yml b/app/javascript/flavours/vanilla/names.yml
new file mode 100644
index 000000000..1b3dab9c9
--- /dev/null
+++ b/app/javascript/flavours/vanilla/names.yml
@@ -0,0 +1,16 @@
+en:
+  flavours:
+    vanilla:
+      description: The theme used by vanilla Mastodon instances. This theme might not support all of the features of GlitchSoc.
+      name: Vanilla Mastodon
+  skins:
+    vanilla:
+      default: Default
+pl:
+  flavours:
+    vanilla:
+      description: Motyw używany przez instancje czystego Mastodona. Może nie obsługiwać wszystkich funkcji GlitchSoc.
+      name: Mastodon Vanilla
+  skins:
+    vanilla:
+      default: Domyślny
diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml
new file mode 100644
index 000000000..f54c231fe
--- /dev/null
+++ b/app/javascript/flavours/vanilla/theme.yml
@@ -0,0 +1,44 @@
+#  (REQUIRED) The location of the pack files inside `pack_directory`.
+pack:
+  about: about.js
+  admin: public.js
+  auth:
+  common:
+    filename: common.js
+    stylesheet: true
+  embed: public.js
+  error:
+  home:
+    filename: application.js
+    preload:
+    - features/getting_started
+    - features/compose
+    - features/home_timeline
+    - features/notifications
+  mailer:
+  modal:
+  public: public.js
+  settings:
+  share: share.js
+
+#  (OPTIONAL) The directory which contains localization files for
+#  the flavour, relative to this directory.
+locales: ../../mastodon/locales
+
+#  (OPTIONAL) A file to use as the preview screenshot for the flavour,
+#  or an array thereof. These filenames must be unique across all
+#  images (regardless of path), so it's a good idea to namespace them
+#  to your theme. It's up to you to let webpack know to compile them.
+screenshot: screenshot.jpg
+
+#  (OPTIONAL) The directory which contains the pack files.
+#  Defaults to this directory (`app/javascript/flavour/[flavour]`),
+#  but in the case of the vanilla Mastodon flavour the pack files are
+#  somewhere else.
+pack_directory: app/javascript/packs
+
+#  (OPTIONAL) By default the theme will fallback to the default flavour
+#  if a particular pack is not provided. You can specify different
+#  fallbacks here, or disable fallback behaviours altogether by
+#  specifying a `null` value.
+fallback:
diff --git a/app/javascript/images/icon_cached.svg b/app/javascript/images/icon_cached.svg
new file mode 100644
index 000000000..1087c4350
--- /dev/null
+++ b/app/javascript/images/icon_cached.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg width="2048" height="1792" viewBox="0 0 2048 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1344 1504q0 13-9.5 22.5t-22.5 9.5h-960q-8 0-13.5-2t-9-7-5.5-8-3-11.5-1-11.5v-600h-192q-26 0-45-19t-19-45q0-24 15-41l320-384q19-22 49-22t49 22l320 384q15 17 15 41 0 26-19 45t-45 19h-192v384h576q16 0 25 11l160 192q7 10 7 21zm640-416q0 24-15 41l-320 384q-20 23-49 23t-49-23l-320-384q-15-17-15-41 0-26 19-45t45-19h192v-384h-576q-16 0-25-12l-160-192q-7-9-7-20 0-13 9.5-22.5t22.5-9.5h960q8 0 13.5 2t9 7 5.5 8 3 11.5 1 11.5v600h192q26 0 45 19t19 45z" fill="#fff"/></svg>
diff --git a/app/javascript/images/icon_email.svg b/app/javascript/images/icon_email.svg
new file mode 100644
index 000000000..6d0ad9d9b
--- /dev/null
+++ b/app/javascript/images/icon_email.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/app/javascript/images/icon_grade.svg b/app/javascript/images/icon_grade.svg
new file mode 100644
index 000000000..f48b46889
--- /dev/null
+++ b/app/javascript/images/icon_grade.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/app/javascript/images/icon_lock_open.svg b/app/javascript/images/icon_lock_open.svg
new file mode 100644
index 000000000..3288b46d6
--- /dev/null
+++ b/app/javascript/images/icon_lock_open.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z"/>
+</svg>
\ No newline at end of file
diff --git a/app/javascript/images/icon_person_add.svg b/app/javascript/images/icon_person_add.svg
new file mode 100644
index 000000000..068b8ae7c
--- /dev/null
+++ b/app/javascript/images/icon_person_add.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
+</svg>
\ No newline at end of file
diff --git a/app/javascript/images/icon_reply.svg b/app/javascript/images/icon_reply.svg
new file mode 100644
index 000000000..cf6a09abc
--- /dev/null
+++ b/app/javascript/images/icon_reply.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/app/javascript/images/logo_transparent.svg b/app/javascript/images/logo_transparent.svg
new file mode 100644
index 000000000..abd6d1f67
--- /dev/null
+++ b/app/javascript/images/logo_transparent.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" fill="#fff"/></svg>
\ No newline at end of file
diff --git a/app/javascript/images/screenshot.jpg b/app/javascript/images/screenshot.jpg
new file mode 100644
index 000000000..45b270fbb
--- /dev/null
+++ b/app/javascript/images/screenshot.jpg
Binary files differdiff --git a/app/javascript/locales/index.js b/app/javascript/locales/index.js
new file mode 100644
index 000000000..421cb7fab
--- /dev/null
+++ b/app/javascript/locales/index.js
@@ -0,0 +1,9 @@
+let theLocale;
+
+export function setLocale(locale) {
+  theLocale = locale;
+}
+
+export function getLocale() {
+  return theLocale;
+}
diff --git a/app/javascript/mastodon/locales/locale-data/README.md b/app/javascript/locales/locale-data/README.md
index 83368fae7..83368fae7 100644
--- a/app/javascript/mastodon/locales/locale-data/README.md
+++ b/app/javascript/locales/locale-data/README.md
diff --git a/app/javascript/mastodon/locales/locale-data/oc.js b/app/javascript/locales/locale-data/oc.js
index c4b56350b..c4b56350b 100644
--- a/app/javascript/mastodon/locales/locale-data/oc.js
+++ b/app/javascript/locales/locale-data/oc.js
diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js
index 1d040bc8c..5f47a5501 100644
--- a/app/javascript/mastodon/actions/push_notifications/registerer.js
+++ b/app/javascript/mastodon/actions/push_notifications/registerer.js
@@ -1,4 +1,4 @@
-import axios from 'axios';
+import api from '../../api';
 import { pushNotificationsSetting } from '../../settings';
 import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
 
@@ -35,7 +35,7 @@ const subscribe = (registration) =>
 const unsubscribe = ({ registration, subscription }) =>
   subscription ? subscription.unsubscribe().then(() => registration) : registration;
 
-const sendSubscriptionToBackend = (subscription, me) => {
+const sendSubscriptionToBackend = (getState, subscription, me) => {
   const params = { subscription };
 
   if (me) {
@@ -45,7 +45,7 @@ const sendSubscriptionToBackend = (subscription, me) => {
     }
   }
 
-  return axios.post('/api/web/push_subscriptions', params).then(response => response.data);
+  return api(getState).post('/api/web/push_subscriptions', params).then(response => response.data);
 };
 
 // Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
@@ -85,13 +85,13 @@ export function register () {
             } else {
               // Something went wrong, try to subscribe again
               return unsubscribe({ registration, subscription }).then(subscribe).then(
-                subscription => sendSubscriptionToBackend(subscription, me));
+                subscription => sendSubscriptionToBackend(getState, subscription, me));
             }
           }
 
           // No subscription, try to subscribe
           return subscribe(registration).then(
-            subscription => sendSubscriptionToBackend(subscription, me));
+            subscription => sendSubscriptionToBackend(getState, subscription, me));
         })
         .then(subscription => {
           // If we got a PushSubscription (and not a subscription object from the backend)
@@ -137,7 +137,7 @@ export function saveSettings() {
     const alerts = state.get('alerts');
     const data = { alerts };
 
-    axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+    api(getState).put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
       data,
     }).then(() => {
       const me = getState().getIn(['meta', 'me']);
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
index aeef43527..b96383daa 100644
--- a/app/javascript/mastodon/actions/settings.js
+++ b/app/javascript/mastodon/actions/settings.js
@@ -1,4 +1,4 @@
-import axios from 'axios';
+import api from '../api';
 import { debounce } from 'lodash';
 
 export const SETTING_CHANGE = 'SETTING_CHANGE';
@@ -23,7 +23,7 @@ const debouncedSave = debounce((dispatch, getState) => {
 
   const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
 
-  axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+  api(getState).put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
 }, 5000, { trailing: true });
 
 export function saveSettings() {
diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js
index ecc703c0a..0be08d7fd 100644
--- a/app/javascript/mastodon/api.js
+++ b/app/javascript/mastodon/api.js
@@ -1,4 +1,5 @@
 import axios from 'axios';
+import ready from './ready';
 import LinkHeader from './link_header';
 
 export const getLinks = response => {
@@ -11,10 +12,17 @@ export const getLinks = response => {
   return LinkHeader.parse(value);
 };
 
+let csrfHeader = {};
+function setCSRFHeader() {
+  const csrfToken = document.querySelector('meta[name=csrf-token]').content;
+  csrfHeader['X-CSRF-Token'] = csrfToken;
+}
+ready(setCSRFHeader);
+
 export default getState => axios.create({
-  headers: {
+  headers: Object.assign(csrfHeader, getState ? {
     'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
-  },
+  } : {}),
 
   transformResponse: [function (data) {
     try {
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
index 533359ffe..29fdc2412 100644
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
@@ -4,14 +4,16 @@ exports[`<DisplayName /> renders display name + account name 1`] = `
 <span
   className="display-name"
 >
-  <strong
-    className="display-name__html"
-    dangerouslySetInnerHTML={
-      Object {
-        "__html": "<p>Foo</p>",
+  <bdi>
+    <strong
+      className="display-name__html"
+      dangerouslySetInnerHTML={
+        Object {
+          "__html": "<p>Foo</p>",
+        }
       }
-    }
-  />
+    />
+  </bdi>
    
   <span
     className="display-name__account"
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index 81459731c..a3642e61d 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent {
             {hidingNotificationsButton}
           </Fragment>
         );
-      } else if (!account.get('moved')) {
+      } 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} />;
       }
     }
diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js
index b3d00b335..9f2d46ddd 100644
--- a/app/javascript/mastodon/components/attachment_list.js
+++ b/app/javascript/mastodon/components/attachment_list.js
@@ -20,11 +20,11 @@ export default class AttachmentList extends ImmutablePureComponent {
         </div>
 
         <ul className='attachment-list__list'>
-          {media.map(attachment =>
+          {media.map(attachment => (
             <li key={attachment.get('id')}>
               <a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
             </li>
-          )}
+          ))}
         </ul>
       </div>
     );
diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js
index 42ea37ec2..d5d431186 100644
--- a/app/javascript/mastodon/components/collapsable.js
+++ b/app/javascript/mastodon/components/collapsable.js
@@ -5,11 +5,11 @@ import PropTypes from 'prop-types';
 
 const Collapsable = ({ fullHeight, isVisible, children }) => (
   <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
-    {({ opacity, height }) =>
+    {({ opacity, height }) => (
       <div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
         {children}
       </div>
-    }
+    )}
   </Motion>
 );
 
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index 80a8fbdb3..c300db89b 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -23,7 +23,6 @@ export default class ColumnHeader extends React.PureComponent {
     icon: PropTypes.string.isRequired,
     active: PropTypes.bool,
     multiColumn: PropTypes.bool,
-    focusable: PropTypes.bool,
     showBackButton: PropTypes.bool,
     children: PropTypes.node,
     pinned: PropTypes.bool,
@@ -32,10 +31,6 @@ export default class ColumnHeader extends React.PureComponent {
     onClick: PropTypes.func,
   };
 
-  static defaultProps = {
-    focusable: true,
-  }
-
   state = {
     collapsed: true,
     animating: false,
@@ -68,7 +63,7 @@ export default class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props;
+    const { title, icon, active, children, pinned, onPin, multiColumn, showBackButton, intl: { formatMessage } } = this.props;
     const { collapsed, animating } = this.state;
 
     const wrapperClassName = classNames('column-header__wrapper', {
@@ -135,11 +130,13 @@ export default class ColumnHeader extends React.PureComponent {
 
     return (
       <div className={wrapperClassName}>
-        <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
-          <i className={`fa fa-fw fa-${icon} column-header__icon`} />
-          <span className='column-header__title'>
-            {title}
-          </span>
+        <h1 className={buttonClassName}>
+          <button onClick={this.handleTitleClick}>
+            <i className={`fa fa-fw fa-${icon} column-header__icon`} />
+            <span className='column-header__title'>
+              {title}
+            </span>
+          </button>
 
           <div className='column-header__buttons'>
             {backButton}
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index 2cf84f8f4..a1c56ae35 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -12,7 +12,7 @@ export default class DisplayName extends React.PureComponent {
 
     return (
       <span className='display-name'>
-        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
+        <bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
       </span>
     );
   }
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 06f53841d..b96e48fd0 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -93,7 +93,7 @@ export default class IconButton extends React.PureComponent {
 
     return (
       <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
-        {({ rotate }) =>
+        {({ rotate }) => (
           <button
             aria-label={title}
             aria-pressed={pressed}
@@ -106,7 +106,7 @@ export default class IconButton extends React.PureComponent {
           >
             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
           </button>
-        }
+        )}
       </Motion>
     );
   }
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index d23ff87fa..c030510a0 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -162,7 +162,7 @@ export default class Status extends ImmutablePureComponent {
       prepend = (
         <div className='status__prepend'>
           <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
-          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
+          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
         </div>
       );
 
@@ -178,14 +178,16 @@ export default class Status extends ImmutablePureComponent {
 
         media = (
           <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
-            {Component => <Component
-              preview={video.get('preview_url')}
-              src={video.get('url')}
-              width={239}
-              height={110}
-              sensitive={status.get('sensitive')}
-              onOpenVideo={this.handleOpenVideo}
-            />}
+            {Component => (
+              <Component
+                preview={video.get('preview_url')}
+                src={video.get('url')}
+                width={239}
+                height={110}
+                sensitive={status.get('sensitive')}
+                onOpenVideo={this.handleOpenVideo}
+              />
+            )}
           </Bundle>
         );
       } else {
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index b2399ae9b..b8605d11f 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -41,7 +41,7 @@ class Avatar extends ImmutablePureComponent {
 
     return (
       <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
-        {({ radius }) =>
+        {({ radius }) => (
           <a
             href={account.get('url')}
             className='account__header__avatar'
@@ -56,7 +56,7 @@ class Avatar extends ImmutablePureComponent {
           >
             <span style={{ display: 'none' }}>{account.get('acct')}</span>
           </a>
-        }
+        )}
       </Motion>
     );
   }
@@ -103,7 +103,7 @@ export default class Header extends ImmutablePureComponent {
       }
     }
 
-    if (account.get('moved')) {
+    if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
       actionBtn = '';
     }
 
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index a40722417..ece219a3d 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -94,12 +94,12 @@ export default class AccountGallery extends ImmutablePureComponent {
             </div>
 
             <div className='account-gallery__container'>
-              {medias.map(media =>
+              {medias.map(media => (
                 <MediaItem
                   key={media.get('id')}
                   media={media}
                 />
-              )}
+              ))}
               {loadMore}
             </div>
           </div>
diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.js b/app/javascript/mastodon/features/account_timeline/components/moved_note.js
index 1c0e081cc..280389bba 100644
--- a/app/javascript/mastodon/features/account_timeline/components/moved_note.js
+++ b/app/javascript/mastodon/features/account_timeline/components/moved_note.js
@@ -34,7 +34,7 @@ export default class MovedNote extends ImmutablePureComponent {
       <div className='account__moved-note'>
         <div className='account__moved-note__message'>
           <div className='account__moved-note__icon-wrapper'><i className='fa fa-fw fa-suitcase account__moved-note__icon' /></div>
-          <FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <strong dangerouslySetInnerHTML={displayNameHtml} /> }} />
+          <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'>
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index c1e85aee3..e5de13178 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -72,7 +72,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
         {({ opacity, scaleX, scaleY }) => (
           <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
-            {items.map(item =>
+            {items.map(item => (
               <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
                 <div className='privacy-dropdown__option__icon'>
                   <i className={`fa fa-fw fa-${item.icon}`} />
@@ -83,7 +83,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
                   {item.meta}
                 </div>
               </div>
-            )}
+            ))}
           </div>
         )}
       </Motion>
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index 8350d20a5..d16f7fce7 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -40,11 +40,11 @@ export default class SearchResults extends ImmutablePureComponent {
       count += results.get('hashtags').size;
       hashtags = (
         <div className='search-results__section'>
-          {results.get('hashtags').map(hashtag =>
+          {results.get('hashtags').map(hashtag => (
             <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
               #{hashtag}
             </Link>
-          )}
+          ))}
         </div>
       );
     }
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index c3e936ab9..84e3a2338 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -94,15 +94,15 @@ export default class Compose extends React.PureComponent {
           <div className='drawer__inner' onFocus={this.onFocus}>
             <NavigationContainer onClose={this.onBlur} />
             <ComposeFormContainer />
-            <div className='mastodon' />
+            {multiColumn && <div className='mastodon' />}
           </div>
 
           <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
-            {({ x }) =>
+            {({ 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/mastodon/features/compose/util/url_regex.js b/app/javascript/mastodon/features/compose/util/url_regex.js
index e676d1879..d5e39e0d5 100644
--- a/app/javascript/mastodon/features/compose/util/url_regex.js
+++ b/app/javascript/mastodon/features/compose/util/url_regex.js
@@ -40,98 +40,98 @@ export const urlRegex = (function() {
   regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
   regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
   regexen.validGTLD = regexSupplant(RegExp(
-  '(?:(?:' +
-    '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
-    '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
-    'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
-    'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
-    'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
-    'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
-    'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
-    'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
-    'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
-    'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
-    'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
-    'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
-    'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
-    'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
-    'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
-    'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
-    'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
-    'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
-    'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
-    'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
-    'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
-    'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
-    'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
-    'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
-    'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
-    'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
-    'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
-    'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
-    'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
-    'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
-    'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
-    'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
-    'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
-    'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
-    'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
-    'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
-    'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
-    'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
-    'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
-    'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
-    'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
-    'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
-    'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
-    'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
-    'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
-    'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
-    'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
-    'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
-    'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
-    'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
-    'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
-    'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
-    'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
-    'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
-    'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
-    'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
-    'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
-    'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
-    'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
-    'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
-    'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
-    'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
-    'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
-    'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
-    'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
-    'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
-    'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
-    'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
-    'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
-    'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
-    'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
-    'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
-    'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
-    'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
-    'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
-    'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
-    'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
-    'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
-    'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
-    'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
-    'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
-    'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
-    'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
-    'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
-    'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
-    'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
-    'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
-    'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
-  ')(?=[^0-9a-zA-Z@]|$))'));
+    '(?:(?:' +
+      '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
+      '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
+      'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
+      'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
+      'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
+      'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
+      'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
+      'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
+      'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
+      'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
+      'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
+      'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
+      'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
+      'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
+      'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
+      'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
+      'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
+      'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
+      'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
+      'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
+      'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
+      'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
+      'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
+      'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
+      'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
+      'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
+      'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
+      'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
+      'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
+      'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
+      'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
+      'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
+      'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
+      'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
+      'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
+      'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
+      'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
+      'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
+      'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
+      'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
+      'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
+      'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
+      'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
+      'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
+      'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
+      'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
+      'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
+      'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
+      'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
+      'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
+      'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
+      'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
+      'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
+      'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
+      'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
+      'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
+      'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
+      'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
+      'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
+      'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
+      'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
+      'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
+      'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
+      'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
+      'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
+      'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
+      'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
+      'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
+      'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
+      'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
+      'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
+      'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
+      'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
+      'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
+      'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
+      'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
+      'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
+      'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
+      'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
+      'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
+      'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
+      'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
+      'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
+      'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
+      'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
+      'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
+      'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
+      'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
+    ')(?=[^0-9a-zA-Z@]|$))'));
   regexen.validCCTLD = regexSupplant(RegExp(
-  '(?:(?:' +
+    '(?:(?:' +
       '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
       'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
       'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
@@ -143,7 +143,7 @@ export const urlRegex = (function() {
       'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
       'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
       'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
-  ')(?=[^0-9a-zA-Z@]|$))'));
+    ')(?=[^0-9a-zA-Z@]|$))'));
   regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
   regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
   regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
@@ -168,8 +168,8 @@ export const urlRegex = (function() {
           '#{validGeneralUrlPathChars}*'    +
         ')'                                 +
       ')'                                   +
-    '\\)'
-  , 'i');
+    '\\)',
+    'i');
   // Valid end-of-path chracters (so /foo. does not gobble the period).
   // 1. Allow =&# for empty URL parameters and other URL-join artifacts
   regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i);
@@ -190,7 +190,7 @@ export const urlRegex = (function() {
       '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional)
       '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path
       '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String
-    ')'
-  , 'gi');
+    ')',
+    'gi');
   return regexen.validUrl;
 }());
diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
index 372459c78..a49703bb1 100644
--- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
+++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
@@ -24,10 +24,10 @@ describe('emoji', () => {
       expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
         '<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
       expect(emojify('👨‍👩‍👧‍👧')).toEqual(
-      '<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
+        '<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
       expect(emojify('👩‍👩‍👦')).toEqual('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
       expect(emojify('\u2757')).toEqual(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
+        '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
     });
 
     it('does multiple unicode', () => {
diff --git a/app/javascript/mastodon/features/list_editor/index.js b/app/javascript/mastodon/features/list_editor/index.js
index a3b60e447..65f7420de 100644
--- a/app/javascript/mastodon/features/list_editor/index.js
+++ b/app/javascript/mastodon/features/list_editor/index.js
@@ -66,11 +66,11 @@ export default class ListEditor extends ImmutablePureComponent {
           {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 }) =>
+            {({ x }) => (
               <div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
                 {searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
               </div>
-            }
+            )}
           </Motion>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 9d170cad5..6db62b330 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -133,7 +133,7 @@ export default class Notification extends ImmutablePureComponent {
     const { notification } = this.props;
     const account          = notification.get('account');
     const displayNameHtml  = { __html: account.get('display_name_html') };
-    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} />;
+    const link             = <bdi><Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
 
     switch(notification.get('type')) {
     case 'follow':
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
index af195ea9c..fdf9aab1a 100644
--- a/app/javascript/mastodon/features/ui/components/column_header.js
+++ b/app/javascript/mastodon/features/ui/components/column_header.js
@@ -25,7 +25,7 @@ export default class ColumnHeader extends React.PureComponent {
     }
 
     return (
-      <div role='heading' tabIndex='0' className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
+      <div role='button heading' tabIndex='0' className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
         {icon}
         {type}
       </div>
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index f00b74dfd..a01e5a390 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -37,6 +37,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
   static propTypes = {
     intl: PropTypes.object.isRequired,
     columns: ImmutablePropTypes.list.isRequired,
+    isModalOpen: PropTypes.bool.isRequired,
     singleColumn: PropTypes.bool,
     children: PropTypes.node,
   };
@@ -144,7 +145,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
   }
 
   render () {
-    const { columns, children, singleColumn } = this.props;
+    const { columns, children, singleColumn, isModalOpen } = this.props;
     const { shouldAnimate } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
@@ -159,7 +160,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
     }
 
     return (
-      <div className='columns-area' ref={this.setRef}>
+      <div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}>
         {columns.map(column => {
           const params = column.get('params', null) === null ? null : column.get('params').toJS();
 
diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js
index 1afffb51b..d440a8826 100644
--- a/app/javascript/mastodon/features/ui/components/embed_modal.js
+++ b/app/javascript/mastodon/features/ui/components/embed_modal.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { FormattedMessage, injectIntl } from 'react-intl';
-import axios from 'axios';
+import api from '../../../api';
 
 @injectIntl
 export default class EmbedModal extends ImmutablePureComponent {
@@ -23,7 +23,7 @@ export default class EmbedModal extends ImmutablePureComponent {
 
     this.setState({ loading: true });
 
-    axios.post('/api/web/embed', { url }).then(res => {
+    api().post('/api/web/embed', { url }).then(res => {
       this.setState({ loading: false, oembed: res.data });
 
       const iframeDocument = this.iframe.contentWindow.document;
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index ebbff6b5a..5839ba40a 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -113,13 +113,11 @@ export default class ModalRoot extends React.PureComponent {
         <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
           <div role='presentation' className='modal-root__overlay' onClick={onClose} />
           <div role='dialog' className='modal-root__container'>
-            {
-              visible ?
-                (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-                  {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
-                </BundleContainer>) :
-              null
-            }
+            {visible && (
+              <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+                {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+              </BundleContainer>
+            )}
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
index 8b9a26270..6c423b2c1 100644
--- a/app/javascript/mastodon/features/ui/components/upload_area.js
+++ b/app/javascript/mastodon/features/ui/components/upload_area.js
@@ -37,14 +37,14 @@ export default class UploadArea extends React.PureComponent {
 
     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 }) =>
+        {({ 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/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
index 95f95618b..f3e82a8ac 100644
--- a/app/javascript/mastodon/features/ui/containers/columns_area_container.js
+++ b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
@@ -3,6 +3,7 @@ import ColumnsArea from '../components/columns_area';
 
 const mapStateToProps = state => ({
   columns: state.getIn(['settings', 'columns']),
+  isModalOpen: !!state.get('modal').modalType,
 });
 
 export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index f1bb465d9..795b27707 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -67,7 +67,7 @@
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "هل تود حقا حذف هذه القائمة ؟",
   "confirmations.domain_block.confirm": "إخفاء إسم النطاق كاملا",
-  "confirmations.domain_block.message": "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.",
+  "confirmations.domain_block.message": "متأكد من أنك تود حظر إسم النطاق {domain} بالكامل ؟ في غالب الأحيان يُستَحسَن كتم أو حظر بعض الحسابات بدلا من حظر نطاق بالكامل.",
   "confirmations.mute.confirm": "أكتم",
   "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
   "confirmations.unfollow.confirm": "إلغاء المتابعة",
@@ -92,7 +92,7 @@
   "empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
   "empty_column.home": "إنك لا تتبع بعد أي شخص إلى حد الآن. زر {public} أو استخدام حقل البحث لكي تبدأ على التعرف على مستخدمين آخرين.",
   "empty_column.home.public_timeline": "الخيط العام",
-  "empty_column.list": "هذه القائمة فارغة.",
+  "empty_column.list": "هذه القائمة فارغة مؤقتا و لكن سوف تمتلئ تدريجيا عندما يبدأ الأعضاء المُنتَمين إليها بنشر تبويقات.",
   "empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
   "empty_column.public": "لا يوجد أي شيء هنا ! قم بنشر شيء ما للعامة، أو إتبع مستخدمين آخرين في الخوادم المثيلة الأخرى لملء خيط المحادثات العام",
   "follow_request.authorize": "ترخيص",
@@ -123,7 +123,7 @@
   "keyboard_shortcuts.reply": "للردّ",
   "keyboard_shortcuts.search": "للتركيز على البحث",
   "keyboard_shortcuts.toot": "لتحرير تبويق جديد",
-  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث",
   "keyboard_shortcuts.up": "للإنتقال إلى أعلى القائمة",
   "lightbox.close": "إغلاق",
   "lightbox.next": "التالي",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 3d2fe2839..3eb0e3d26 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -92,7 +92,7 @@
   "empty_column.hashtag": "Encara no hi ha res amb aquesta etiqueta.",
   "empty_column.home": "Encara no segueixes ningú. Visita {public} o fes cerca per començar i conèixer altres usuaris.",
   "empty_column.home.public_timeline": "la línia de temps pública",
-  "empty_column.list": "Encara no hi ha res en aquesta llista.",
+  "empty_column.list": "Encara no hi ha res en aquesta llista. Quan els membres d'aquesta llista publiquin nous estats, apareixeran aquí.",
   "empty_column.notifications": "Encara no tens notificacions. Interactua amb altres per iniciar la conversa.",
   "empty_column.public": "No hi ha res aquí! Escriu alguna cosa públicament o segueix manualment usuaris d'altres instàncies per omplir-ho",
   "follow_request.authorize": "Autoritzar",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 797054d57..acf051de5 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1324,6 +1324,19 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Favourite",
+        "id": "status.favourite"
+      },
+      {
+        "defaultMessage": "You can press {combo} to skip this next time",
+        "id": "favourite_modal.combo"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/favourite_modal.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Network error",
         "id": "bundle_column_error.title"
       },
@@ -1629,4 +1642,4 @@
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
   }
-]
\ No newline at end of file
+]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index d214fe85f..b1dbaa698 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -33,6 +33,7 @@
   "bundle_modal_error.retry": "Try again",
   "column.blocks": "Blocked users",
   "column.community": "Local timeline",
+  "column.direct": "Direct messages",
   "column.favourites": "Favourites",
   "column.follow_requests": "Follow requests",
   "column.home": "Home",
@@ -48,6 +49,9 @@
   "column_header.pin": "Pin",
   "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
+  "column.heading": "Misc",
+  "column.subheading": "Miscellaneous options",
+  "column_subheading.lists": "Lists",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
@@ -89,6 +93,7 @@
   "emoji_button.symbols": "Symbols",
   "emoji_button.travel": "Travel & Places",
   "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
@@ -142,12 +147,14 @@
   "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.direct": "Direct messages",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.info": "About this instance",
   "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.lists": "Lists",
+  "navigation_bar.misc": "Misc",
   "navigation_bar.logout": "Logout",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.pins": "Pinned toots",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index f6c6f5ced..8c52ffdb4 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -7,22 +7,22 @@
   "account.followers": "پیگیران",
   "account.follows": "پی می‌گیرد",
   "account.follows_you": "پیگیر شماست",
-  "account.hide_reblogs": "Hide boosts from @{name}",
+  "account.hide_reblogs": "پنهان کردن بازبوق‌های @{name}",
   "account.media": "رسانه",
   "account.mention": "نام‌بردن از @{name}",
-  "account.moved_to": "{name} has moved to:",
+  "account.moved_to": "{name} منتقل شده است به:",
   "account.mute": "بی‌صدا کردن @{name}",
-  "account.mute_notifications": "Mute notifications from @{name}",
+  "account.mute_notifications": "بی‌صداکردن اعلان‌ها از طرف @{name}",
   "account.posts": "نوشته‌ها",
   "account.report": "گزارش @{name}",
   "account.requested": "در انتظار پذیرش",
   "account.share": "هم‌رسانی نمایهٔ @{name}",
-  "account.show_reblogs": "Show boosts from @{name}",
+  "account.show_reblogs": "نشان‌دادن بازبوق‌های  @{name}",
   "account.unblock": "رفع انسداد @{name}",
   "account.unblock_domain": "رفع پنهان‌سازی از {domain}",
   "account.unfollow": "پایان پیگیری",
   "account.unmute": "باصدا کردن @{name}",
-  "account.unmute_notifications": "Unmute notifications from @{name}",
+  "account.unmute_notifications": "باصداکردن اعلان‌ها از طرف @{name}",
   "account.view_full_profile": "نمایش نمایهٔ کامل",
   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
   "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.",
@@ -36,7 +36,7 @@
   "column.favourites": "پسندیده‌ها",
   "column.follow_requests": "درخواست‌های پیگیری",
   "column.home": "خانه",
-  "column.lists": "Lists",
+  "column.lists": "فهرست‌ها",
   "column.mutes": "کاربران بی‌صداشده",
   "column.notifications": "اعلان‌ها",
   "column.pins": "نوشته‌های ثابت",
@@ -65,7 +65,7 @@
   "confirmations.delete.confirm": "پاک کن",
   "confirmations.delete.message": "آیا واقعاً می‌خواهید این نوشته را پاک کنید؟",
   "confirmations.delete_list.confirm": "Delete",
-  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.delete_list.message": "آیا واقعاً می‌خواهید این فهرست را برای همیشه پاک کنید؟",
   "confirmations.domain_block.confirm": "پنهان‌سازی کل دامین",
   "confirmations.domain_block.message": "آیا جدی جدی می‌خواهید کل دامین {domain} را مسدود کنید؟ بیشتر وقت‌ها مسدودکردن یا بی‌صداکردن چند حساب کاربری خاص کافی است و توصیه می‌شود.",
   "confirmations.mute.confirm": "بی‌صدا کن",
@@ -92,7 +92,7 @@
   "empty_column.hashtag": "هنوز هیچ چیزی با این هشتگ نیست.",
   "empty_column.home": "شما هنوز پیگیر کسی نیستید. {public} را ببینید یا چیزی را جستجو کنید تا کاربران دیگر را ببینید.",
   "empty_column.home.public_timeline": "فهرست نوشته‌های همه‌جا",
-  "empty_column.list": "There is nothing in this list yet.",
+  "empty_column.list": "در این فهرست هنوز چیزی نیست. وقتی اعضای این فهرست چیزی بنویسند، این‌جا ظاهر خواهد شد.",
   "empty_column.notifications": "هنوز هیچ اعلانی ندارید. به نوشته‌های دیگران واکنش نشان دهید تا گفتگو آغاز شود.",
   "empty_column.public": "این‌جا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران دیگر را پی بگیرید تا این‌جا پر شود",
   "follow_request.authorize": "اجازه دهید",
@@ -108,46 +108,46 @@
   "home.column_settings.show_reblogs": "نمایش بازبوق‌ها",
   "home.column_settings.show_replies": "نمایش پاسخ‌ها",
   "home.settings": "تنظیمات ستون",
-  "keyboard_shortcuts.back": "to navigate back",
-  "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
-  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.back": "برای بازگشت",
+  "keyboard_shortcuts.boost": "برای بازبوقیدن",
+  "keyboard_shortcuts.column": "برای برجسته‌کردن یک نوشته در یکی از ستون‌ها",
+  "keyboard_shortcuts.compose": "برای فعال‌کردن کادر نوشتهٔ تازه",
   "keyboard_shortcuts.description": "Description",
-  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.down": "برای پایین‌رفتن در فهرست",
   "keyboard_shortcuts.enter": "to open status",
-  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.favourite": "برای پسندیدن",
   "keyboard_shortcuts.heading": "Keyboard Shortcuts",
-  "keyboard_shortcuts.hotkey": "Hotkey",
-  "keyboard_shortcuts.legend": "to display this legend",
-  "keyboard_shortcuts.mention": "to mention author",
-  "keyboard_shortcuts.reply": "to reply",
-  "keyboard_shortcuts.search": "to focus search",
-  "keyboard_shortcuts.toot": "to start a brand new toot",
-  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
-  "keyboard_shortcuts.up": "to move up in the list",
+  "keyboard_shortcuts.hotkey": "میان‌بر",
+  "keyboard_shortcuts.legend": "برای نمایش این راهنما",
+  "keyboard_shortcuts.mention": "برای نام‌بردن از نویسنده",
+  "keyboard_shortcuts.reply": "برای پاسخ‌دادن",
+  "keyboard_shortcuts.search": "برای فعال‌کردن جستجو",
+  "keyboard_shortcuts.toot": "برای آغاز یک بوق تازه",
+  "keyboard_shortcuts.unfocus": "برای برداشتن توجه از نوشتن/جستجو",
+  "keyboard_shortcuts.up": "برای بالا رفتن در فهرست",
   "lightbox.close": "بستن",
   "lightbox.next": "بعدی",
   "lightbox.previous": "قبلی",
-  "lists.account.add": "Add to list",
-  "lists.account.remove": "Remove from list",
-  "lists.delete": "Delete list",
-  "lists.edit": "Edit list",
-  "lists.new.create": "Add list",
-  "lists.new.title_placeholder": "New list title",
-  "lists.search": "Search among people you follow",
-  "lists.subheading": "Your lists",
+  "lists.account.add": "افزودن به فهرست",
+  "lists.account.remove": "پاک‌کردن از فهرست",
+  "lists.delete": "حذف فهرست",
+  "lists.edit": "ویرایش فهرست",
+  "lists.new.create": "افزودن فهرست",
+  "lists.new.title_placeholder": "نام فهرست تازه",
+  "lists.search": "بین کسانی که پی می‌گیرید بگردید",
+  "lists.subheading": "فهرست‌های شما",
   "loading_indicator.label": "بارگیری...",
   "media_gallery.toggle_visible": "تغییر پیدایی",
   "missing_indicator.label": "پیدا نشد",
-  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "mute_modal.hide_notifications": "اعلان‌های این کاربر پنهان شود؟",
   "navigation_bar.blocks": "کاربران مسدودشده",
   "navigation_bar.community_timeline": "نوشته‌های محلی",
   "navigation_bar.edit_profile": "ویرایش نمایه",
   "navigation_bar.favourites": "پسندیده‌ها",
   "navigation_bar.follow_requests": "درخواست‌های پیگیری",
   "navigation_bar.info": "اطلاعات تکمیلی",
-  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
-  "navigation_bar.lists": "Lists",
+  "navigation_bar.keyboard_shortcuts": "میان‌برهای صفحه‌کلید",
+  "navigation_bar.lists": "فهرست‌ها",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "کاربران بی‌صداشده",
   "navigation_bar.pins": "نوشته‌های ثابت",
@@ -174,7 +174,7 @@
   "onboarding.page_four.home": "ستون «خانه» نوشته‌های کسانی را نشان می‌دهد که شما پی می‌گیرید.",
   "onboarding.page_four.notifications": "ستون «اعلان‌ها» ارتباط‌های شما با دیگران را نشان می‌دهد.",
   "onboarding.page_one.federation": "ماستدون شبکه‌ای از سرورهای مستقل است که با پیوستن به یکدیگر یک شبکهٔ اجتماعی بزرگ را تشکیل می‌دهند.",
-  "onboarding.page_one.handle": "شما روی سرور {domain} هستید، بنابراین شناسهٔ کامل شما {handle} است.",
+  "onboarding.page_one.handle": "شما روی سرور {domain} هستید، بنابراین شناسهٔ کامل شما {handle} است",
   "onboarding.page_one.welcome": "به ماستدون خوش آمدید!",
   "onboarding.page_six.admin": "نشانی مسئول سرور شما {admin} است.",
   "onboarding.page_six.almost_done": "الان تقریباً آماده‌اید...",
@@ -199,7 +199,7 @@
   "privacy.unlisted.short": "فهرست‌نشده",
   "relative_time.days": "{number}d",
   "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
+  "relative_time.just_now": "الان",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
   "reply_indicator.cancel": "لغو",
@@ -222,7 +222,7 @@
   "status.load_more": "بیشتر نشان بده",
   "status.media_hidden": "تصویر پنهان شده",
   "status.mention": "نام‌بردن از @{name}",
-  "status.more": "More",
+  "status.more": "بیشتر",
   "status.mute": "Mute @{name}",
   "status.mute_conversation": "بی‌صداکردن گفتگو",
   "status.open": "این نوشته را باز کن",
@@ -244,7 +244,7 @@
   "tabs_bar.home": "خانه",
   "tabs_bar.local_timeline": "محلی",
   "tabs_bar.notifications": "اعلان‌ها",
-  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
+  "ui.beforeunload": "اگر از ماستدون خارج شوید پیش‌نویس شما پاک خواهد شد.",
   "upload_area.title": "برای بارگذاری به این‌جا بکشید",
   "upload_button.label": "افزودن تصویر",
   "upload_form.description": "نوشتهٔ توضیحی برای کم‌بینایان و نابینایان",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 523dcc924..77f6b82ab 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -92,7 +92,7 @@
   "empty_column.hashtag": "Aínda non hai nada con esta etiqueta.",
   "empty_column.home": "A súa liña temporal de inicio está baldeira! Visite {public} ou utilice a busca para atopar outras usuarias.",
   "empty_column.home.public_timeline": "a liña temporal pública",
-  "empty_column.list": "Aínda non hai nada en esta lista.",
+  "empty_column.list": "Aínda non hai nada en esta lista. Cando as usuarias incluídas na lista publiquen mensaxes, aparecerán aquí.",
   "empty_column.notifications": "Aínda non ten notificacións. Interactúe con outras para iniciar unha conversa.",
   "empty_column.public": "Nada por aquí! Escriba algo de xeito público, ou siga manualmente usuarias de outras instancias para ir enchéndoa",
   "follow_request.authorize": "Autorizar",
@@ -109,7 +109,7 @@
   "home.column_settings.show_replies": "Mostrar respostas",
   "home.settings": "Axustes da columna",
   "keyboard_shortcuts.back": "voltar atrás",
-  "keyboard_shortcuts.boost": "repetir",
+  "keyboard_shortcuts.boost": "promover",
   "keyboard_shortcuts.column": "destacar un estado en unha das columnas",
   "keyboard_shortcuts.compose": "Foco no área de escritura",
   "keyboard_shortcuts.description": "Descrición",
@@ -227,8 +227,8 @@
   "status.mute_conversation": "Acalar conversa",
   "status.open": "Expandir este estado",
   "status.pin": "Fixar no perfil",
-  "status.reblog": "Promocionar",
-  "status.reblogged_by": "{name} promocionado",
+  "status.reblog": "Promover",
+  "status.reblogged_by": "{name} promoveu",
   "status.reply": "Resposta",
   "status.replyAll": "Resposta a conversa",
   "status.report": "Informar @{name}",
diff --git a/app/javascript/mastodon/locales/index.js b/app/javascript/mastodon/locales/index.js
index 421cb7fab..7e7297561 100644
--- a/app/javascript/mastodon/locales/index.js
+++ b/app/javascript/mastodon/locales/index.js
@@ -1,9 +1 @@
-let theLocale;
-
-export function setLocale(locale) {
-  theLocale = locale;
-}
-
-export function getLocale() {
-  return theLocale;
-}
+export * from 'locales';
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index e7f15691c..19885056c 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -33,6 +33,7 @@
   "bundle_modal_error.retry": "再試行",
   "column.blocks": "ブロックしたユーザー",
   "column.community": "ローカルタイムライン",
+  "column.direct": "ダイレクトメッセージ",
   "column.favourites": "お気に入り",
   "column.follow_requests": "フォローリクエスト",
   "column.home": "ホーム",
@@ -48,6 +49,9 @@
   "column_header.pin": "ピン留めする",
   "column_header.show_settings": "設定を表示",
   "column_header.unpin": "ピン留めを外す",
+  "column.heading": "その他",
+  "column.subheading": "その他のオプション",
+  "column_subheading.lists": "リスト",
   "column_subheading.navigation": "ナビゲーション",
   "column_subheading.settings": "設定",
   "compose_form.hashtag_warning": "このトゥートは未収載なのでハッシュタグの一覧に表示されません。公開トゥートだけがハッシュタグで検索できます。",
@@ -89,6 +93,7 @@
   "emoji_button.symbols": "記号",
   "emoji_button.travel": "旅行と場所",
   "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
+  "empty_column.direct": "あなたはまだダイレクトメッセージを受け取っていません。あなたが送ったり受け取ったりすると、ここに表示されます。",
   "empty_column.hashtag": "このハッシュタグはまだ使われていません。",
   "empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。",
   "empty_column.home.public_timeline": "連合タイムライン",
@@ -142,6 +147,7 @@
   "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?",
   "navigation_bar.blocks": "ブロックしたユーザー",
   "navigation_bar.community_timeline": "ローカルタイムライン",
+  "navigation_bar.direct": "ダイレクトメッセージ",
   "navigation_bar.edit_profile": "プロフィールを編集",
   "navigation_bar.favourites": "お気に入り",
   "navigation_bar.follow_requests": "フォローリクエスト",
@@ -153,6 +159,7 @@
   "navigation_bar.pins": "固定されたトゥート",
   "navigation_bar.preferences": "ユーザー設定",
   "navigation_bar.public_timeline": "連合タイムライン",
+  "navigation_bar.misc": "その他",
   "notification.favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました",
   "notification.follow": "{name}さんにフォローされました",
   "notification.mention": "{name}さんがあなたに返信しました",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 321e3ce47..31e5e377e 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -25,11 +25,11 @@
   "account.unmute_notifications": "@{name}의 알림 뮤트 해제",
   "account.view_full_profile": "전체 프로필 보기",
   "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
+  "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
   "bundle_column_error.retry": "다시 시도",
   "bundle_column_error.title": "네트워크 에러",
   "bundle_modal_error.close": "닫기",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
+  "bundle_modal_error.message": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
   "bundle_modal_error.retry": "다시 시도",
   "column.blocks": "차단 중인 사용자",
   "column.community": "로컬 타임라인",
@@ -50,7 +50,7 @@
   "column_header.unpin": "고정 해제",
   "column_subheading.navigation": "내비게이션",
   "column_subheading.settings": "설정",
-  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.hashtag_warning": "이 툿은 어떤 해시태그로도 검색 되지 않습니다. 전체공개로 게시 된 툿만이 해시태그로 검색 될 수 있습니다.",
   "compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
   "compose_form.lock_disclaimer.lock": "비공개",
   "compose_form.placeholder": "지금 무엇을 하고 있나요?",
@@ -135,7 +135,7 @@
   "lists.new.create": "리스트 추가",
   "lists.new.title_placeholder": "새 리스트의 이름",
   "lists.search": "팔로우 중인 사람들 중에서 찾기",
-  "lists.subheading": "Your lists",
+  "lists.subheading": "당신의 리스트",
   "loading_indicator.label": "불러오는 중...",
   "media_gallery.toggle_visible": "표시 전환",
   "missing_indicator.label": "찾을 수 없습니다",
@@ -178,7 +178,7 @@
   "onboarding.page_one.welcome": "Mastodon에 어서 오세요!",
   "onboarding.page_six.admin": "이 인스턴스의 관리자는 {admin}입니다.",
   "onboarding.page_six.almost_done": "이상입니다.",
-  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.appetoot": "본 아페툿!",
   "onboarding.page_six.apps_available": "iOS、Android 또는 다른 플랫폼에서 사용할 수 있는 {apps}이 있습니다.",
   "onboarding.page_six.github": "Mastodon는 오픈 소스 소프트웨어입니다. 버그 보고나 기능 추가 요청, 기여는 {github}에서 할 수 있습니다.",
   "onboarding.page_six.guidelines": "커뮤니티 가이드라인",
@@ -213,7 +213,7 @@
   "search_popout.tips.text": "단순한 텍스트 검색은 관계된 프로필 이름, 유저 이름 그리고 해시태그를 표시합니다",
   "search_popout.tips.user": "유저",
   "search_results.total": "{count, number}건의 결과",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "지금 이런 이야기를 하고 있습니다…",
   "status.block": "@{name} 차단",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
   "status.delete": "삭제",
@@ -247,7 +247,7 @@
   "ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.",
   "upload_area.title": "드래그 & 드롭으로 업로드",
   "upload_button.label": "미디어 추가",
-  "upload_form.description": "Describe for the visually impaired",
+  "upload_form.description": "시각장애인을 위한 설명",
   "upload_form.undo": "재시도",
   "upload_progress.label": "업로드 중...",
   "video.close": "동영상 닫기",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index f85cc75c5..6dc7292f1 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -100,10 +100,10 @@
   "getting_started.appsshort": "Apps",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Beginnen",
-  "getting_started.open_source_notice": "Mastodon is open-sourcesoftware. Je kunt bijdragen of problemen melden op GitHub via {github}.",
+  "getting_started.open_source_notice": "Mastodon is vrije software. Je kunt bijdragen of problemen melden op GitHub via {github}.",
   "getting_started.userguide": "Gebruikersgids",
   "home.column_settings.advanced": "Geavanceerd",
-  "home.column_settings.basic": "Basis",
+  "home.column_settings.basic": "Algemeen",
   "home.column_settings.filter_regex": "Wegfilteren met reguliere expressies",
   "home.column_settings.show_reblogs": "Boosts tonen",
   "home.column_settings.show_replies": "Reacties tonen",
@@ -146,7 +146,7 @@
   "navigation_bar.favourites": "Favorieten",
   "navigation_bar.follow_requests": "Volgverzoeken",
   "navigation_bar.info": "Uitgebreide informatie",
-  "navigation_bar.keyboard_shortcuts": "Toetsenbord sneltoetsen",
+  "navigation_bar.keyboard_shortcuts": "Sneltoetsen",
   "navigation_bar.lists": "Lijsten",
   "navigation_bar.logout": "Afmelden",
   "navigation_bar.mutes": "Genegeerde gebruikers",
@@ -180,7 +180,7 @@
   "onboarding.page_six.almost_done": "Bijna klaar...",
   "onboarding.page_six.appetoot": "Veel succes!",
   "onboarding.page_six.apps_available": "Er zijn {apps} beschikbaar voor iOS, Android en andere platformen.",
-  "onboarding.page_six.github": "Mastodon kost niets, en is open-source- en vrije software. Je kan bugs melden, nieuwe mogelijkheden aanvragen en als ontwikkelaar meewerken op {github}.",
+  "onboarding.page_six.github": "Mastodon kost niets en is vrije software. Je kan bugs melden, nieuwe mogelijkheden aanvragen en als ontwikkelaar meewerken op {github}.",
   "onboarding.page_six.guidelines": "communityrichtlijnen",
   "onboarding.page_six.read_guidelines": "Vergeet niet de {guidelines} van {domain} te lezen!",
   "onboarding.page_six.various_app": "mobiele apps",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 334178e03..d36b1e6ed 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -33,6 +33,7 @@
   "bundle_modal_error.retry": "Spróbuj ponownie",
   "column.blocks": "Zablokowani użytkownicy",
   "column.community": "Lokalna oś czasu",
+  "column.direct": "Wiadomości bezpośrednie",
   "column.favourites": "Ulubione",
   "column.follow_requests": "Prośby o śledzenie",
   "column.home": "Strona główna",
@@ -48,6 +49,9 @@
   "column_header.pin": "Przypnij",
   "column_header.show_settings": "Pokaż ustawienia",
   "column_header.unpin": "Cofnij przypięcie",
+  "column.heading": "Różne",
+  "column.subheading": "Różne opcje",
+  "column_subheading.lists": "Listy",
   "column_subheading.navigation": "Nawigacja",
   "column_subheading.settings": "Ustawienia",
   "compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
@@ -89,10 +93,11 @@
   "emoji_button.symbols": "Symbole",
   "emoji_button.travel": "Podróże i miejsca",
   "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
+  "empty_column.direct": "Nie masz żadnych wiadomości bezpośrednich. Jeżeli wyślesz lub otrzymasz jakąś, będzie tu widoczna.",
   "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
   "empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
   "empty_column.home.public_timeline": "publiczna oś czasu",
-  "empty_column.list": "Nie ma nic na tej liście.",
+  "empty_column.list": "Nie ma nic na tej liście. Kiedy członkowie listy dodadzą nowe wpisy, pojawia się one tutaj.",
   "empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.",
   "empty_column.public": "Tu nic nie ma! Napisz coś publicznie, lub dodaj ludzi z innych instancji, aby to wyświetlić.",
   "follow_request.authorize": "Autoryzuj",
@@ -142,6 +147,7 @@
   "mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
   "navigation_bar.blocks": "Zablokowani użytkownicy",
   "navigation_bar.community_timeline": "Lokalna oś czasu",
+  "navigation_bar.direct": "Wiadomości bezpośrednie",
   "navigation_bar.edit_profile": "Edytuj profil",
   "navigation_bar.favourites": "Ulubione",
   "navigation_bar.follow_requests": "Prośby o śledzenie",
@@ -149,6 +155,7 @@
   "navigation_bar.keyboard_shortcuts": "Skróty klawiszowe",
   "navigation_bar.lists": "Listy",
   "navigation_bar.logout": "Wyloguj",
+  "navigation_bar.misc": "Różne",
   "navigation_bar.mutes": "Wyciszeni użytkownicy",
   "navigation_bar.pins": "Przypięte wpisy",
   "navigation_bar.preferences": "Preferencje",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index bc6ae928d..947c6fb2b 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -92,7 +92,7 @@
   "empty_column.hashtag": "Ainda não há qualquer conteúdo com essa hashtag.",
   "empty_column.home": "Você ainda não segue usuário algo. Visite a timeline {public} ou use o buscador para procurar e conhecer outros usuários.",
   "empty_column.home.public_timeline": "global",
-  "empty_column.list": "Ainda não há nada nesta lista.",
+  "empty_column.list": "Ainda não há nada nesta lista. Quando membros dessa lista fizerem novas postagens, elas aparecerão aqui.",
   "empty_column.notifications": "Você ainda não possui notificações. Interaja com outros usuários para começar a conversar.",
   "empty_column.public": "Não há nada aqui! Escreva algo publicamente ou siga manualmente usuários de outras instâncias",
   "follow_request.authorize": "Autorizar",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index f9db2ad08..f566f551b 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -35,11 +35,11 @@
   "column.community": "Local",
   "column.favourites": "Favoritos",
   "column.follow_requests": "Seguidores Pendentes",
-  "column.home": "Home",
+  "column.home": "Início",
   "column.lists": "Listas",
   "column.mutes": "Utilizadores silenciados",
   "column.notifications": "Notificações",
-  "column.pins": "Pinned toot",
+  "column.pins": "Posts fixos",
   "column.public": "Global",
   "column_back_button.label": "Voltar",
   "column_header.hide_settings": "Esconder preferências",
@@ -47,7 +47,7 @@
   "column_header.moveRight_settings": "Mover coluna para a direita",
   "column_header.pin": "Fixar",
   "column_header.show_settings": "Mostrar preferências",
-  "column_header.unpin": "Remover fixar",
+  "column_header.unpin": "Desafixar",
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Preferências",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
@@ -92,7 +92,7 @@
   "empty_column.hashtag": "Não foram encontradas publicações com essa hashtag.",
   "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.",
   "empty_column.home.public_timeline": "global",
-  "empty_column.list": "Ainda não existem publicações nesta lista.",
+  "empty_column.list": "Ainda não existem publicações nesta lista. Quando membros desta lista fizerem novas publicações, elas aparecerão aqui.",
   "empty_column.notifications": "Não tens notificações. Interage com outros utilizadores para iniciar uma conversa.",
   "empty_column.public": "Não há nada aqui! Escreve algo publicamente ou segue outros utilizadores para ver aqui os conteúdos públicos",
   "follow_request.authorize": "Autorizar",
@@ -226,7 +226,7 @@
   "status.mute": "Mute @{name}",
   "status.mute_conversation": "Silenciar conversa",
   "status.open": "Expandir",
-  "status.pin": "Pin on profile",
+  "status.pin": "Fixar no perfil",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
   "status.reply": "Responder",
@@ -234,7 +234,7 @@
   "status.report": "Denunciar @{name}",
   "status.sensitive_toggle": "Clique para ver",
   "status.sensitive_warning": "Conteúdo sensível",
-  "status.share": "Share",
+  "status.share": "Compartilhar",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Deixar de silenciar esta conversa",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index ce16dcd8e..b6435a260 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -50,7 +50,7 @@
   "column_header.unpin": "取消固定",
   "column_subheading.navigation": "导航",
   "column_subheading.settings": "设置",
-  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.hashtag_warning": "这条嘟文被设置为“不公开”,因此它不会出现在任何话题标签的列表下。只有公开的嘟文才能通过话题标签进行搜索。",
   "compose_form.lock_disclaimer": "你的帐户没有{locked}。任何人都可以在关注你后立即查看仅关注者可见的嘟文。",
   "compose_form.lock_disclaimer.lock": "开启保护",
   "compose_form.placeholder": "在想啥?",
@@ -170,10 +170,10 @@
   "notifications.column_settings.sound": "播放音效",
   "onboarding.done": "出发!",
   "onboarding.next": "下一步",
-  "onboarding.page_five.public_timelines": "本站时间轴显示的是由本站({domain})用户发布的所有公开嘟文。跨站公共时间轴显示的的是由本站用户关注对象所发布的所有公开嘟文。这些就是寻人好去处的公共时间轴啦。",
-  "onboarding.page_four.home": "你的主页时间轴上显示的是你的关注对象所发布的嘟文。",
-  "onboarding.page_four.notifications": "如果有人与你互动了,他们就会出现在通知栏中哦~",
-  "onboarding.page_one.federation": "Mastodon 是由一系列独立的服务器共同打造的强大的社交网络,我们将这些各自独立而又相互连接的服务器叫做实例。",
+  "onboarding.page_five.public_timelines": "“本站时间轴”显示的是由本站({domain})用户发布的所有公开嘟文。“跨站公共时间轴”显示的的是由本站用户关注对象所发布的所有公开嘟文。这些就是寻人好去处的公共时间轴啦。",
+  "onboarding.page_four.home": "你的“主页”时间轴上显示的是你的关注对象所发布的嘟文。",
+  "onboarding.page_four.notifications": "如果有人与你互动了,他们就会出现在“通知”栏中哦~",
+  "onboarding.page_one.federation": "Mastodon 是由一系列独立的服务器共同打造的强大的社交网络,我们将这些各自独立而又相互连接的服务器叫做“实例”。",
   "onboarding.page_one.handle": "你是在 {domain} 上注册的,所以你的完整用户地址是 {handle}。",
   "onboarding.page_one.welcome": "欢迎来到 Mastodon!",
   "onboarding.page_six.admin": "{admin} 是你所在服务器实例的管理员。",
@@ -184,8 +184,8 @@
   "onboarding.page_six.guidelines": "社区指南",
   "onboarding.page_six.read_guidelines": "别忘了看看 {domain} 的{guidelines}!",
   "onboarding.page_six.various_app": "移动设备应用",
-  "onboarding.page_three.profile": "你可以修改你的个人资料,比如头像、简介和昵称等偏好设置。",
-  "onboarding.page_three.search": "你可以通过搜索功能寻找用户和话题标签,比如{illustration}或者{introductions}。如果你想搜索其他实例上的用户,就需要输入完整用户地址(@用户名@域名)哦。",
+  "onboarding.page_three.profile": "你还可以修改你的个人资料,比如头像、简介和昵称等偏好设置。",
+  "onboarding.page_three.search": "你可以通过搜索功能寻找用户和话题标签,比如“{illustration}”,或是“{introductions}”。如果你想搜索其他实例上的用户,就需要输入完整用户地址(@用户名@域名)哦。",
   "onboarding.page_two.compose": "在撰写栏中开始嘟嘟吧!下方的按钮分别可以用来上传图片、修改嘟文可见范围,以及添加警告信息。",
   "onboarding.skip": "跳过",
   "privacy.change": "设置嘟文可见范围",
@@ -214,7 +214,7 @@
   "search_popout.tips.user": "用户",
   "search_results.total": "共 {count, number} 个结果",
   "standalone.public_title": "大家都在干啥?",
-  "status.block": "Block @{name}",
+  "status.block": "屏蔽 @{name}",
   "status.cannot_reblog": "无法转嘟这条嘟文",
   "status.delete": "删除",
   "status.embed": "嵌入",
@@ -223,7 +223,7 @@
   "status.media_hidden": "隐藏媒体内容",
   "status.mention": "提及 @{name}",
   "status.more": "更多",
-  "status.mute": "Mute @{name}",
+  "status.mute": "隐藏 @{name}",
   "status.mute_conversation": "隐藏此对话",
   "status.open": "展开嘟文",
   "status.pin": "在个人资料页面置顶",
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
index 50c81198e..63e12da42 100644
--- a/app/javascript/packs/about.js
+++ b/app/javascript/packs/about.js
@@ -1,7 +1,5 @@
 import loadPolyfills from '../mastodon/load_polyfills';
 
-require.context('../images/', true);
-
 function loaded() {
   const TimelineContainer = require('../mastodon/containers/timeline_container').default;
   const React             = require('react');
diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js
index 96e6f4b16..5d42509c5 100644
--- a/app/javascript/packs/common.js
+++ b/app/javascript/packs/common.js
@@ -1,6 +1 @@
-import { start } from 'rails-ujs';
-import 'font-awesome/css/font-awesome.css';
-
-require.context('../images/', true);
-
-start();
+import 'styles/application.scss';
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index a47fc2830..3472af6c1 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -1,26 +1,8 @@
 import loadPolyfills from '../mastodon/load_polyfills';
 import ready from '../mastodon/ready';
 
-window.addEventListener('message', e => {
-  const data = e.data || {};
-
-  if (!window.parent || data.type !== 'setHeight') {
-    return;
-  }
-
-  ready(() => {
-    window.parent.postMessage({
-      type: 'setHeight',
-      id: data.id,
-      height: document.getElementsByTagName('html')[0].scrollHeight,
-    }, '*');
-  });
-});
-
 function main() {
-  const { length } = require('stringz');
   const IntlRelativeFormat = require('intl-relativeformat').default;
-  const { delegate } = require('rails-ujs');
   const emojify = require('../mastodon/features/emoji/emoji').default;
   const { getLocale } = require('../mastodon/locales');
   const { localeData } = getLocale();
@@ -86,60 +68,6 @@ function main() {
       ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
     });
   });
-
-  delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
-    if (button !== 0) {
-      return true;
-    }
-    window.location.href = target.href;
-    return false;
-  });
-
-  delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
-    const contentEl = target.parentNode.parentNode.querySelector('.e-content');
-
-    if (contentEl.style.display === 'block') {
-      contentEl.style.display = 'none';
-      target.parentNode.style.marginBottom = 0;
-    } else {
-      contentEl.style.display = 'block';
-      target.parentNode.style.marginBottom = null;
-    }
-
-    return false;
-  });
-
-  delegate(document, '.account_display_name', 'input', ({ target }) => {
-    const nameCounter = document.querySelector('.name-counter');
-
-    if (nameCounter) {
-      nameCounter.textContent = 30 - length(target.value);
-    }
-  });
-
-  delegate(document, '.account_note', 'input', ({ target }) => {
-    const noteCounter = document.querySelector('.note-counter');
-
-    if (noteCounter) {
-      noteCounter.textContent = 160 - length(target.value);
-    }
-  });
-
-  delegate(document, '#account_avatar', 'change', ({ target }) => {
-    const avatar = document.querySelector('.card.compact .avatar img');
-    const [file] = target.files || [];
-    const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
-
-    avatar.src = url;
-  });
-
-  delegate(document, '#account_header', 'change', ({ target }) => {
-    const header = document.querySelector('.card.compact');
-    const [file] = target.files || [];
-    const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
-
-    header.style.backgroundImage = `url(${url})`;
-  });
 }
 
 loadPolyfills().then(main).catch(error => {
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
index 51e4ae38b..e9580f648 100644
--- a/app/javascript/packs/share.js
+++ b/app/javascript/packs/share.js
@@ -1,7 +1,5 @@
 import loadPolyfills from '../mastodon/load_polyfills';
 
-require.context('../images/', true);
-
 function loaded() {
   const ComposeContainer = require('../mastodon/containers/compose_container').default;
   const React = require('react');
diff --git a/app/javascript/skins/vanilla/win95/common.scss b/app/javascript/skins/vanilla/win95/common.scss
new file mode 100644
index 000000000..298f6ee9d
--- /dev/null
+++ b/app/javascript/skins/vanilla/win95/common.scss
@@ -0,0 +1 @@
+@import 'styles/win95';
diff --git a/app/javascript/skins/vanilla/win95/names.yml b/app/javascript/skins/vanilla/win95/names.yml
new file mode 100644
index 000000000..2083084a2
--- /dev/null
+++ b/app/javascript/skins/vanilla/win95/names.yml
@@ -0,0 +1,4 @@
+en:
+  skins:
+    vanilla:
+      win95: Masto95
diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss
index 206f1865e..3d2b5a93f 100644
--- a/app/javascript/styles/fonts/montserrat.scss
+++ b/app/javascript/styles/fonts/montserrat.scss
@@ -1,9 +1,9 @@
 @font-face {
   font-family: 'mastodon-font-display';
   src: local('Montserrat'),
-    url('../fonts/montserrat/Montserrat-Regular.woff2') format('woff2'),
-    url('../fonts/montserrat/Montserrat-Regular.woff') format('woff'),
-    url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
+    url('~fonts/montserrat/Montserrat-Regular.woff2') format('woff2'),
+    url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'),
+    url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
   font-weight: 400;
   font-style: normal;
 }
@@ -11,7 +11,7 @@
 @font-face {
   font-family: 'mastodon-font-display';
   src: local('Montserrat'),
-    url('../fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
+    url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
   font-weight: 500;
   font-style: normal;
 }
diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss
index 2a1f74e16..c793aa6ed 100644
--- a/app/javascript/styles/fonts/roboto-mono.scss
+++ b/app/javascript/styles/fonts/roboto-mono.scss
@@ -1,10 +1,10 @@
 @font-face {
   font-family: 'mastodon-font-monospace';
   src: local('Roboto Mono'),
-    url('../fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'),
-    url('../fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'),
-    url('../fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
-    url('../fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
+    url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'),
+    url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'),
+    url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
+    url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
   font-weight: 400;
   font-style: normal;
 }
diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss
index 345d9ad50..79034c018 100644
--- a/app/javascript/styles/fonts/roboto.scss
+++ b/app/javascript/styles/fonts/roboto.scss
@@ -1,10 +1,10 @@
 @font-face {
   font-family: 'mastodon-font-sans-serif';
   src: local('Roboto'),
-    url('../fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
-    url('../fonts/roboto/roboto-italic-webfont.woff') format('woff'),
-    url('../fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
-    url('../fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
+    url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
+    url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'),
+    url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
+    url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
   font-weight: normal;
   font-style: italic;
 }
@@ -12,10 +12,10 @@
 @font-face {
   font-family: 'mastodon-font-sans-serif';
   src: local('Roboto'),
-    url('../fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
-    url('../fonts/roboto/roboto-bold-webfont.woff') format('woff'),
-    url('../fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
-    url('../fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
+    url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
+    url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'),
+    url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
+    url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
   font-weight: bold;
   font-style: normal;
 }
@@ -23,10 +23,10 @@
 @font-face {
   font-family: 'mastodon-font-sans-serif';
   src: local('Roboto'),
-    url('../fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
-    url('../fonts/roboto/roboto-medium-webfont.woff') format('woff'),
-    url('../fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
-    url('../fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
+    url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
+    url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'),
+    url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
+    url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
   font-weight: 500;
   font-style: normal;
 }
@@ -34,10 +34,10 @@
 @font-face {
   font-family: 'mastodon-font-sans-serif';
   src: local('Roboto'),
-    url('../fonts/roboto/roboto-regular-webfont.woff2') format('woff2'),
-    url('../fonts/roboto/roboto-regular-webfont.woff') format('woff'),
-    url('../fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
-    url('../fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
+    url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'),
+    url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'),
+    url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
+    url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
   font-weight: normal;
   font-style: normal;
 }
diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
new file mode 100644
index 000000000..e6422b2ea
--- /dev/null
+++ b/app/javascript/styles/mailer.scss
@@ -0,0 +1,470 @@
+@import 'mastodon/variables';
+@import 'fonts/roboto';
+
+table,
+td,
+div {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  width: 100% !important;
+  min-width: 100%;
+  margin: 0;
+  padding: 0;
+  -webkit-text-size-adjust: 100%;
+  -ms-text-size-adjust: 100%;
+}
+
+.email_body {
+  td,
+  div,
+  a,
+  span {
+    line-height: inherit;
+  }
+}
+
+a {
+  &,
+  &:visited,
+  span {
+    text-decoration: none;
+    color: $ui-highlight-color;
+  }
+
+  #outlook & {
+    padding: 0;
+  }
+}
+
+img {
+  outline: none;
+  border: 0;
+  text-decoration: none;
+  -ms-interpolation-mode: bicubic;
+  clear: both;
+  line-height: 100%;
+}
+
+table {
+  border-spacing: 0;
+  mso-table-lspace: 0;
+  mso-table-rspace: 0;
+}
+
+td {
+  vertical-align: top;
+}
+
+.email-table,
+.content-section,
+.column,
+.column-cell {
+  width: 100%;
+  min-width: 100%;
+}
+
+.email-body {
+  font-size: 0 !important;
+  line-height: 100%;
+  text-align: center;
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+.email-start {
+  padding-top: 32px;
+}
+
+.email-end {
+  padding-bottom: 32px;
+}
+
+.email-body,
+html,
+body {
+  background-color: lighten($ui-base-color, 4%);
+}
+
+.email-container,
+.email-row,
+.col-0,
+.col-1,
+.col-2,
+.col-3,
+.col-4,
+.col-5,
+.col-6, {
+  font-size: 0;
+  display: inline-block;
+  width: 100%;
+  min-width: 100%;
+  min-width: 0 !important;
+  vertical-align: top;
+}
+
+.content-cell {
+  width: 100%;
+  min-width: 100%;
+  min-width: 0 !important;
+}
+
+.column-cell {
+  padding-top: 16px;
+  padding-bottom: 16px;
+  vertical-align: top;
+
+  &.button-cell {
+    padding-top: 0;
+  }
+}
+
+.email-container {
+  max-width: 632px;
+  margin: 0 auto;
+  text-align: center;
+}
+
+.email-row {
+  display: block;
+  max-width: 600px !important;
+  margin: 0 auto;
+  text-align: center;
+  clear: both;
+}
+
+.col-0 {
+  max-width: 50px;
+}
+
+.col-1 {
+  max-width: 100px;
+}
+
+.col-2 {
+  max-width: 200px;
+}
+
+.col-3 {
+  max-width: 300px;
+}
+
+.col-4 {
+  max-width: 400px;
+}
+
+.col-5 {
+  max-width: 500px;
+}
+
+.col-6 {
+  max-width: 600px;
+}
+
+.column-cell,
+.column-cell td,
+p {
+  font-family: Helvetica, Arial, sans-serif;
+
+  @media only screen {
+    font-family: 'mastodon-font-sans-serif', sans-serif !important;
+  }
+}
+
+.email-body .column-cell,
+.column-cell,
+p {
+  font-size: 15px;
+  line-height: 23px;
+  color: $ui-primary-color;
+  mso-line-height-rule: exactly;
+  text-rendering: optimizelegibility;
+}
+
+p {
+  display: block;
+  margin-top: 0;
+  margin-bottom: 16px;
+
+  &.small {
+    font-size: 13px;
+  }
+
+  &.lead {
+    font-size: 19px;
+    line-height: 27px;
+  }
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  color: $ui-secondary-color;
+  margin-left: 0;
+  margin-right: 0;
+  margin-top: 20px;
+  margin-bottom: 8px;
+  padding: 0;
+  font-weight: 500;
+}
+
+h1 {
+  font-size: 26px;
+  line-height: 36px;
+}
+
+h2 {
+  font-size: 23px;
+  line-height: 30px;
+}
+
+h3 {
+  font-size: 19px;
+  line-height: 25px;
+}
+
+.input {
+  td {
+    background: darken($ui-base-color, 8%);
+    border-radius: 4px;
+    padding: 16px;
+    line-height: 20px;
+    mso-line-height-rule: exactly;
+    border-radius: 4px;
+    text-align: center;
+    font-weight: 500;
+    font-size: 17px;
+  }
+}
+
+.content-cell,
+.blank-cell {
+  width: 100%;
+  font-size: 0;
+  text-align: center;
+  vertical-align: top;
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+.content-cell {
+  background-color: darken($ui-base-color, 4%);
+
+  &.darker {
+    background-color: darken($ui-base-color, 8%);
+  }
+}
+
+.hero {
+  background-color: $ui-base-color;
+  padding-top: 20px;
+}
+
+.hero-with-button {
+  h1 {
+    margin-bottom: 4px;
+  }
+
+  p.lead {
+    margin-bottom: 32px;
+  }
+
+  padding-bottom: 16px;
+}
+
+.header {
+  border-radius: 5px 5px 0 0;
+  background-color: darken($ui-base-color, 8%);
+
+  .column-cell {
+    text-align: center;
+    padding-top: 20px;
+    padding-bottom: 8px;
+  }
+}
+
+.content-start {
+  padding-top: 32px;
+}
+
+.content-end {
+  border-radius: 0 0 5px 5px;
+  padding-top: 16px;
+}
+
+.footer {
+  .column-cell,
+  p {
+    color: lighten($ui-base-color, 34%);
+  }
+
+  p {
+    margin-bottom: 0;
+    font-size: 13px;
+
+    &.small {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: lighten($ui-base-color, 34%);
+    text-decoration: underline;
+  }
+
+  img {
+    opacity: 0.3;
+  }
+}
+
+.logo {
+  position: relative;
+  left: -4px;
+}
+
+.button {
+  display: table;
+  margin-left: auto;
+  margin-right: auto;
+
+  td {
+    line-height: 20px;
+    mso-line-height-rule: exactly;
+    border-radius: 4px;
+    text-align: center;
+    font-weight: 500;
+    font-size: 17px;
+    padding: 0 !important;
+
+    a,
+    a span {
+      color: $primary-text-color;
+      display: block !important;
+      text-align: center !important;
+      vertical-align: top !important;
+      line-height: inherit !important;
+    }
+
+    a {
+      padding: 10px 22px !important;
+      line-height: 26px !important;
+      font-weight: 500 !important;
+    }
+  }
+}
+
+.button-default {
+  background-color: darken($ui-base-color, 8%);
+}
+
+.button-primary {
+  background-color: darken($ui-highlight-color, 3%);
+}
+
+.text-center {
+  text-align: center;
+}
+
+.text-right {
+  text-align: right;
+}
+
+.padded {
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+.hero-icon {
+  width: 64px;
+
+  td {
+    text-align: center;
+    vertical-align: middle;
+    line-height: 100%;
+    mso-line-height-rule: exactly;
+    padding: 16px;
+    border-radius: 80px;
+    background: $success-green;
+  }
+
+  img {
+    max-width: 32px;
+    width: 32px;
+    height: 32px;
+    display: block;
+    line-height: 100%;
+  }
+}
+
+.hr {
+  width: 100%;
+
+  td {
+    font-size: 0;
+    line-height: 1px;
+    mso-line-height-rule: exactly;
+    min-height: 1px;
+    overflow: hidden;
+    height: 2px;
+    background-color: transparent !important;
+    border-top: 1px solid lighten($ui-base-color, 8%);
+  }
+}
+
+.status {
+  padding-bottom: 32px;
+
+  .status-header {
+    td {
+      font-size: 14px;
+      padding-bottom: 15px;
+    }
+
+    bdi {
+      color: $white;
+      font-size: 16px;
+      display: block;
+      font-weight: 500;
+    }
+
+    td:first-child {
+      padding-right: 10px;
+    }
+
+    img {
+      width: 48px;
+      height: 48px;
+      border-radius: 4px;
+    }
+  }
+
+  p {
+    font-size: 19px;
+    margin-bottom: 20px;
+
+    &.status-footer {
+      color: lighten($ui-base-color, 26%);
+      font-size: 14px;
+      margin-bottom: 0;
+
+      a {
+        color: lighten($ui-base-color, 26%);
+      }
+    }
+  }
+}
+
+.border-top {
+  border-top: 1px solid lighten($ui-base-color, 8%);
+}
+
+@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) {
+  body {
+    min-height: 1024px !important;
+  }
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index dec489e9a..71bffdaa6 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1613,6 +1613,10 @@
   justify-content: flex-start;
   overflow-x: auto;
   position: relative;
+
+  &.unscrollable {
+    overflow-x: hidden;
+  }
 }
 
 @media screen and (min-width: 360px) {
@@ -1758,7 +1762,7 @@
   position: absolute;
   top: 0;
   left: 0;
-  background: lighten($ui-base-color, 13%) url('../images/wave-drawer.png') no-repeat bottom / 100% auto;
+  background: lighten($ui-base-color, 13%) url('~images/wave-drawer.png') no-repeat bottom / 100% auto;
   box-sizing: border-box;
   padding: 0;
   display: flex;
@@ -1773,7 +1777,7 @@
   }
 
   > .mastodon {
-    background: url('../images/mastodon-ui.png') no-repeat left bottom / contain;
+    background: url('~images/mastodon-ui.png') no-repeat left bottom / contain;
     flex: 1;
   }
 }
@@ -2312,7 +2316,7 @@
   justify-content: center;
 
   & > div {
-    background: url('../images/mastodon-not-found.png') no-repeat center -50px;
+    background: url('~images/mastodon-not-found.png') no-repeat center -50px;
     padding-top: 210px;
     width: 100%;
   }
@@ -2350,6 +2354,19 @@
   position: relative;
   z-index: 2;
   outline: 0;
+  overflow: hidden;
+
+  & > button {
+    display: flex;
+    flex: auto;
+    margin: 0;
+    border: none;
+    padding: 0;
+    color: inherit;
+    background: transparent;
+    font: inherit;
+    text-align: left;
+  }
 
   &.active {
     box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);
@@ -3204,7 +3221,7 @@
   img,
   canvas {
     display: block;
-    background: url('../images/void.png') repeat;
+    background: url('~images/void.png') repeat;
     object-fit: contain;
   }
 
@@ -3457,7 +3474,7 @@
 }
 
 .onboarding-modal__page-one__elephant-friend {
-  background: url('../images/elephant-friend-1.png') no-repeat center center / contain;
+  background: url('~images/elephant-friend-1.png') no-repeat center center / contain;
   width: 155px;
   height: 193px;
   margin-right: 15px;
diff --git a/app/javascript/styles/mastodon/modal.scss b/app/javascript/styles/mastodon/modal.scss
index 310dcb924..a17476ccb 100644
--- a/app/javascript/styles/mastodon/modal.scss
+++ b/app/javascript/styles/mastodon/modal.scss
@@ -1,5 +1,5 @@
 .modal-layout {
-  background: $ui-base-color url('../images/wave-modal.png') repeat-x bottom fixed;
+  background: $ui-base-color url('~images/wave-modal.png') repeat-x bottom fixed;
   display: flex;
   flex-direction: column;
   height: 100vh;
@@ -15,6 +15,6 @@
   > * {
     flex: 1;
     max-height: 235px;
-    background: url('../images/mastodon-ui.png') no-repeat left bottom / contain;
+    background: url('~images/mastodon-ui.png') no-repeat left bottom / contain;
   }
 }
diff --git a/app/javascript/styles/win95.scss b/app/javascript/styles/win95.scss
index d4bd43bca..f04a43c03 100644
--- a/app/javascript/styles/win95.scss
+++ b/app/javascript/styles/win95.scss
@@ -1,3 +1,8 @@
+//  win95 theme from cybrespace.
+
+//  Modified by kibi! to use webpack package syntax for urls (eg,
+//  `url(~images/…)`) for easy importing into skins.
+
 $win95-bg: #bfbfbf;
 $win95-dark-grey: #404040;
 $win95-mid-grey: #808080;
@@ -68,7 +73,7 @@ $ui-highlight-color: $win95-window-header;
 
 @font-face {
   font-family:"premillenium";
-  src: url('../fonts/premillenium/MSSansSerif.ttf') format('truetype');
+  src: url('~fonts/premillenium/MSSansSerif.ttf') format('truetype');
 }
 
 @import 'application';
@@ -174,7 +179,7 @@ body.admin {
   font-size:0px;
   color:$win95-bg;
 
-  background-image: url("../images/start.png"); 
+  background-image: url("~images/start.png"); 
   background-repeat:no-repeat;
   background-position:8%;
   background-clip:padding-box;
@@ -196,7 +201,7 @@ body.admin {
     left:15px;
     width:132px;
     height:117px;
-    background-image:url("../images/clippy_wave.gif"), url("../images/clippy_frame.png");
+    background-image:url("~images/clippy_wave.gif"), url("~images/clippy_frame.png");
     background-repeat:no-repeat;
     background-position: 4px 20px, 0px 0px;
     z-index:0;
@@ -749,7 +754,7 @@ body.admin {
   font-size:0px;
   color:$win95-bg;
   
-  background-image: url("../images/start.png"); 
+  background-image: url("~images/start.png"); 
   background-repeat:no-repeat;
   background-position:8%;
   background-clip:padding-box;
@@ -1126,52 +1131,52 @@ body.admin {
 }
 
 .column-link[href="/web/timelines/public"] { 
-  background-image: url("../images/icon_public.png"); 
-  &:hover { background-image: url("../images/icon_public.png"); }
+  background-image: url("~images/icon_public.png"); 
+  &:hover { background-image: url("~images/icon_public.png"); }
 }
 .column-link[href="/web/timelines/public/local"] { 
-  background-image: url("../images/icon_local.png"); 
-  &:hover { background-image: url("../images/icon_local.png"); }
+  background-image: url("~images/icon_local.png"); 
+  &:hover { background-image: url("~images/icon_local.png"); }
 }
 .column-link[href="/web/pinned"] { 
-  background-image: url("../images/icon_pin.png"); 
-  &:hover { background-image: url("../images/icon_pin.png"); }
+  background-image: url("~images/icon_pin.png"); 
+  &:hover { background-image: url("~images/icon_pin.png"); }
 }
 .column-link[href="/web/favourites"] { 
-  background-image: url("../images/icon_likes.png"); 
-  &:hover { background-image: url("../images/icon_likes.png"); }
+  background-image: url("~images/icon_likes.png"); 
+  &:hover { background-image: url("~images/icon_likes.png"); }
 }
 .column-link[href="/web/lists"] { 
-  background-image: url("../images/icon_lists.png"); 
-  &:hover { background-image: url("../images/icon_lists.png"); }
+  background-image: url("~images/icon_lists.png"); 
+  &:hover { background-image: url("~images/icon_lists.png"); }
 }
 .column-link[href="/web/follow_requests"] { 
-  background-image: url("../images/icon_follow_requests.png"); 
-  &:hover { background-image: url("../images/icon_follow_requests.png"); }
+  background-image: url("~images/icon_follow_requests.png"); 
+  &:hover { background-image: url("~images/icon_follow_requests.png"); }
 }
 .column-link[href="/web/keyboard-shortcuts"] { 
-  background-image: url("../images/icon_keyboard_shortcuts.png"); 
-  &:hover { background-image: url("../images/icon_keyboard_shortcuts.png"); }
+  background-image: url("~images/icon_keyboard_shortcuts.png"); 
+  &:hover { background-image: url("~images/icon_keyboard_shortcuts.png"); }
 }
 .column-link[href="/web/blocks"] { 
-  background-image: url("../images/icon_blocks.png"); 
-  &:hover { background-image: url("../images/icon_blocks.png"); }
+  background-image: url("~images/icon_blocks.png"); 
+  &:hover { background-image: url("~images/icon_blocks.png"); }
 }
 .column-link[href="/web/mutes"] { 
-  background-image: url("../images/icon_mutes.png"); 
-  &:hover { background-image: url("../images/icon_mutes.png"); }
+  background-image: url("~images/icon_mutes.png"); 
+  &:hover { background-image: url("~images/icon_mutes.png"); }
 }
 .column-link[href="/settings/preferences"] { 
-  background-image: url("../images/icon_settings.png"); 
-  &:hover { background-image: url("../images/icon_settings.png"); }
+  background-image: url("~images/icon_settings.png"); 
+  &:hover { background-image: url("~images/icon_settings.png"); }
 }
 .column-link[href="/about/more"] { 
-  background-image: url("../images/icon_about.png"); 
-  &:hover { background-image: url("../images/icon_about.png"); }
+  background-image: url("~images/icon_about.png"); 
+  &:hover { background-image: url("~images/icon_about.png"); }
 }
 .column-link[href="/auth/sign_out"] { 
-  background-image: url("../images/icon_logout.png"); 
-  &:hover { background-image: url("../images/icon_logout.png"); }
+  background-image: url("~images/icon_logout.png"); 
+  &:hover { background-image: url("~images/icon_logout.png"); }
 }
 
 .getting-started__footer {
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
index d0082483c..bd90c9019 100644
--- a/app/lib/activitypub/activity/accept.rb
+++ b/app/lib/activitypub/activity/accept.rb
@@ -2,18 +2,16 @@
 
 class ActivityPub::Activity::Accept < ActivityPub::Activity
   def perform
-    if @object.respond_to?(:[]) &&
-       @object['type'] == 'Follow' && @object['actor'].present?
-      accept_follow_from @object['actor']
-    else
-      accept_follow_object @object
+    case @object['type']
+    when 'Follow'
+      accept_follow
     end
   end
 
   private
 
-  def accept_follow_from(actor)
-    target_account = account_from_uri(value_or_id(actor))
+  def accept_follow
+    target_account = account_from_uri(target_uri)
 
     return if target_account.nil? || !target_account.local?
 
@@ -21,8 +19,7 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
     follow_request&.authorize!
   end
 
-  def accept_follow_object(object)
-    follow_request = ActivityPub::TagManager.instance.uri_to_resource(value_or_id(object), FollowRequest)
-    follow_request&.authorize!
+  def target_uri
+    @target_uri ||= value_or_id(@object['actor'])
   end
 end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index b84098933..abf2b9b80 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -5,7 +5,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
     original_status   = status_from_uri(object_uri)
     original_status ||= fetch_remote_original_status
 
-    return if original_status.nil? || delete_arrived_first?(@json['id'])
+    return if original_status.nil? || delete_arrived_first?(@json['id']) || !announceable?(original_status)
 
     status = Status.find_by(account: @account, reblog: original_status)
 
@@ -33,4 +33,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
       ::FetchRemoteStatusService.new.call(@object['url'])
     end
   end
+
+  def announceable?(status)
+    status.public_visibility? || status.unlisted_visibility?
+  end
 end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 3a985c19b..64c429420 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -1,11 +1,11 @@
 # frozen_string_literal: true
 
 class ActivityPub::Activity::Create < ActivityPub::Activity
-  SUPPORTED_TYPES = %w(Article Note).freeze
-  CONVERTED_TYPES = %w(Image Video).freeze
+  SUPPORTED_TYPES = %w(Note).freeze
+  CONVERTED_TYPES = %w(Image Video Article).freeze
 
   def perform
-    return if delete_arrived_first?(object_uri) || unsupported_object_type?
+    return if delete_arrived_first?(object_uri) || unsupported_object_type? || invalid_origin?(@object['id'])
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
@@ -213,7 +213,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def object_url
     return if @object['url'].blank?
-    url_to_href(@object['url'], 'text/html')
+
+    url_candidate = url_to_href(@object['url'], 'text/html')
+
+    if invalid_origin?(url_candidate)
+      nil
+    else
+      url_candidate
+    end
   end
 
   def content_language_map?
@@ -245,6 +252,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
   end
 
+  def invalid_origin?(url)
+    return true if unsupported_uri_scheme?(url)
+
+    needle   = Addressable::URI.parse(url).host
+    haystack = Addressable::URI.parse(@account.uri).host
+
+    !haystack.casecmp(needle).zero?
+  end
+
   def reply_to_local?
     !replied_to_status.nil? && replied_to_status.account.local?
   end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 1c35e1672..fa2a8f7d3 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -28,8 +28,6 @@ class ActivityPub::TagManager
     return target.uri if target.respond_to?(:local?) && !target.local?
 
     case target.object_type
-    when :follow
-      account_follow_url(target.account.username, target)
     when :person
       account_url(target)
     when :note, :comment, :activity
@@ -69,6 +67,8 @@ class ActivityPub::TagManager
   def cc(status)
     cc = []
 
+    cc << uri_for(status.reblog.account) if status.reblog?
+
     case status.visibility
     when 'public'
       cc << account_followers_url(status.account)
@@ -99,12 +99,6 @@ class ActivityPub::TagManager
       case klass.name
       when 'Account'
         klass.find_local(uri_to_local_id(uri, :username))
-      when 'FollowRequest'
-        params = Rails.application.routes.recognize_path(uri)
-        klass.joins(:account).find_by!(
-          accounts: { domain: nil, username: params[:account_username] },
-          id: params[:id]
-        )
       else
         StatusFinder.new(uri).status
       end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 20b10e113..fe5ebfc36 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -149,7 +149,10 @@ class FeedManager
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
 
+    return true if keyword_filter?(status, receiver_id)
+
     check_for_mutes = [status.account_id]
+    check_for_mutes.concat(status.mentions.pluck(:account_id))
     check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
 
     return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
@@ -174,6 +177,25 @@ class FeedManager
     false
   end
 
+  def keyword_filter?(status, receiver_id)
+    text_matcher = Glitch::KeywordMute.text_matcher_for(receiver_id)
+    tag_matcher  = Glitch::KeywordMute.tag_matcher_for(receiver_id)
+
+    should_filter   = text_matcher.matches?(status.text)
+    should_filter ||= text_matcher.matches?(status.spoiler_text)
+    should_filter ||= tag_matcher.matches?(status.tags)
+
+    if status.reblog?
+      reblog = status.reblog
+
+      should_filter ||= text_matcher.matches?(reblog.text)
+      should_filter ||= text_matcher.matches?(reblog.spoiler_text)
+      should_filter ||= tag_matcher.matches?(status.tags)
+    end
+
+    should_filter
+  end
+
   def filter_from_mentions?(status, receiver_id)
     return true if receiver_id == status.account_id
 
@@ -183,6 +205,7 @@ class FeedManager
 
     should_filter   = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
+    should_filter ||= keyword_filter?(status, receiver_id)                                                                               # or if the mention contains a muted keyword
 
     should_filter
   end
diff --git a/app/lib/frontmatter_handler.rb b/app/lib/frontmatter_handler.rb
new file mode 100644
index 000000000..83e5f465e
--- /dev/null
+++ b/app/lib/frontmatter_handler.rb
@@ -0,0 +1,244 @@
+# frozen_string_literal: true
+
+require 'singleton'
+
+#  See also `app/javascript/features/account/util/bio_metadata.js`.
+
+class FrontmatterHandler
+  include Singleton
+
+  #  CONVENIENCE FUNCTIONS  #
+
+  def self.unirex(str)
+    Regexp.new str, Regexp::MULTILINE, 'u'
+  end
+  def self.rexstr(exp)
+    '(?:' + exp.source + ')'
+  end
+
+  #  CHARACTER CLASSES  #
+
+  DOCUMENT_START    = /^/
+  DOCUMENT_END      = /$/
+  ALLOWED_CHAR      =  #  c-printable` in the YAML 1.2 spec.
+    /[\t\n\r\u{20}-\u{7e}\u{85}\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u
+  WHITE_SPACE       = /[ \t]/
+  INDENTATION       = / */
+  LINE_BREAK        = /\r?\n|\r|<br\s*\/?>/
+  ESCAPE_CHAR       = /[0abt\tnvfre "\/\\N_LP]/
+  HEXADECIMAL_CHARS = /[0-9a-fA-F]/
+  INDICATOR         = /[-?:,\[\]{}&#*!|>'"%@`]/
+  FLOW_CHAR         = /[,\[\]{}]/
+
+  #  NEGATED CHARACTER CLASSES  #
+
+  NOT_WHITE_SPACE   = unirex '(?!' + rexstr(WHITE_SPACE) + ').'
+  NOT_LINE_BREAK    = unirex '(?!' + rexstr(LINE_BREAK) + ').'
+  NOT_INDICATOR     = unirex '(?!' + rexstr(INDICATOR) + ').'
+  NOT_FLOW_CHAR     = unirex '(?!' + rexstr(FLOW_CHAR) + ').'
+  NOT_ALLOWED_CHAR  = unirex '(?!' + rexstr(ALLOWED_CHAR) + ').'
+
+  #  BASIC CONSTRUCTS  #
+
+  ANY_WHITE_SPACE   = unirex rexstr(WHITE_SPACE) + '*'
+  ANY_ALLOWED_CHARS = unirex rexstr(ALLOWED_CHAR) + '*'
+  NEW_LINE          = unirex(
+    rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
+  )
+  SOME_NEW_LINES    = unirex(
+    '(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+'
+  )
+  POSSIBLE_STARTS   = unirex(
+    rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
+  )
+  POSSIBLE_ENDS     = unirex(
+    rexstr(SOME_NEW_LINES) + '|' +
+    rexstr(DOCUMENT_END) + '|' +
+    rexstr(/<\/p>/)
+  )
+  CHARACTER_ESCAPE  = unirex(
+    rexstr(/\\/) +
+    '(?:' +
+      rexstr(ESCAPE_CHAR) + '|' +
+      rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' +
+      rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' +
+      rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' +
+    ')'
+  )
+  ESCAPED_CHAR      = unirex(
+    rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' +
+    rexstr(CHARACTER_ESCAPE)
+  )
+  ANY_ESCAPED_CHARS = unirex(
+    rexstr(ESCAPED_CHAR) + '*'
+  )
+  ESCAPED_APOS      = unirex(
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
+  )
+  ANY_ESCAPED_APOS  = unirex(
+    rexstr(ESCAPED_APOS) + '*'
+  )
+  FIRST_KEY_CHAR    = unirex(
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    rexstr(NOT_INDICATOR) + '|' +
+    rexstr(/[?:-]/) +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    '(?=' + rexstr(NOT_FLOW_CHAR) + ')'
+  )
+  FIRST_VALUE_CHAR  = unirex(
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    rexstr(NOT_INDICATOR) + '|' +
+    rexstr(/[?:-]/) +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+    #  Flow indicators are allowed in values.
+  )
+  LATER_KEY_CHAR    = unirex(
+    rexstr(WHITE_SPACE) + '|' +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
+    rexstr(/[^:#]#?/) + '|' +
+    rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+  )
+  LATER_VALUE_CHAR  = unirex(
+    rexstr(WHITE_SPACE) + '|' +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    #  Flow indicators are allowed in values.
+    rexstr(/[^:#]#?/) + '|' +
+    rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+  )
+
+  #  YAML CONSTRUCTS  #
+
+  YAML_START        = unirex(
+    rexstr(ANY_WHITE_SPACE) + rexstr(/---/)
+  )
+  YAML_END          = unirex(
+    rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/)
+  )
+  YAML_LOOKAHEAD    = unirex(
+    '(?=' +
+      rexstr(YAML_START) +
+      rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
+      rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +
+    ')'
+  )
+  YAML_DOUBLE_QUOTE = unirex(
+    rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/)
+  )
+  YAML_SINGLE_QUOTE = unirex(
+    rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/)
+  )
+  YAML_SIMPLE_KEY   = unirex(
+    rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
+  )
+  YAML_SIMPLE_VALUE = unirex(
+    rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
+  )
+  YAML_KEY          = unirex(
+    rexstr(YAML_DOUBLE_QUOTE) + '|' +
+    rexstr(YAML_SINGLE_QUOTE) + '|' +
+    rexstr(YAML_SIMPLE_KEY)
+  )
+  YAML_VALUE        = unirex(
+    rexstr(YAML_DOUBLE_QUOTE) + '|' +
+    rexstr(YAML_SINGLE_QUOTE) + '|' +
+    rexstr(YAML_SIMPLE_VALUE)
+  )
+  YAML_SEPARATOR    = unirex(
+    rexstr(ANY_WHITE_SPACE) +
+    ':' + rexstr(WHITE_SPACE) +
+    rexstr(ANY_WHITE_SPACE)
+  )
+  YAML_LINE         = unirex(
+    '(' + rexstr(YAML_KEY) + ')' +
+    rexstr(YAML_SEPARATOR) +
+    '(' + rexstr(YAML_VALUE) + ')'
+  )
+
+  #  FRONTMATTER REGEX  #
+
+  YAML_FRONTMATTER  = unirex(
+    rexstr(POSSIBLE_STARTS) +
+    rexstr(YAML_LOOKAHEAD) +
+    rexstr(YAML_START) + rexstr(SOME_NEW_LINES) +
+    '(?:' +
+      '(' + rexstr(INDENTATION) + ')' +
+      rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
+      '(?:' +
+        '\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
+      '){0,4}' +
+    ')?' +
+    rexstr(YAML_END) + rexstr(POSSIBLE_ENDS)
+  )
+
+  #  SEARCHES  #
+
+  FIND_YAML_LINES   = unirex(
+    rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE)
+  )
+
+  #  STRING PROCESSING  #
+
+  def process_string(str)
+    case str[0]
+    when '"'
+      str[1..-2]
+        .gsub(/\\0/, "\u{00}")
+        .gsub(/\\a/, "\u{07}")
+        .gsub(/\\b/, "\u{08}")
+        .gsub(/\\t/, "\u{09}")
+        .gsub(/\\\u{09}/, "\u{09}")
+        .gsub(/\\n/, "\u{0a}")
+        .gsub(/\\v/, "\u{0b}")
+        .gsub(/\\f/, "\u{0c}")
+        .gsub(/\\r/, "\u{0d}")
+        .gsub(/\\e/, "\u{1b}")
+        .gsub(/\\ /, "\u{20}")
+        .gsub(/\\"/, "\u{22}")
+        .gsub(/\\\//, "\u{2f}")
+        .gsub(/\\\\/, "\u{5c}")
+        .gsub(/\\N/, "\u{85}")
+        .gsub(/\\_/, "\u{a0}")
+        .gsub(/\\L/, "\u{2028}")
+        .gsub(/\\P/, "\u{2029}")
+        .gsub(/\\x([0-9a-fA-F]{2})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
+        .gsub(/\\u([0-9a-fA-F]{4})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
+        .gsub(/\\U([0-9a-fA-F]{8})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
+    when "'"
+      str[1..-2].gsub(/''/, "'")
+    else
+      str
+    end
+  end
+
+  #  BIO PROCESSING  #
+
+  def process_bio content
+    result = {
+      text: content.gsub(/&quot;/, '"').gsub(/&apos;/, "'"),
+      metadata: []
+    }
+    yaml = YAML_FRONTMATTER.match(result[:text])
+    return result unless yaml
+    yaml = yaml[0]
+    start = YAML_START =~ result[:text]
+    ending = start + yaml.length - (YAML_START =~ yaml)
+    result[:text][start..ending - 1] = ''
+    metadata = nil
+    index = 0
+    while metadata = FIND_YAML_LINES.match(yaml, index) do
+      index = metadata.end(0)
+      result[:metadata].push [
+        process_string(metadata[1]), process_string(metadata[2])
+      ]
+    end
+    return result
+  end
+
+end
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index f210e134a..b38407cd3 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -26,6 +26,9 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     cached_reblog = reblog
     status = nil
 
+    # Skip if the reblogged status is not public
+    return if cached_reblog && !(cached_reblog.public_visibility? || cached_reblog.unlisted_visibility?)
+
     media_attachments = save_media
 
     ApplicationRecord.transaction do
diff --git a/app/lib/themes.rb b/app/lib/themes.rb
index 243ffb9ab..55824a5c4 100644
--- a/app/lib/themes.rb
+++ b/app/lib/themes.rb
@@ -7,10 +7,77 @@ class Themes
   include Singleton
 
   def initialize
-    @conf = YAML.load_file(Rails.root.join('config', 'themes.yml'))
+
+    core = YAML.load_file(Rails.root.join('app', 'javascript', 'core', 'theme.yml'))
+    core['pack'] = Hash.new unless core['pack']
+
+    result = Hash.new
+    Dir.glob(Rails.root.join('app', 'javascript', 'flavours', '*', 'theme.yml')) do |path|
+      data = YAML.load_file(path)
+      dir = File.dirname(path)
+      name = File.basename(dir)
+      locales = []
+      screenshots = []
+      if data['locales']
+        Dir.glob(File.join(dir, data['locales'], '*.{js,json}')) do |locale|
+          localeName = File.basename(locale, File.extname(locale))
+          locales.push(localeName) unless localeName.match(/defaultMessages|whitelist|index/)
+        end
+      end
+      if data['screenshot']
+        if data['screenshot'].is_a? Array
+          screenshots = data['screenshot']
+        else
+          screenshots.push(data['screenshot'])
+        end
+      end
+      if data['pack']
+        data['name'] = name
+        data['locales'] = locales
+        data['screenshot'] = screenshots
+        data['skin'] = { 'default' => [] }
+        result[name] = data
+      end
+    end
+
+    Dir.glob(Rails.root.join('app', 'javascript', 'skins', '*', '*')) do |path|
+      ext = File.extname(path)
+      skin = File.basename(path)
+      name = File.basename(File.dirname(path))
+      if result[name]
+        if File.directory?(path)
+          pack = []
+          Dir.glob(File.join(path, '*.{css,scss}')) do |sheet|
+            pack.push(File.basename(sheet, File.extname(sheet)))
+          end
+        elsif ext.match(/^\.s?css$/i)
+          skin = File.basename(path, ext)
+          pack = ['common']
+        end
+        if skin != 'default'
+          result[name]['skin'][skin] = pack
+        end
+      end
+    end
+
+    @core = core
+    @conf = result
+
   end
 
-  def names
+  def core
+    @core
+  end
+
+  def flavour(name)
+    @conf[name]
+  end
+
+  def flavours
     @conf.keys
   end
+
+  def skins_for(name)
+    @conf[name]['skin'].keys
+  end
 end
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index d48e1da65..5f0176f27 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -21,12 +21,14 @@ class UserSettingsDecorator
     user.settings['default_sensitive']   = default_sensitive_preference if change?('setting_default_sensitive')
     user.settings['unfollow_modal']      = unfollow_modal_preference if change?('setting_unfollow_modal')
     user.settings['boost_modal']         = boost_modal_preference if change?('setting_boost_modal')
+    user.settings['favourite_modal']         = favourite_modal_preference if change?('setting_favourite_modal')
     user.settings['delete_modal']        = delete_modal_preference if change?('setting_delete_modal')
     user.settings['auto_play_gif']       = auto_play_gif_preference if change?('setting_auto_play_gif')
     user.settings['reduce_motion']       = reduce_motion_preference if change?('setting_reduce_motion')
     user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
     user.settings['noindex']             = noindex_preference if change?('setting_noindex')
-    user.settings['theme']               = theme_preference if change?('setting_theme')
+    user.settings['flavour']             = flavour_preference if change?('setting_flavour')
+    user.settings['skin']                = skin_preference if change?('setting_skin')
   end
 
   def merged_notification_emails
@@ -52,6 +54,10 @@ class UserSettingsDecorator
   def boost_modal_preference
     boolean_cast_setting 'setting_boost_modal'
   end
+  
+  def favourite_modal_preference
+    boolean_cast_setting 'setting_favourite_modal'
+  end
 
   def delete_modal_preference
     boolean_cast_setting 'setting_delete_modal'
@@ -73,8 +79,12 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_noindex'
   end
 
-  def theme_preference
-    settings['setting_theme']
+  def flavour_preference
+    settings['setting_flavour']
+  end
+
+  def skin_preference
+    settings['setting_skin']
   end
 
   def boolean_cast_setting(key)
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index fd9223533..a30468eb8 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -1,7 +1,9 @@
 # frozen_string_literal: true
 
 class AdminMailer < ApplicationMailer
-  helper StreamEntriesHelper
+  layout 'plain_mailer'
+
+  helper :stream_entries
 
   def new_report(recipient, report)
     @report   = report
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 95b770ff1..683b60c86 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -2,7 +2,9 @@
 
 class ApplicationMailer < ActionMailer::Base
   layout 'mailer'
+
   helper :instance
+  helper :mailer
 
   protected
 
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index fd2b0649a..9fed4a636 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -1,7 +1,9 @@
 # frozen_string_literal: true
 
 class NotificationMailer < ApplicationMailer
-  helper StreamEntriesHelper
+  helper :stream_entries
+
+  add_template_helper RoutingHelper
 
   def mention(recipient, notification)
     @me     = recipient
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 7821be32b..a7efa73c1 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -5,6 +5,8 @@ class UserMailer < Devise::Mailer
 
   helper :instance
 
+  add_template_helper RoutingHelper
+
   def confirmation_instructions(user, token, **)
     @resource = user
     @token    = token
diff --git a/app/models/account.rb b/app/models/account.rb
index 686e74044..c75ea028e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -56,6 +56,8 @@ class Account < ApplicationRecord
   include Remotable
   include Paginable
 
+  MAX_NOTE_LENGTH = 500
+
   enum protocol: [:ostatus, :activitypub]
 
   # Local users
@@ -70,7 +72,7 @@ class Account < ApplicationRecord
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
   validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
-  validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
+  validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? }
 
   # Timelines
   has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -367,6 +369,22 @@ class Account < ApplicationRecord
     self.public_key  = keypair.public_key.to_pem
   end
 
+  YAML_START = "---\r\n"
+  YAML_END = "\r\n...\r\n"
+
+  def note_length_does_not_exceed_length_limit
+    note_without_metadata = note
+    if note.start_with? YAML_START
+      idx = note.index YAML_END
+      unless idx.nil?
+        note_without_metadata = note[(idx + YAML_END.length) .. -1]
+      end
+    end
+    if note_without_metadata.mb_chars.grapheme_length > MAX_NOTE_LENGTH
+      errors.add(:note, "can't be longer than 500 graphemes")
+    end
+  end
+
   def normalize_domain
     return if local?
 
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 33e5fec12..ebf6959ce 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -21,10 +21,6 @@ class FollowRequest < ApplicationRecord
 
   validates :account_id, uniqueness: { scope: :target_account_id }
 
-  def object_type
-    :follow
-  end
-
   def authorize!
     account.follow!(target_account, reblogs: show_reblogs)
     MergeWorker.perform_async(target_account.id, account.id)
diff --git a/app/models/glitch.rb b/app/models/glitch.rb
new file mode 100644
index 000000000..0e497babc
--- /dev/null
+++ b/app/models/glitch.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Glitch
+  def self.table_name_prefix
+    'glitch_'
+  end
+end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
new file mode 100644
index 000000000..a2481308f
--- /dev/null
+++ b/app/models/glitch/keyword_mute.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: glitch_keyword_mutes
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  keyword    :string           not null
+#  whole_word :boolean          default(TRUE), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class Glitch::KeywordMute < ApplicationRecord
+  belongs_to :account, required: true
+
+  validates_presence_of :keyword
+
+  after_commit :invalidate_cached_matchers
+
+  def self.text_matcher_for(account_id)
+    TextMatcher.new(account_id)
+  end
+
+  def self.tag_matcher_for(account_id)
+    TagMatcher.new(account_id)
+  end
+
+  private
+
+  def invalidate_cached_matchers
+    Rails.cache.delete(TextMatcher.cache_key(account_id))
+    Rails.cache.delete(TagMatcher.cache_key(account_id))
+  end
+
+  class RegexpMatcher
+    attr_reader :account_id
+    attr_reader :regex
+
+    def initialize(account_id)
+      @account_id = account_id
+      regex_text = Rails.cache.fetch(self.class.cache_key(account_id)) { make_regex_text }
+      @regex = /#{regex_text}/
+    end
+
+    protected
+
+    def keywords
+      Glitch::KeywordMute.where(account_id: account_id).pluck(:whole_word, :keyword)
+    end
+
+    def boundary_regex_for_keyword(keyword)
+      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+
+      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    end
+  end
+
+  class TextMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:text:%s', account_id)
+    end
+
+    def matches?(str)
+      !!(regex =~ str)
+    end
+
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        whole_word ? boundary_regex_for_keyword(keyword) : keyword
+      end
+
+      Regexp.union(kws).source
+    end
+  end
+
+  class TagMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:tag:%s', account_id)
+    end
+
+    def matches?(tags)
+      tags.pluck(:name).any? { |n| regex =~ n }
+    end
+
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        term = (Tag::HASHTAG_RE =~ keyword) ? $1 : keyword
+        whole_word ? boundary_regex_for_keyword(term) : term
+      end
+
+      Regexp.union(kws).source
+    end
+  end
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index abc5ab854..368ccef3a 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -24,15 +24,32 @@ require 'mime/types'
 class MediaAttachment < ApplicationRecord
   self.inheritance_column = nil
 
-  enum type: [:image, :gifv, :video, :unknown]
+  enum type: [:image, :gifv, :video, :audio, :unknown]
 
   IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze
   VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v'].freeze
+  AUDIO_FILE_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.ogg'].freeze
 
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
+  AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 
   IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  AUDIO_STYLES = {
+    original: {
+      format: 'mp4',
+      convert_options: {
+        output: {
+          filter_complex: '"[0:a]compand,showwaves=s=640x360:mode=line,format=yuv420p[v]"',
+          map: '"[v]" -map 0:a', 
+          threads: 2,
+          vcodec: 'libx264',
+          acodec: 'aac',
+          movflags: '+faststart',
+        },
+      },
+    },
+  }.freeze
   VIDEO_STYLES = {
     small: {
       convert_options: {
@@ -55,7 +72,7 @@ class MediaAttachment < ApplicationRecord
 
   include Remotable
 
-  validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
+  validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
   validates_attachment_size :file, less_than: 8.megabytes
 
   validates :account, presence: true
@@ -110,6 +127,8 @@ class MediaAttachment < ApplicationRecord
         }
       elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
         IMAGE_STYLES
+      elsif AUDIO_MIME_TYPES.include? f.instance.file_content_type
+        AUDIO_STYLES
       else
         VIDEO_STYLES
       end
@@ -120,6 +139,8 @@ class MediaAttachment < ApplicationRecord
         [:gif_transcoder]
       elsif VIDEO_MIME_TYPES.include? f.file_content_type
         [:video_transcoder]
+      elsif AUDIO_MIME_TYPES.include? f.file_content_type
+        [:audio_transcoder]
       else
         [:thumbnail]
       end
@@ -144,8 +165,8 @@ class MediaAttachment < ApplicationRecord
   end
 
   def set_type_and_extension
-    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
-    extension = appropriate_extension
+    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : AUDIO_MIME_TYPES.include?(file_content_type) ? :audio : :image
+    extension = AUDIO_MIME_TYPES.include?(file_content_type) ? '.mp4' : appropriate_extension
     basename  = Paperclip::Interpolations.basename(file, :original)
     file.instance_write :file_name, [basename, extension].delete_if(&:blank?).join('.')
   end
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 105696da6..ca984641a 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -6,9 +6,9 @@
 #  id                 :integer          not null, primary key
 #  created_at         :datetime         not null
 #  updated_at         :datetime         not null
+#  hide_notifications :boolean          default(TRUE), not null
 #  account_id         :integer          not null
 #  target_account_id  :integer          not null
-#  hide_notifications :boolean          default(TRUE), not null
 #
 
 class Mute < ApplicationRecord
diff --git a/app/models/status.rb b/app/models/status.rb
index 00dcec624..cb18b0705 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -23,6 +23,7 @@
 #  account_id             :integer          not null
 #  application_id         :integer
 #  in_reply_to_account_id :integer
+#  local_only             :boolean
 #
 
 class Status < ApplicationRecord
@@ -74,6 +75,8 @@ class Status < ApplicationRecord
   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
 
+  scope :not_local_only, -> { where(local_only: [false, nil]) }
+
   cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
 
   delegate :domain, to: :account, prefix: true
@@ -139,6 +142,8 @@ class Status < ApplicationRecord
 
   around_create Mastodon::Snowflake::Callbacks
 
+  before_create :set_locality
+
   before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
   before_validation :set_visibility
@@ -155,6 +160,14 @@ class Status < ApplicationRecord
       where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
     end
 
+    def as_direct_timeline(account)
+      query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
+              .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
+              .where(visibility: [:direct])
+
+      apply_timeline_filters(query, account, false)
+    end
+
     def as_public_timeline(account = nil, local_only = false)
       query = timeline_scope(local_only).without_replies
 
@@ -211,7 +224,7 @@ class Status < ApplicationRecord
       visibility = [:public, :unlisted]
 
       if account.nil?
-        where(visibility: visibility)
+        where(visibility: visibility).not_local_only
       elsif target_account.blocking?(account) # get rid of blocked peeps
         none
       elsif account.id == target_account.id # author can see own stuff
@@ -250,7 +263,7 @@ class Status < ApplicationRecord
     end
 
     def filter_timeline_default(query)
-      query.excluding_silenced_accounts
+      query.not_local_only.excluding_silenced_accounts
     end
 
     def account_silencing_filter(account)
@@ -262,6 +275,15 @@ class Status < ApplicationRecord
     end
   end
 
+  def marked_local_only?
+    # match both with and without U+FE0F (the emoji variation selector)
+    /#{local_only_emoji}\ufe0f?\z/.match?(content)
+  end
+
+  def local_only_emoji
+    '👁'
+  end
+
   private
 
   def store_uri
@@ -287,6 +309,12 @@ class Status < ApplicationRecord
     self.sensitive = sensitive || spoiler_text.present?
   end
 
+  def set_locality
+    if account.domain.nil? && !attribute_changed?(:local_only)
+      self.local_only = marked_local_only?
+    end
+  end
+
   def set_conversation
     self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
 
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index 2ae034d93..36fe487dc 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -27,7 +27,7 @@ class StreamEntry < ApplicationRecord
   scope :recent, -> { reorder(id: :desc) }
   scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
 
-  delegate :target, :title, :content, :thread,
+  delegate :target, :title, :content, :thread, :local_only?,
            to: :status,
            allow_nil: true
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 9459db7fe..65ecb33cd 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -78,8 +78,8 @@ class User < ApplicationRecord
 
   has_many :session_activations, dependent: :destroy
 
-  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
-           :reduce_motion, :system_font_ui, :noindex, :theme,
+  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
+           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_accessor :invite_code
@@ -170,6 +170,10 @@ class User < ApplicationRecord
     settings.default_privacy || (account.locked? ? 'private' : 'public')
   end
 
+  def allows_digest_emails?
+    settings.notification_emails['digest']
+  end
+
   def token_for_app(a)
     return nil if a.nil? || a.owner != self
     Doorkeeper::AccessToken
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 0373fdf04..369ede2b0 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -6,6 +6,8 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def show?
+    return false if local_only? && current_account.nil?
+
     if direct?
       owned? || record.mentions.where(account: current_account).exists?
     elsif private?
@@ -46,4 +48,8 @@ class StatusPolicy < ApplicationPolicy
   def author
     record.account
   end
+  
+  def local_only?
+    record.local_only?
+  end
 end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 4c1124d59..1c08fb3bc 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -32,6 +32,15 @@ class InstancePresenter
     Mastodon::Version
   end
 
+  def commit_hash
+    current_release_file = Pathname.new('CURRENT_RELEASE').expand_path
+    if current_release_file.file?
+      IO.read(current_release_file).strip!
+    else
+      ''
+    end
+  end
+
   def source_url
     Mastodon::Version.source_url
   end
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index eecd64701..86c9992fe 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -1,12 +1,11 @@
 # frozen_string_literal: true
 
 class ActivityPub::FollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
-  attribute :id, if: :dereferencable?
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
   def id
-    ActivityPub::TagManager.instance.uri_for(object)
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
   end
 
   def type
@@ -20,8 +19,4 @@ class ActivityPub::FollowSerializer < ActiveModel::Serializer
   def virtual_object
     ActivityPub::TagManager.instance.uri_for(object.target_account)
   end
-
-  def dereferencable?
-    object.respond_to?(:object_type)
-  end
 end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 4fa1981ed..904daa804 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -2,10 +2,15 @@
 
 class InitialStateSerializer < ActiveModel::Serializer
   attributes :meta, :compose, :accounts,
-             :media_attachments, :settings, :push_subscription
+             :media_attachments, :settings, :push_subscription,
+             :max_toot_chars
 
   has_many :custom_emojis, serializer: REST::CustomEmojiSerializer
 
+  def max_toot_chars
+    StatusLengthValidator::MAX_CHARS
+  end
+
   def custom_emojis
     CustomEmoji.local.where(disabled: false)
   end
@@ -23,6 +28,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:me]             = object.current_account.id.to_s
       store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal
       store[:boost_modal]    = object.current_account.user.setting_boost_modal
+      store[:favourite_modal]  = object.current_account.user.setting_favourite_modal
       store[:delete_modal]   = object.current_account.user.setting_delete_modal
       store[:auto_play_gif]  = object.current_account.user.setting_auto_play_gif
       store[:reduce_motion]  = object.current_account.user.setting_reduce_motion
@@ -53,6 +59,6 @@ class InitialStateSerializer < ActiveModel::Serializer
   end
 
   def media_attachments
-    { accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES }
+    { accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::AUDIO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES }
   end
 end
diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb
index 95bcc21bb..859ef0d14 100644
--- a/app/serializers/manifest_serializer.rb
+++ b/app/serializers/manifest_serializer.rb
@@ -6,7 +6,8 @@ class ManifestSerializer < ActiveModel::Serializer
 
   attributes :name, :short_name, :description,
              :icons, :theme_color, :background_color,
-             :display, :start_url, :scope
+             :display, :start_url, :scope,
+             :share_target
 
   def name
     object.site_title
@@ -49,4 +50,8 @@ class ManifestSerializer < ActiveModel::Serializer
   def scope
     root_url
   end
+
+  def share_target
+    { url_template: 'share?title={title}&text={text}&url={url}' }
+  end
 end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index ae1dbe6b5..65907dad2 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -4,7 +4,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
   include RoutingHelper
 
   attributes :uri, :title, :description, :email,
-             :version, :urls, :stats, :thumbnail
+             :version, :urls, :stats, :thumbnail, :max_toot_chars
 
   def uri
     Rails.configuration.x.local_domain
@@ -30,6 +30,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
     instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('preview.jpg')
   end
 
+  def max_toot_chars
+    StatusLengthValidator::MAX_CHARS
+  end
+
   def stats
     {
       user_count: instance_presenter.user_count,
diff --git a/app/serializers/rest/mute_serializer.rb b/app/serializers/rest/mute_serializer.rb
new file mode 100644
index 000000000..043a2f059
--- /dev/null
+++ b/app/serializers/rest/mute_serializer.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class REST::MuteSerializer < ActiveModel::Serializer
+  include RoutingHelper
+  
+  attributes :id, :account, :target_account, :created_at, :hide_notifications
+
+  def account
+    REST::AccountSerializer.new(object.account)
+  end
+
+  def target_account
+    REST::AccountSerializer.new(object.target_account)
+  end
+end
\ No newline at end of file
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index 7649bceca..503c175d8 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -30,7 +30,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   end
 
   def actor_id
-    first_of_value(@json['attributedTo'])
+    value_or_id(first_of_value(@json['attributedTo']))
   end
 
   def trustworthy_attribution?(uri, attributed_to)
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 0fbf18c00..f43edafe7 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -6,7 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService
   # Should be called with confirmed valid JSON
   # and WebFinger-resolved username and domain
   def call(username, domain, json)
-    return if json['inbox'].blank?
+    return if json['inbox'].blank? || unsupported_uri_scheme?(json['id'])
 
     @json        = json
     @uri         = @json['id']
@@ -107,7 +107,21 @@ class ActivityPub::ProcessAccountService < BaseService
 
   def url
     return if @json['url'].blank?
-    url_to_href(@json['url'], 'text/html')
+
+    url_candidate = url_to_href(@json['url'], 'text/html')
+
+    if unsupported_uri_scheme?(url_candidate) || mismatching_origin?(url_candidate)
+      nil
+    else
+      url_candidate
+    end
+  end
+
+  def mismatching_origin?(url)
+    needle   = Addressable::URI.parse(url).host
+    haystack = Addressable::URI.parse(@uri).host
+
+    !haystack.casecmp(needle).zero?
   end
 
   def outbox_total_items
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index e2763c2b9..cb65a2256 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -36,6 +36,7 @@ class BatchedRemoveStatusService < BaseService
     # Cannot be batched
     statuses.each do |status|
       unpush_from_public_timelines(status)
+      unpush_from_direct_timelines(status) if status.direct_visibility?
       batch_salmon_slaps(status) if status.local?
     end
 
@@ -87,6 +88,16 @@ class BatchedRemoveStatusService < BaseService
     end
   end
 
+  def unpush_from_direct_timelines(status)
+    payload = @json_payloads[status.id]
+    redis.pipelined do
+      @mentions[status.id].each do |mention|
+        redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local?
+      end
+      redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local?
+    end
+  end
+
   def batch_salmon_slaps(status)
     return if @mentions[status.id].empty?
 
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index bbaf3094b..0f77556dc 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -10,8 +10,11 @@ class FanOutOnWriteService < BaseService
 
     deliver_to_self(status) if status.account.local?
 
+    render_anonymous_payload(status)
+
     if status.direct_visibility?
       deliver_to_mentioned_followers(status)
+      deliver_to_direct_timelines(status)
     else
       deliver_to_followers(status)
       deliver_to_lists(status)
@@ -19,7 +22,6 @@ class FanOutOnWriteService < BaseService
 
     return if status.account.silenced? || !status.public_visibility? || status.reblog?
 
-    render_anonymous_payload(status)
     deliver_to_hashtags(status)
 
     return if status.reply? && status.in_reply_to_account_id != status.account_id
@@ -84,4 +86,13 @@ class FanOutOnWriteService < BaseService
     Redis.current.publish('timeline:public', @payload)
     Redis.current.publish('timeline:public:local', @payload) if status.local?
   end
+
+  def deliver_to_direct_timelines(status)
+    Rails.logger.debug "Delivering status #{status.id} to direct timelines"
+
+    status.mentions.includes(:account).each do |mention|
+      Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
+    end
+    Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local?
+  end
 end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 9b7cbd81f..547b2efa1 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -3,7 +3,6 @@
 class MuteService < BaseService
   def call(account, target_account, notifications: nil)
     return if account.id == target_account.id
-    FeedManager.instance.clear_from_timeline(account, target_account)
     mute = account.mute!(target_account, notifications: notifications)
     BlockWorker.perform_async(account.id, target_account.id)
     mute
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 92d868afe..6b6a37676 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -39,9 +39,12 @@ class PostStatusService < BaseService
 
     LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
     DistributionWorker.perform_async(status.id)
-    Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
-    ActivityPub::DistributionWorker.perform_async(status.id)
-    ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
+
+    unless status.local_only?
+      Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(status.id)
+      ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
+    end
 
     if options[:idempotency].present?
       redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id)
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 3c4e5847f..8d8b15a41 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -20,8 +20,11 @@ class ReblogService < BaseService
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
     DistributionWorker.perform_async(reblog.id)
-    Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
-    ActivityPub::DistributionWorker.perform_async(reblog.id)
+
+    unless reblogged_status.local_only?
+      Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(reblog.id)
+    end
 
     create_notification(reblog)
     reblog
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index a100f73ce..e164c03ab 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -20,6 +20,7 @@ class RemoveStatusService < BaseService
     remove_reblogs
     remove_from_hashtags
     remove_from_public
+    remove_from_direct if status.direct_visibility?
 
     @status.destroy!
 
@@ -130,6 +131,13 @@ class RemoveStatusService < BaseService
     Redis.current.publish('timeline:public:local', @payload) if @status.local?
   end
 
+  def remove_from_direct
+    @mentions.each do |mention|
+      Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
+    end
+    Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local?
+  end
+
   def redis
     Redis.current
   end
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
index 77be3f1f5..79d17742a 100644
--- a/app/validators/status_length_validator.rb
+++ b/app/validators/status_length_validator.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class StatusLengthValidator < ActiveModel::Validator
-  MAX_CHARS = 500
+  MAX_CHARS = (ENV['MAX_TOOT_CHARS'] || 500).to_i
 
   def validate(status)
     return unless status.local? && !status.reblog?
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index b012606ce..d92362bd7 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -2,7 +2,6 @@
   = site_hostname
 
 - content_for :header_tags do
-  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
   = render partial: 'shared/og'
 
 .landing-page
@@ -55,4 +54,8 @@
     .container
       %p
         = link_to t('about.source_code'), @instance_presenter.source_url
-        = " (#{@instance_presenter.version_number})"
+        - if @instance_presenter.commit_hash == ""
+          %strong= " (#{@instance_presenter.version_number})"
+        - else
+          %strong= "#{@instance_presenter.version_number}, "
+          %strong= "#{@instance_presenter.commit_hash}"
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index f8f90ce24..4f5b53470 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -3,7 +3,6 @@
 
 - content_for :header_tags do
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
-  = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
   = render partial: 'shared/og'
 
 .landing-page
@@ -47,6 +46,13 @@
                 %p= t('about.closed_registrations')
               - else
                 = @instance_presenter.closed_registrations_message.html_safe
+
+            = simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) do |f|
+              = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
+              = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
+
+              .actions
+                = f.button :button, t('auth.login'), type: :submit
             = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block'
 
   .about-short
@@ -68,4 +74,8 @@
     .container
       %p
         = link_to t('about.source_code'), @instance_presenter.source_url
-        = " (#{@instance_presenter.version_number})"
+        - if @instance_presenter.commit_hash == ""
+          %strong= " (#{@instance_presenter.version_number})"
+        - else
+          %strong= " (#{@instance_presenter.version_number}, "
+          %strong= " #{@instance_presenter.commit_hash})"
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index 3800bd837..b0062752c 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -1,3 +1,4 @@
+- processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account
 .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" }
   .card__illustration
     - unless account.memorial? || account.moved?
@@ -8,7 +9,7 @@
               = fa_icon 'user-times'
               = t('accounts.unfollow')
           - else
-            = link_to account_follows_path(account), data: { method: :post }, class: 'icon-button' do
+            = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
               = fa_icon 'user-plus'
               = t('accounts.follow')
       - elsif !user_signed_in?
@@ -26,7 +27,6 @@
       %small
         %span @#{account.local_username_and_domain}
         = fa_icon('lock') if account.locked?
-
     - if Setting.show_staff_badge
       - if account.user_admin?
         .roles
@@ -36,9 +36,14 @@
         .roles
           .account-role.moderator
             = t 'accounts.roles.moderator'
-
     .bio
-      .account__header__content.p-note.emojify= Formatter.instance.simplified_format(account)
+      .account__header__content.p-note.emojify!=processed_bio[:text]
+      - if processed_bio[:metadata].length > 0
+        %table.metadata<
+          - processed_bio[:metadata].each do |i|
+            %tr.metadata-item><
+              %th.emojify>!=i[0]
+              %td.emojify>!=i[1]
 
     .details-counters
       .counter{ class: active_nav_class(short_account_url(account)) }
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 5747cc274..7dd962bf2 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.reports.report', id: @report.id)
 
diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml
index fe2581527..9747a92cf 100644
--- a/app/views/admin/statuses/index.html.haml
+++ b/app/views/admin/statuses/index.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.statuses.title')
 
diff --git a/app/views/auth/registrations/_sessions.html.haml b/app/views/auth/registrations/_sessions.html.haml
index c1e9764b3..8424a8901 100644
--- a/app/views/auth/registrations/_sessions.html.haml
+++ b/app/views/auth/registrations/_sessions.html.haml
@@ -16,7 +16,7 @@
             %span{ title: session.user_agent }<
               = fa_icon "#{session_device_icon(session)} fw", 'aria-label' => session_device_icon(session)
               = ' '
-              = t 'sessions.description', browser: t("sessions.browsers.#{session.browser}"), platform: t("sessions.platforms.#{session.platform}")
+              = t 'sessions.description', browser: t("sessions.browsers.#{session.browser}", default: "#{session.browser}"), platform: t("sessions.platforms.#{session.platform}", default: "#{session.platform}")
           %td
             %samp= session.ip
           %td
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index 8c88d2d64..e8a81656c 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -1,13 +1,7 @@
 - content_for :header_tags do
-  %link{ href: asset_pack_path('features/getting_started.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
-  %link{ href: asset_pack_path('features/compose.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
-  %link{ href: asset_pack_path('features/home_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
-  %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
   %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
 
-  = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous'
-
 .app-holder#mastodon{ data: { props: Oj.dump(default_props) } }
   %noscript
     = image_tag asset_pack_path('logo.svg'), alt: 'Mastodon'
diff --git a/app/views/layouts/_theme.html.haml b/app/views/layouts/_theme.html.haml
new file mode 100644
index 000000000..066d9de42
--- /dev/null
+++ b/app/views/layouts/_theme.html.haml
@@ -0,0 +1,13 @@
+- if theme
+  - if theme[:pack] != 'common' && theme[:common]
+    = render partial: 'layouts/theme', object: theme[:common]
+  - if theme[:pack]
+    = javascript_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", integrity: true, crossorigin: 'anonymous'
+    - if theme[:skin]
+      - if !theme[:flavour] || theme[:skin] == 'default'
+        = stylesheet_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", integrity: true, media: 'all'
+      - else
+        = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}"
+    - if theme[:preload]
+      - theme[:preload].each do |link|
+        %link{ href: asset_pack_path("#{link}.js"), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index c98d85f7b..66382db50 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-
 - content_for :content do
   .admin-wrapper
     .sidebar-wrapper
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index f38c59165..322d7403e 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -17,14 +17,20 @@
         = ' - '
       = title
 
-    = stylesheet_pack_tag 'common', media: 'all'
-    = stylesheet_pack_tag current_theme, media: 'all'
-    = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
-    = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
+    = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous'
+    - if @theme
+      - if @theme[:supported_locales].include? I18n.locale.to_s
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
+      - elsif @theme[:supported_locales].include? 'en'
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
     = csrf_meta_tags
 
     = yield :header_tags
 
+    -#  These must come after :header_tags to ensure our initial state has been defined.
+    = render partial: 'layouts/theme', object: @core
+    = render partial: 'layouts/theme', object: @theme
+
   - body_classes ||= @body_classes || ''
   - body_classes += ' system-font' if current_account&.user&.setting_system_font_ui
   - body_classes += current_account&.user&.setting_reduce_motion ? ' reduce-motion' : ' no-reduce-motion'
diff --git a/app/views/layouts/auth.html.haml b/app/views/layouts/auth.html.haml
index d8ac733f9..f4812ac6a 100644
--- a/app/views/layouts/auth.html.haml
+++ b/app/views/layouts/auth.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-
 - content_for :content do
   .container
     .logo-container
diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml
index ac11cfbe7..935670514 100644
--- a/app/views/layouts/embedded.html.haml
+++ b/app/views/layouts/embedded.html.haml
@@ -4,10 +4,8 @@
     %meta{ charset: 'utf-8' }/
     %meta{ name: 'robots', content: 'noindex' }/
 
-    = stylesheet_pack_tag 'common', media: 'all'
-    = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all'
-    = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
+    = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous'
     = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
-    = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-  %body.embed
+    = render partial: 'layouts/theme', object: @core
+    = render partial: 'layouts/theme', object: @theme
     = yield
diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml
index 37359b89b..9904b8fdd 100644
--- a/app/views/layouts/error.html.haml
+++ b/app/views/layouts/error.html.haml
@@ -5,8 +5,8 @@
     %meta{ charset: 'utf-8' }/
     %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ')
     %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/
-    = stylesheet_pack_tag 'common', media: 'all'
-    = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all'
+    = render partial: 'layouts/theme', object: @core
+    = render partial: 'layouts/theme', object: @theme
   %body.error
     .dialog
       %img{ alt: Setting.default_settings['site_title'], src: '/oops.gif' }/
diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml
new file mode 100644
index 000000000..85029ec76
--- /dev/null
+++ b/app/views/layouts/mailer.html.haml
@@ -0,0 +1,55 @@
+!!!
+%html{ lang: I18n.locale }
+  %head
+    %meta{ 'http-equiv' => 'Content-Type', 'content' => 'text/html; charset=utf-8' }/
+    %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, shrink-to-fit=no' }
+
+    %title/
+
+    = stylesheet_pack_tag 'core/mailer'
+  %body
+    %table.email-table{ cellspacing: 0, cellpadding: 0 }
+      %tbody
+        %tr
+          %td.email-body.email-start
+            .email-container
+              %table.content-section{ cellspacing: 0, cellpadding: 0 }
+                %tbody
+                  %tr
+                    %td.content-cell.header
+                      .email-row
+                        .col-6
+                          %table.column{ cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.column-cell
+                                  = link_to root_url do
+                                    = image_tag full_pack_url('logo_full.svg'), alt: 'Mastodon', height: 34, class: 'logo'
+
+    = yield
+
+    %table.email-table{ cellspacing: 0, cellpadding: 0 }
+      %tbody
+        %tr
+          %td.email-body.email-end
+            .email-container
+              %table.content-section{ cellspacing: 0, cellpadding: 0 }
+                %tbody
+                  %tr
+                    %td.content-cell.content-end
+                      != "&nbsp; "
+                  %tr
+                    %td.blank-cell.footer
+                      .email-row
+                        .col-4
+                          %table.column{ cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %td.column-cell
+                                %p= t 'about.hosted_on', domain: site_hostname
+                                %p= link_to t('application_mailer.notification_preferences'), settings_notifications_url
+                        .col-2
+                          %table.column{ cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %td.column-cell.text-right
+                                = link_to root_url do
+                                  = image_tag full_pack_url('logo_transparent.svg'), alt: 'Mastodon', height: 24
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
index d01b6b6c5..a5d79f5c0 100644
--- a/app/views/layouts/modal.html.haml
+++ b/app/views/layouts/modal.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-
 - content_for :content do
   - if user_signed_in?
     .account-header
diff --git a/app/views/layouts/plain_mailer.html.haml b/app/views/layouts/plain_mailer.html.haml
new file mode 100644
index 000000000..0a90f092c
--- /dev/null
+++ b/app/views/layouts/plain_mailer.html.haml
@@ -0,0 +1 @@
+= yield
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index 83e92b938..b3795eaad 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-
 - content_for :content do
   .container= yield
   .footer
diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml
new file mode 100644
index 000000000..1e796ed29
--- /dev/null
+++ b/app/views/notification_mailer/_status.html.haml
@@ -0,0 +1,30 @@
+- i ||= 0
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell{ class: i.zero? ? 'content-start' : nil }
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.padded.status
+                              %table.status-header{ cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td{ align: 'left', width: 48 }
+                                      = image_tag status.account.avatar
+                                    %td{ align: 'left' }
+                                      %bdi= display_name(status.account)
+                                      = "@#{status.account.acct}"
+
+                              = Formatter.instance.format(status)
+
+                              %p.status-footer
+                                = link_to l(status.created_at), web_url("statuses/#{status.id}")
diff --git a/app/views/notification_mailer/digest.html.haml b/app/views/notification_mailer/digest.html.haml
new file mode 100644
index 000000000..10e44f8dd
--- /dev/null
+++ b/app/views/notification_mailer/digest.html.haml
@@ -0,0 +1,44 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.darker.hero-with-button
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %h1= t 'notification_mailer.digest.title'
+                              %p.lead= t('notification_mailer.digest.body', since: l(@since.to_date, format: :short), instance: site_hostname)
+                              %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td.button-primary
+                                      = link_to web_url do
+                                        %span= t 'notification_mailer.digest.action'
+
+- @notifications.each_with_index do |n, i|
+  = render 'status', status: n.target_status, i: i
+
+- unless @follows_since.zero?
+  %table.email-table{ cellspacing: 0, cellpadding: 0 }
+    %tbody
+      %tr
+        %td.email-body
+          .email-container
+            %table.content-section{ cellspacing: 0, cellpadding: 0 }
+              %tbody
+                %tr
+                  %td.content-cell.content-start.border-top
+                    .email-row
+                      .col-6
+                        %table.column{ cellspacing: 0, cellpadding: 0 }
+                          %tbody
+                            %tr
+                              %td.column-cell.text-center
+                                %p= t('notification_mailer.digest.new_followers_summary', count: @follows_since)
diff --git a/app/views/notification_mailer/favourite.html.haml b/app/views/notification_mailer/favourite.html.haml
new file mode 100644
index 000000000..f26b08b18
--- /dev/null
+++ b/app/views/notification_mailer/favourite.html.haml
@@ -0,0 +1,45 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_grade.svg'), alt:''
+
+                              %h1= t 'notification_mailer.favourite.title'
+                              %p.lead= t('notification_mailer.favourite.body', name: @account.acct)
+
+= render 'status', status: @status
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start.border-top
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to web_url("statuses/#{@status.id}") do
+                                    %span= t 'application_mailer.view_status'
diff --git a/app/views/notification_mailer/follow.html.haml b/app/views/notification_mailer/follow.html.haml
new file mode 100644
index 000000000..1290e2bc4
--- /dev/null
+++ b/app/views/notification_mailer/follow.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_person_add.svg'), alt: ''
+
+                              %h1= t 'notification_mailer.follow.title'
+                              %p.lead= t('notification_mailer.follow.body', name: @account.acct)
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to web_url("accounts/#{@account.id}") do
+                                    %span= t 'application_mailer.view_profile'
diff --git a/app/views/notification_mailer/follow_request.html.haml b/app/views/notification_mailer/follow_request.html.haml
new file mode 100644
index 000000000..41efeafaf
--- /dev/null
+++ b/app/views/notification_mailer/follow_request.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_person_add.svg'), alt: ''
+
+                              %h1= t 'notification_mailer.follow_request.title'
+                              %p.lead= t('notification_mailer.follow_request.body', name: @account.acct)
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to web_url("follow_requests") do
+                                    %span= t 'notification_mailer.follow_request.action'
diff --git a/app/views/notification_mailer/mention.html.haml b/app/views/notification_mailer/mention.html.haml
new file mode 100644
index 000000000..619c580ce
--- /dev/null
+++ b/app/views/notification_mailer/mention.html.haml
@@ -0,0 +1,45 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_reply.svg'), alt: ''
+
+                              %h1= t 'notification_mailer.mention.title'
+                              %p.lead= t('notification_mailer.mention.body', name: @status.account.acct)
+
+= render 'status', status: @status
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start.border-top
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to web_url("statuses/#{@status.id}") do
+                                    %span= t 'notification_mailer.mention.action'
diff --git a/app/views/notification_mailer/reblog.html.haml b/app/views/notification_mailer/reblog.html.haml
new file mode 100644
index 000000000..61c6ee6be
--- /dev/null
+++ b/app/views/notification_mailer/reblog.html.haml
@@ -0,0 +1,45 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_cached.svg'), alt: ''
+
+                              %h1= t 'notification_mailer.reblog.title'
+                              %p.lead= t('notification_mailer.reblog.body', name: @account.acct)
+
+= render 'status', status: @status
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start.border-top
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to web_url("statuses/#{@status.id}") do
+                                    %span= t 'application_mailer.view_status'
diff --git a/app/views/settings/flavours/show.html.haml b/app/views/settings/flavours/show.html.haml
new file mode 100644
index 000000000..a5126d9c5
--- /dev/null
+++ b/app/views/settings/flavours/show.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+  = t "flavours.#{@selected}.name", default: @selected
+
+= simple_form_for current_user, url: settings_flavour_path(@selected), html: { method: :put } do |f|
+  = render 'shared/error_messages', object: current_user
+
+  - Themes.instance.flavour(@selected)['screenshot'].each do |screen|
+    %img.flavour-screen{ src: asset_pack_path(screen) }
+
+  .flavour-description
+    = t "flavours.#{@selected}.description", default: ''
+
+  %hr/
+
+  - if Themes.instance.skins_for(@selected).length > 1
+    .fields-group
+      = f.input :setting_skin, collection: Themes.instance.skins_for(@selected), label_method: lambda { |skin| I18n.t("skins.#{@selected}.#{skin}", default: skin) }, wrapper: :with_label, include_blank: false
+
+  .actions
+    = f.button :button, t('generic.use_this'), type: :submit
diff --git a/app/views/settings/keyword_mutes/_fields.html.haml b/app/views/settings/keyword_mutes/_fields.html.haml
new file mode 100644
index 000000000..892676f18
--- /dev/null
+++ b/app/views/settings/keyword_mutes/_fields.html.haml
@@ -0,0 +1,11 @@
+.fields-group
+  = f.input :keyword
+  = f.check_box :whole_word
+  = f.label :whole_word, t('keyword_mutes.match_whole_word')
+
+.actions
+  - if f.object.persisted?
+    = f.button :button, t('generic.save_changes'), type: :submit
+    = link_to t('keyword_mutes.remove'), settings_keyword_mute_path(f.object), class: 'negative button', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
+  - else
+    = f.button :button, t('keyword_mutes.add_keyword'), type: :submit
diff --git a/app/views/settings/keyword_mutes/_keyword_mute.html.haml b/app/views/settings/keyword_mutes/_keyword_mute.html.haml
new file mode 100644
index 000000000..c45cc64fb
--- /dev/null
+++ b/app/views/settings/keyword_mutes/_keyword_mute.html.haml
@@ -0,0 +1,10 @@
+%tr
+  %td
+    = keyword_mute.keyword
+  %td
+    - if keyword_mute.whole_word
+      %i.fa.fa-check
+  %td
+    = table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute)
+  %td
+    = table_link_to 'times', t('keyword_mutes.remove'), settings_keyword_mute_path(keyword_mute), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/settings/keyword_mutes/edit.html.haml b/app/views/settings/keyword_mutes/edit.html.haml
new file mode 100644
index 000000000..af3949be2
--- /dev/null
+++ b/app/views/settings/keyword_mutes/edit.html.haml
@@ -0,0 +1,6 @@
+- content_for :page_title do
+  = t('keyword_mutes.edit_keyword')
+
+= simple_form_for @keyword_mute, url: settings_keyword_mute_path(@keyword_mute), as: :keyword_mute do |f|
+  = render 'shared/error_messages', object: @keyword_mute
+  = render 'fields', f: f
diff --git a/app/views/settings/keyword_mutes/index.html.haml b/app/views/settings/keyword_mutes/index.html.haml
new file mode 100644
index 000000000..9ef8d55bc
--- /dev/null
+++ b/app/views/settings/keyword_mutes/index.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_title do
+  = t('settings.keyword_mutes')
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('keyword_mutes.keyword')
+        %th= t('keyword_mutes.match_whole_word')
+        %th
+        %th
+      %tbody
+        = render partial: 'keyword_mute', collection: @keyword_mutes, as: :keyword_mute
+
+= paginate @keyword_mutes
+.simple_form
+  = link_to t('keyword_mutes.add_keyword'), new_settings_keyword_mute_path, class: 'button'
+  = link_to t('keyword_mutes.remove_all'), destroy_all_settings_keyword_mutes_path, class: 'button negative', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/settings/keyword_mutes/new.html.haml b/app/views/settings/keyword_mutes/new.html.haml
new file mode 100644
index 000000000..5c999c8d2
--- /dev/null
+++ b/app/views/settings/keyword_mutes/new.html.haml
@@ -0,0 +1,6 @@
+- content_for :page_title do
+  = t('keyword_mutes.add_keyword')
+
+= simple_form_for @keyword_mute, url: settings_keyword_mutes_path, as: :keyword_mute do |f|
+  = render 'shared/error_messages', object: @keyword_mute
+  = render 'fields', f: f
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 69e26a7be..d1459d93c 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -26,11 +26,9 @@
   %h4= t 'preferences.web'
 
   .fields-group
-    - if Themes.instance.names.size > 1
-      = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false
-
     = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label
     = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
+    = f.input :setting_favourite_modal, as: :boolean, wrapper: :with_label
     = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
 
   .fields-group
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 0fc9db2b9..be7bd0ba0 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -6,7 +6,7 @@
 
   .fields-group
     = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe
-    = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 160 - @account.note.size).html_safe
+    = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 500 - @account.note.size).html_safe
 
   .card.compact{ style: "background-image: url(#{@account.header.url(:original)})", data: { original_src: @account.header.url(:original) } }
     .avatar= image_tag @account.avatar.url(:original), data: { original_src: @account.avatar.url(:original) }
diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml
index 44b6f145f..4c0390c42 100644
--- a/app/views/shares/show.html.haml
+++ b/app/views/shares/show.html.haml
@@ -1,5 +1,4 @@
 - content_for :header_tags do
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
-  = javascript_pack_tag 'share', integrity: true, crossorigin: 'anonymous'
 
 #mastodon-compose{ data: { props: Oj.dump(default_props) } }
diff --git a/app/views/stream_entries/_content_spoiler.html.haml b/app/views/stream_entries/_content_spoiler.html.haml
index 798dfce67..fb42d3f57 100644
--- a/app/views/stream_entries/_content_spoiler.html.haml
+++ b/app/views/stream_entries/_content_spoiler.html.haml
@@ -1,4 +1,4 @@
-.media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' }
+.media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' }><
   .spoiler-button
     .icon-button.overlayed
       %i.fa.fa-fw.fa-eye
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 94e081c84..d88ec8280 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -22,9 +22,9 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380, detailed: true) }}
+      %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380, detailed: true) }}<
     - else
-      %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}
+      %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}<
   - elsif status.preview_cards.first
     %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}
 
diff --git a/app/views/stream_entries/_media.html.haml b/app/views/stream_entries/_media.html.haml
index 779f02c8d..32d024cf6 100644
--- a/app/views/stream_entries/_media.html.haml
+++ b/app/views/stream_entries/_media.html.haml
@@ -1,4 +1,4 @@
-.media-item
+.media-item><
   = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : '', target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
     - unless media.image?
       %video{ src: media.file.url(:original), autoplay: true, loop: true }/
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index b594c9da6..0b45ff308 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -18,11 +18,12 @@
       %p{ style: 'margin-bottom: 0' }<
         %span.p-summary> #{Formatter.instance.format_spoiler(status)}&nbsp;
         %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
-    .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true)
+    .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
+      = Formatter.instance.format(status, custom_emojify: true)
 
-  - unless status.media_attachments.empty?
-    - if status.media_attachments.first.video?
-      - video = status.media_attachments.first
-      %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 610, height: 343) }}
-    - else
-      %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}
+      - unless status.media_attachments.empty?
+        - if status.media_attachments.first.video?
+          - video = status.media_attachments.first
+          %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 610, height: 343) }}><
+        - else
+          %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}><
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index b52334a28..cf6671e67 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -1,5 +1,5 @@
 - content_for :page_title do
-  = t('statuses.title', name: display_name(@account), quote: truncate(@stream_entry.activity.text, length: 50, omission: '…'))
+  = t('statuses.title', name: display_name(@account), quote: truncate(@stream_entry.activity.spoiler_text.presence || @stream_entry.activity.text, length: 50, omission: '…'))
 
 - content_for :header_tags do
   - if @account.user&.setting_noindex
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index e4c16555d..03f19e20a 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -3,7 +3,6 @@
 
 - content_for :header_tags do
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
-  = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
   = render 'og'
 
 .landing-page.tag-page
diff --git a/app/views/user_mailer/confirmation_instructions.ar.html.erb b/app/views/user_mailer/confirmation_instructions.ar.html.erb
deleted file mode 100644
index 2b892b209..000000000
--- a/app/views/user_mailer/confirmation_instructions.ar.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>مرحبا <%= @resource.email %> !</p>
-
-<p>لقد قمت بإنشاء حساب على <%= @instance %>.</p>
-
-<p>لتأكيد التسجيل يرجى النقر على الرابط التالي : <br>
-<%= link_to 'تأكيد إنشاء الحساب', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>يرجى الإطلاع على <%= link_to 'شروط الإستخدام', terms_url %>.</p>
-
-<p>مع أجمل التحيات،<p>
-
-<p>فريق <%= @instance %> </p>
diff --git a/app/views/user_mailer/confirmation_instructions.ca.html.erb b/app/views/user_mailer/confirmation_instructions.ca.html.erb
deleted file mode 100644
index 3591e2433..000000000
--- a/app/views/user_mailer/confirmation_instructions.ca.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Benvingut <%= @resource.email %> !</p>
-
-<p>Has creat un compte a <%= @instance %>.</p>
-
-<p>Per confirmar la subscripció si us plau fes clic en el següent vincle : <br>
-<%= link_to 'Confirmar el meu compte', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>Si us plau, també fes un cop d'ull als nostres <%= link_to 'termes i condicions', terms_url %>.</p>
-
-<p>Sincerament,<p>
-
-<p>L'equip <%= @instance %> </p>
diff --git a/app/views/user_mailer/confirmation_instructions.en.html.erb b/app/views/user_mailer/confirmation_instructions.en.html.erb
deleted file mode 100644
index cd0d70377..000000000
--- a/app/views/user_mailer/confirmation_instructions.en.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Welcome <%= @resource.email %> !</p>
-
-<p>You just created an account on <%= @instance %>.</p>
-
-<p>To confirm your inscription, please click on the following link : <br>
-<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>If the above link did not work, copy and paste this URL into your address bar: <br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>Please also check out our <%= link_to 'terms and conditions', terms_url %>.</p>
-
-<p>Sincerely,<p>
-
-<p>The <%= @instance %> team</p>
diff --git a/app/views/user_mailer/confirmation_instructions.es.html.erb b/app/views/user_mailer/confirmation_instructions.es.html.erb
deleted file mode 100644
index 1d46a12c0..000000000
--- a/app/views/user_mailer/confirmation_instructions.es.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>¡Bienvenido, <%= @resource.email %>!</p>
-
-<p>Acabas de crear una cuenta en <%= @instance %>.</p>
-
-<p>Para confirmar tu registro, por favor ingresa al siguiente enlace:<br>
-<%= link_to 'Confirmar mi cuenta', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>También revisa nuestros <%= link_to 'términos y condiciones', terms_url %>.</p>
-
-<p>Sinceramente,<p>
-
-<p>El equipo de <%= @instance %></p>
\ No newline at end of file
diff --git a/app/views/user_mailer/confirmation_instructions.fa.html.erb b/app/views/user_mailer/confirmation_instructions.fa.html.erb
deleted file mode 100644
index 3e77e043b..000000000
--- a/app/views/user_mailer/confirmation_instructions.fa.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p dir="rtl">خوش آمدید <%= @resource.email %> !</p>
-
-<p dir="rtl">شما الان در <%= @instance %> حساب باز کردید.</p>
-
-<p dir="rtl">برای تأیید عضویت، لطفاً روی پیوند زیر کلیک کنید: <br>
-<%= link_to 'تأیید حساب', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p dir="rtl">لطفاً همچنین <%= link_to 'شرایط و مقررات استفادهٔ', terms_url %> ما را هم بخوانید.</p>
-
-<p dir="rtl">با احترام,<p>
-
-<p dir="rtl">گردانندگان سرور <%= @instance %></p>
\ No newline at end of file
diff --git a/app/views/user_mailer/confirmation_instructions.fi.html.erb b/app/views/user_mailer/confirmation_instructions.fi.html.erb
deleted file mode 100644
index 8b72722da..000000000
--- a/app/views/user_mailer/confirmation_instructions.fi.html.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<p>Tervetuloa <%= @resource.email %>!</p>
-
-<p>Voit vahvistaa Mastodon tilisi klikkaamalla alla olevaa linkkiä:</p>
-
-<p><%= link_to 'Varmista tilini', confirmation_url(@resource, confirmation_token: @token) %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.fr.html.erb b/app/views/user_mailer/confirmation_instructions.fr.html.erb
deleted file mode 100644
index fe3f0a010..000000000
--- a/app/views/user_mailer/confirmation_instructions.fr.html.erb
+++ /dev/null
@@ -1,14 +0,0 @@
-<p>Bonjour <%= @resource.email %>&nbsp;!<p>
-
-<p>Vous venez de vous créer un compte sur <%= @instance %> et nous vous en remercions :)</p>
-
-<p>Pour confirmer votre inscription, merci de cliquer sur le lien suivant : <br>
-<%= link_to 'Confirmer mon compte', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Après votre première connexion, vous pourrez accéder à la documentation de l’outil.</p>
-
-<p>Pensez également à jeter un œil à nos <%= link_to 'conditions d\'utilisation', terms_url %>.</p>
-
-<p>Amicalement,</p>
-
-<p>L’équipe <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.he.html.erb b/app/views/user_mailer/confirmation_instructions.he.html.erb
deleted file mode 100644
index 7933faa23..000000000
--- a/app/views/user_mailer/confirmation_instructions.he.html.erb
+++ /dev/null
@@ -1,14 +0,0 @@
-<div lang="he" dir="rtl">
-<p>שלום <%= @resource.email %> !</p>
-
-<p>הרגע יצרת חשבון בקהילה <%= @instance %>.</p>
-
-<p>כדי לוודא את הרשמתך, יש ללחוץ על הקישורית הבאה : <br>
-<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>יש לעבור גם על תנאי השימוש <%= link_to 'terms and conditions', terms_url %>.</p>
-
-<p>בתודה מראש,<p>
-
-<p>צוות ניהול <%= @instance %></p>
-</div>
\ No newline at end of file
diff --git a/app/views/user_mailer/confirmation_instructions.html.haml b/app/views/user_mailer/confirmation_instructions.html.haml
new file mode 100644
index 000000000..0f999bcbc
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.html.haml
@@ -0,0 +1,76 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_email.svg'), alt: ''
+
+                              %h1= t 'devise.mailer.confirmation_instructions.title'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'devise.mailer.confirmation_instructions.explanation', host: site_hostname
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to confirmation_url(@resource, confirmation_token: @token) do
+                                    %span= t 'devise.mailer.confirmation_instructions.action'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'devise.mailer.confirmation_instructions.extra_html', terms_path: about_more_url, policy_path: terms_url
diff --git a/app/views/user_mailer/confirmation_instructions.id.html.erb b/app/views/user_mailer/confirmation_instructions.id.html.erb
deleted file mode 100644
index 998267d76..000000000
--- a/app/views/user_mailer/confirmation_instructions.id.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Selamat datang <%= @resource.email %> !</p>
-
-<p>Anda barus saja membuat akun di <%= @instance %>.</p>
-
-<p>Untuk mengkonfirmasi, silakan klik link berikut ini : <br>
-<%= link_to 'Konfirmasikan akun saya', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>Silakan cek juga <%= link_to 'ketentuan layanan', terms_url %> kami.</p>
-
-<p>Hormat kami,<p>
-
-<p>Tim <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.it.html.erb b/app/views/user_mailer/confirmation_instructions.it.html.erb
deleted file mode 100644
index 2fa745476..000000000
--- a/app/views/user_mailer/confirmation_instructions.it.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Benvenuto <%= @resource.email %> !</p>
-
-<p>Hai appena creato un account su <%= @instance %>.</p>
-
-<p>Per confermare la tua iscrizione, fai clic sul seguente link: <br>
-<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>Per piacere leggi anche i nostri <%= link_to 'terms and conditions', terms_url %>.</p>
-
-<p>Sinceramente,<p>
-
-<p>The <%= @instance %> team</p>
diff --git a/app/views/user_mailer/confirmation_instructions.ja.html.erb b/app/views/user_mailer/confirmation_instructions.ja.html.erb
deleted file mode 100644
index 1232f94b4..000000000
--- a/app/views/user_mailer/confirmation_instructions.ja.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-<p>ようこそ<%= @resource.email %>さん</p>
-
-<p><%= @instance %>にアカウントが作成されました。</p>
-
-<p>以下のリンクをクリックしてMastodonアカウントのメールアドレスを確認してください。</p>
-
-<p><%= link_to 'メールアドレスの確認', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>また、インスタンスの<%= link_to '利用規約', terms_url %>についてもご確認ください。</p>
-
-<p><%= @instance %> チーム</p>
diff --git a/app/views/user_mailer/confirmation_instructions.ko.html.erb b/app/views/user_mailer/confirmation_instructions.ko.html.erb
deleted file mode 100644
index a749cd97b..000000000
--- a/app/views/user_mailer/confirmation_instructions.ko.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-<p>안녕하세요 <%= @resource.email %> 님!</p>
-
-<p><%= @instance %>에 새로 계정을 만들었습니다.</p>
-
-<p>아래 링크를 눌러 회원가입을 완료 하세요:<br>
-<%= link_to '계정 활성화', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>만약 위의 링크가 작동하지 않는다면 아래 URL을 복사하여 주소창에 붙여넣으세요</p>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p> <%= link_to '약관', terms_url %>도 확인 바랍니다.</p>
-
-<p><%= @instance %> 드림</p>
diff --git a/app/views/user_mailer/confirmation_instructions.nl.html.erb b/app/views/user_mailer/confirmation_instructions.nl.html.erb
deleted file mode 100644
index d65d08b6a..000000000
--- a/app/views/user_mailer/confirmation_instructions.nl.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Welkom <%= @resource.email %> !</p>
-
-<p>Je hebt zojuist een account aangemaakt op <%= @instance %>.</p>
-
-<p>Klik op de volgende link om jouw registratie te bevestigen :<br />
-<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %> </p>
-
-<p>Lees ook onze gebruikersvoorwaarden op <%= link_to 'terms and conditions', terms_url %>.</p>
-
-<p>Vriendelijke groet,</p>
-
-<p>De beheerder(s) van <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.no.html.erb b/app/views/user_mailer/confirmation_instructions.no.html.erb
deleted file mode 100644
index 366d9a4d3..000000000
--- a/app/views/user_mailer/confirmation_instructions.no.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Velkommen <%= @resource.email %> !</p>
-
-<p>Du har akkurat opprettet en konto på <%= @instance %>.</p>
-
-<p>For å bekrefte innskriving i manntallet vennligst trykk på følgende lenke : <br>
-<%= link_to 'Bekreft min bruker', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>Vennligst også les våre <%= link_to 'brukervilkår', terms_url %>.</p>
-
-<p>Med vennlig hilsen,<p>
-
-<p>Gjengen bak <%= @instance %> </p>
diff --git a/app/views/user_mailer/confirmation_instructions.oc.html.erb b/app/views/user_mailer/confirmation_instructions.oc.html.erb
deleted file mode 100644
index 5657e40d4..000000000
--- a/app/views/user_mailer/confirmation_instructions.oc.html.erb
+++ /dev/null
@@ -1,14 +0,0 @@
-<p>Bonjorn <%= @resource.email %> !<p>
-
-<p>Venètz de vos crear un compte sus <%= @instance %> e vos mercegem :)</p>
-
-<p>Per confirmar vòstra inscripcion, mercés de clicar sul ligam seguent : <br>
-<%= link_to 'Confirmar mon compte', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Aprèp vòstra primièra connexion, poiretz accedir a la documentacion de l’aisina.</p>
-
-<p>Pensatz tanben de gaitar nòstres <%= link_to 'tèrmes e condicions d\'utilizacion', terms_url %>.</p>
-
-<p>Amistosament,</p>
-
-<p>La còla <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.pl.html.erb b/app/views/user_mailer/confirmation_instructions.pl.html.erb
deleted file mode 100644
index 2285b5c6e..000000000
--- a/app/views/user_mailer/confirmation_instructions.pl.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Witaj, <%= @resource.email %>!</p>
-
-<p>Właśnie utworzyłeś konto na instancji <%= @instance %>.</p>
-
-<p>Aby aktywować konto, odwiedź poniższy link: <br>
-<%= link_to 'Potwierdź rejestrację', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>Pamiętaj przeczytać nasz <%= link_to 'regulamin i zasady użytkowania', terms_url %>.</p>
-
-<p>Z pozdrowieniami,<p>
-
-<p>Zespół <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb b/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb
deleted file mode 100644
index 0be16d994..000000000
--- a/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Boas vindas, <%= @resource.email %>!</p>
-
-<p>Você acabou de criar uma conta na instância <%= @instance %>.</p>
-
-<p>Para confirmar o seu cadastro, por favor clique no link a seguir: <br>
-<%= link_to 'Confirmar cadastro', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>Por favor, leia também os nossos <%= link_to 'termos de serviços', terms_url %>.</p>
-
-<p>Atenciosamente,<p>
-
-<p>A equipe da instância <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.ru.html.erb b/app/views/user_mailer/confirmation_instructions.ru.html.erb
deleted file mode 100644
index 2a755460e..000000000
--- a/app/views/user_mailer/confirmation_instructions.ru.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<p>Добро пожаловать, <%= @resource.email %> !</p>
-
-<p>Вы только что завели аккаунт на <%= @instance %>.</p>
-
-<p>Чтобы подтвердить создание аккаунта, пожалуйста, перейдите по этой ссылке: <br>
-<%= confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Также просим Вас прочитать об условиях использования аккаунта здесь: <%= terms_url %></p>
-
-<p>Искренне Ваши,</p>
-
-<p>Команда <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb b/app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb
deleted file mode 100644
index a16008250..000000000
--- a/app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Dobrodošao <%= @resource.email %> !</p>
-
-<p>Upravo ste napravili nalog na instanci <%= @instance %>.</p>
-
-<p>Da potvrdite Vašu registraciju, molimo Vas kliknite na sledeći link: <br>
-<%= link_to 'Potvrdi moj nalog', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Ako link iznad ne radi, kopirajte i nalepite ovu adresu u adresnu traku: <br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>Takođe pogledajte i <%= link_to 'pravila i uslove korišćenja', terms_url %>.</p>
-
-<p>S poštovanjem,<p>
-
-<p><%= @instance %> tim</p>
diff --git a/app/views/user_mailer/confirmation_instructions.sr.html.erb b/app/views/user_mailer/confirmation_instructions.sr.html.erb
deleted file mode 100644
index 09203cc9a..000000000
--- a/app/views/user_mailer/confirmation_instructions.sr.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Добродошао <%= @resource.email %> !</p>
-
-<p>Управо сте направили налог на инстанци <%= @instance %>.</p>
-
-<p>Да потврдите Вашу регистрацију, молимо Вас кликните на следећи линк: <br>
-<%= link_to 'Потврди мој налог', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Ако линк изнад не ради, копирајте и налепите ову адресу у адресну траку: <br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>Такође погледајте и <%= link_to 'правила и услове коришћења', terms_url %>.</p>
-
-<p>С поштовањем,<p>
-
-<p><%= @instance %> тим</p>
diff --git a/app/views/user_mailer/confirmation_instructions.sv.html.erb b/app/views/user_mailer/confirmation_instructions.sv.html.erb
deleted file mode 100644
index e0ad611a7..000000000
--- a/app/views/user_mailer/confirmation_instructions.sv.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Välkommen <%= @resource.email %> !</p>
-
-<p>Du har precis startat upp ett konto på <%= @instance %>.</p>
-
-<p>För att bekräfta din inskrift, vänligen klicka på följande länk : <br>
-<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Om länken ovan inte fungerar, kopiera och klistra in den här webbadressen i adressfältet: <br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>Vänligen kolla även våra <%= link_to 'terms and conditions', terms_url %>.</p>
-
-<p>Vänliga hälsningar,<p>
-
-<p>Teamet på <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.tr.html.erb b/app/views/user_mailer/confirmation_instructions.tr.html.erb
deleted file mode 100644
index 7879f0d29..000000000
--- a/app/views/user_mailer/confirmation_instructions.tr.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-
-<p> Aramıza hoşgeldin <%= @resource.email %> </p>
-
-<p>Bu sunucumuzda yeni bir hesap oluşturduğunu görüyoruz: <%= @instance %>.</p>
-
-<p>Siz olduğunuzu teyit edebilmemiz için lütfen aşağıdaki linke tıklaman yeterli: <br>
-
-<%= link_to 'Hesabımı doğrula', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>Ayrıca  <%= link_to 'kullanım şartları ve koşullarımızı', terms_url %> inceleyebilirsin.</p>
-
-<p>En içten dileklerimizle,<p>
-
-<p> <%= @instance %> ekibi</p>
-
diff --git a/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb b/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb
deleted file mode 100644
index 8a676498a..000000000
--- a/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-<p><%= @resource.email %>,你好呀!</p>
-
-<p>你刚刚在 <%= @instance %> 创建了一个帐户呢。</p>
-
-<p>点击下面的链接来完成注册啦:<br>
-<%= link_to '确认帐户', confirmation_url(@resource, confirmation_token: @token) %>
-
-<p>上面的链接按不动?把下面的链接复制到地址栏再试试:<br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>记得读一读我们的<%= link_to '使用条款', terms_url %>哦。</p>
-
-<p>来自 <%= @instance %> 管理团队</p>
diff --git a/app/views/user_mailer/email_changed.en.html.erb b/app/views/user_mailer/email_changed.en.html.erb
deleted file mode 100644
index c10680086..000000000
--- a/app/views/user_mailer/email_changed.en.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Hello <%= @resource.email %>!</p>
-
-<% if @resource&.unconfirmed_email? %>
-  <p>We're contacting you to notify you that the email you use on <%= @instance %> is being changed to <%= @resource.unconfirmed_email %>.</p>
-<% else %>
-  <p>We're contacting you to notify you that the email you use on <%= @instance %> has been changed to <%= @resource.email %>.</p>
-<% end %>
-
-<p>
-  If you did not change your email, it is likely that someone has gained access to your account. Please change your password immediately or contact the instance admin if you're locked out of your account.
-</p>
-
-<p>Sincerely,<p>
-
-<p>The <%= @instance %> team</p>
diff --git a/app/views/user_mailer/email_changed.html.haml b/app/views/user_mailer/email_changed.html.haml
new file mode 100644
index 000000000..45dc06650
--- /dev/null
+++ b/app/views/user_mailer/email_changed.html.haml
@@ -0,0 +1,58 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_email.svg'), alt: ''
+
+                              %h1= t 'devise.mailer.email_changed.title'
+                              %p.lead= t 'devise.mailer.email_changed.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.input-cell
+                          %table.input{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td= @resource.unconfirmed_email
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %p= t 'devise.mailer.email_changed.extra'
diff --git a/app/views/user_mailer/email_changed.ja.html.erb b/app/views/user_mailer/email_changed.ja.html.erb
deleted file mode 100644
index c66f409c6..000000000
--- a/app/views/user_mailer/email_changed.ja.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-<p>こんにちは<%= @resource.email %>さん</p>
-
-<% if @resource&.unconfirmed_email? %>
-  <p><%= @instance %>で使っているメールアドレスが<%= @resource.unconfirmed_email %>に変更されようとしています。</p>
-<% else %>
-  <p><%= @instance %>で使っているメールアドレスが<%= @resource.email %>に変更されました。</p>
-<% end %>
-
-<p>
-  メールアドレスを変更した覚えがない場合、誰かがあなたのアカウントにアクセスしたおそれがあります。すぐにパスワードを変更するか、アカウントにアクセスできない場合はインスタンスの管理者に連絡してください。
-</p>
-
-<p><%= @instance %>チームより</p>
diff --git a/app/views/user_mailer/email_changed.oc.html.erb b/app/views/user_mailer/email_changed.oc.html.erb
deleted file mode 100644
index 0f4c891dc..000000000
--- a/app/views/user_mailer/email_changed.oc.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Bonjorn <%= @resource.email %> !</p>
-
-<% if @resource&.unconfirmed_email? %>
-  <p>Vos contactem per vos senhalar que l’adreça qu’utilizatz per <%= @instance %> es cambiada per aquesta d’aquí <%= @resource.unconfirmed_email %>.</p>
-<% else %>
-  <p>Vos contactem per vos senhalar que l’adreça qu’utilizatz per <%= @instance %> es cambiada per aquesta d’aquí <%= @resource.email %>.</p>
-<% end %>
-
-<p>
-  S’avètz pas demandat aqueste cambiament d’adreça, poiriá arribar que qualqu’un mai aguèsse agut accès a vòstre compte. Mercés de cambiar sulpic vòstre senhal o de contactar vòstre administrator d’instància se l’accès a vòstre compte vos es barrat.
-</p>
-
-<p>Amistosament,<p>
-
-<p>La còla <%= @instance %></p>
diff --git a/app/views/user_mailer/email_changed.pl.html.erb b/app/views/user_mailer/email_changed.pl.html.erb
deleted file mode 100644
index 9ed122b0f..000000000
--- a/app/views/user_mailer/email_changed.pl.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Witaj, <%= @resource.email %>!</p>
-
-<% if @resource&.unconfirmed_email? %>
-  <p>Informujemy, że e-mail używany przez Ciebie na <%= @instance %> został zmieniony na <%= @resource.unconfirmed_email %>.</p>
-<% else %>
-  <p>Informujemy, że e-mail używany przez Ciebie na <%= @instance %> został zmieniony na <%= @resource.email %>.</p>
-<% end %>
-
-<p>
-  Jeżeli to nie Ty, prawdopodobnie ktoś uzyskał dostęp do Twojego konta. Zalecana jest natychmiastowa zmiana hasła lub skontaktowanie się z administratorem, jeżeli nie masz dostępu do swojego konta.
-</p>
-
-<p>Z pozdrowieniami,<p>
-
-<p>Zespół <%= @instance %></p>
diff --git a/app/views/user_mailer/email_changed.zh-cn.text.erb b/app/views/user_mailer/email_changed.zh-cn.text.erb
new file mode 100644
index 000000000..d59ac58c4
--- /dev/null
+++ b/app/views/user_mailer/email_changed.zh-cn.text.erb
@@ -0,0 +1,11 @@
+<%= @resource.email %>,你好呀!
+
+<% if @resource&.unconfirmed_email? %>
+我们发送这封邮件是为了提醒你,你在 <%= @instance %> 上使用的电子邮件地址即将变更为 <%= @resource.unconfirmed_email %>。
+<% else %>
+我们发送这封邮件是为了提醒你,你在 <%= @instance %> 上使用的电子邮件地址已经变更为 <%= @resource.unconfirmed_email %>。
+<% end %>
+
+如果你并没有请求更改你的电子邮件地址,则他人很有可能已经入侵你的帐户。请立即更改你的密码;如果你已经无法访问你的帐户,请联系实例的管理员请求协助。
+
+来自 <%= @instance %> 管理团队
diff --git a/app/views/user_mailer/password_change.ar.html.erb b/app/views/user_mailer/password_change.ar.html.erb
deleted file mode 100644
index 8e7bd2f08..000000000
--- a/app/views/user_mailer/password_change.ar.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>صباح الخير <%= @resource.email %>&nbsp;!</p>
-
-<p>نود أن نخبرك أنه قد تم تعديل كلمة مرور ماستدون الخاصة بك بنجاح.</p>
diff --git a/app/views/user_mailer/password_change.ca.html.erb b/app/views/user_mailer/password_change.ca.html.erb
deleted file mode 100644
index e10c21e1c..000000000
--- a/app/views/user_mailer/password_change.ca.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Hola <%= @resource.email %>!</p>
-
-<p>Aquest correu es per a notificar-te que la teva contrasenya a mastodont.cat ha canviat.</p>
diff --git a/app/views/user_mailer/password_change.en.html.erb b/app/views/user_mailer/password_change.en.html.erb
deleted file mode 100644
index 414e05a29..000000000
--- a/app/views/user_mailer/password_change.en.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Hello <%= @resource.email %>!</p>
-
-<p>We're contacting you to notify you that your password on <%= @instance %> has been changed.</p>
diff --git a/app/views/user_mailer/password_change.es.html.erb b/app/views/user_mailer/password_change.es.html.erb
deleted file mode 100644
index 0a9eb4c4c..000000000
--- a/app/views/user_mailer/password_change.es.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>¡Hola, <%= @resource.email %>!</p>
-
-<p>Te contactamos para notificarte que tu contraseña en <%= @instance %> ha sido modificada.</p>
\ No newline at end of file
diff --git a/app/views/user_mailer/password_change.fa.html.erb b/app/views/user_mailer/password_change.fa.html.erb
deleted file mode 100644
index 8167ae160..000000000
--- a/app/views/user_mailer/password_change.fa.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p dir="rtl">سلام <%= @resource.email %>!</p>
-
-<p dir="rtl">این پیغام برای این است که به شما بگوییم رمز شما در ماستدون تغییر کرده است.</p>
diff --git a/app/views/user_mailer/password_change.fi.html.erb b/app/views/user_mailer/password_change.fi.html.erb
deleted file mode 100644
index c56b96593..000000000
--- a/app/views/user_mailer/password_change.fi.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Hei <%= @resource.email %>!</p>
-
-<p>Lähetämme tämän viestin ilmoittaaksemme että salasanasi on vaihdettu.</p>
diff --git a/app/views/user_mailer/password_change.fr.html.erb b/app/views/user_mailer/password_change.fr.html.erb
deleted file mode 100644
index cb8a261fe..000000000
--- a/app/views/user_mailer/password_change.fr.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Bonjour <%= @resource.email %>&nbsp;!</p>
-
-<p>Nous vous contactons pour vous informer que votre mot de passe sur Mastodon a bien été modifié.</p>
diff --git a/app/views/user_mailer/password_change.he.html.erb b/app/views/user_mailer/password_change.he.html.erb
deleted file mode 100644
index a356edf58..000000000
--- a/app/views/user_mailer/password_change.he.html.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-<div lang="he" dir="rtl">
-<p>שלום <%= @resource.email %>!</p>
-
-<p>רצינו להודיע לך שסיסמתך במסטודון אצלנו הוחלפה.</p>
diff --git a/app/views/user_mailer/password_change.html.haml b/app/views/user_mailer/password_change.html.haml
new file mode 100644
index 000000000..2e9377dff
--- /dev/null
+++ b/app/views/user_mailer/password_change.html.haml
@@ -0,0 +1,40 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_lock_open.svg'), alt: ''
+
+                              %h1= t 'devise.mailer.password_change.title'
+                              %p.lead= t 'devise.mailer.password_change.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %p= t 'devise.mailer.password_change.extra'
diff --git a/app/views/user_mailer/password_change.id.html.erb b/app/views/user_mailer/password_change.id.html.erb
deleted file mode 100644
index 469c98c5d..000000000
--- a/app/views/user_mailer/password_change.id.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Hai <%= @resource.email %>!</p>
-
-<p>Kami menghubungi anda untuk memberitahu bahwa kata sandi anda di Mastodon telah diubah.</p>
diff --git a/app/views/user_mailer/password_change.it.html.erb b/app/views/user_mailer/password_change.it.html.erb
deleted file mode 100644
index b4ca99769..000000000
--- a/app/views/user_mailer/password_change.it.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Ciao <%= @resource.email %>!</p>
-
-<p>Ti stiamo contattando per avvisarti che la tua password su Mastodon è stata cambiata.</p>
diff --git a/app/views/user_mailer/password_change.ja.html.erb b/app/views/user_mailer/password_change.ja.html.erb
deleted file mode 100644
index 3aa83f187..000000000
--- a/app/views/user_mailer/password_change.ja.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>こんにちは<%= @resource.email %>さん</p>
-
-<p>Mastodonアカウントのパスワードが変更されました。</p>
diff --git a/app/views/user_mailer/password_change.nl.html.erb b/app/views/user_mailer/password_change.nl.html.erb
deleted file mode 100644
index 3ffc547cd..000000000
--- a/app/views/user_mailer/password_change.nl.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Hallo <%= @resource.email %>!</p>
-
-<p>Hierbij laten we jou weten dat jouw wachtwoord op Mastodon is veranderd.</p>
diff --git a/app/views/user_mailer/password_change.no.html.erb b/app/views/user_mailer/password_change.no.html.erb
deleted file mode 100644
index cd0a28f82..000000000
--- a/app/views/user_mailer/password_change.no.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Hei <%= @resource.email %>!</p>
-
-<p>Ditt Mastodon-passord har blitt endret.</p>
diff --git a/app/views/user_mailer/password_change.oc.html.erb b/app/views/user_mailer/password_change.oc.html.erb
deleted file mode 100644
index 094c221a8..000000000
--- a/app/views/user_mailer/password_change.oc.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Bonjorn <%= @resource.email %> !</p>
-
-<p>Vos contactem per vos avisar qu’avèm ben cambiat vòstre senhal Mastodon.</p>
diff --git a/app/views/user_mailer/password_change.pl.html.erb b/app/views/user_mailer/password_change.pl.html.erb
deleted file mode 100644
index a7cb15a05..000000000
--- a/app/views/user_mailer/password_change.pl.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Witaj, <%= @resource.email %>!</p>
-
-<p>Informujemy, że ostatnio zmieniono Twoje hasło na <%= @instance %>.</p>
diff --git a/app/views/user_mailer/password_change.pt-BR.html.erb b/app/views/user_mailer/password_change.pt-BR.html.erb
deleted file mode 100644
index a1aaa265e..000000000
--- a/app/views/user_mailer/password_change.pt-BR.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Olá, <%= @resource.email %>!</p>
-
-<p>Estamos te contatando para te notificar que a sua senha na instância <%= @instance %> foi modificada.</p>
diff --git a/app/views/user_mailer/password_change.ru.html.erb b/app/views/user_mailer/password_change.ru.html.erb
deleted file mode 100644
index 1c98e364e..000000000
--- a/app/views/user_mailer/password_change.ru.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Здравствуйте, <%= @resource.email %>!</p>
-
-<p>Мы пишем, чтобы оповестить Вас о смене пароля на Вашем аккаунте Mastodon.</p>
diff --git a/app/views/user_mailer/password_change.sr-Latn.html.erb b/app/views/user_mailer/password_change.sr-Latn.html.erb
deleted file mode 100644
index ab4e23bdf..000000000
--- a/app/views/user_mailer/password_change.sr-Latn.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Zdravo <%= @resource.email %>!</p>
-
-<p>Želimo samo da Vas obavestimo da je Vaša lozinka na Mastodont instanci <%= @instance %> promenjena.</p>
diff --git a/app/views/user_mailer/password_change.sr.html.erb b/app/views/user_mailer/password_change.sr.html.erb
deleted file mode 100644
index 4bb61b74f..000000000
--- a/app/views/user_mailer/password_change.sr.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Здраво <%= @resource.email %>!</p>
-
-<p>Желимо само да Вас обавестимо да је Ваша лозинка на Мастодонт инстанци <%= @instance %> промењена.</p>
diff --git a/app/views/user_mailer/password_change.sv.html.erb b/app/views/user_mailer/password_change.sv.html.erb
deleted file mode 100644
index f6168c638..000000000
--- a/app/views/user_mailer/password_change.sv.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>Hej <%= @resource.email %>!</p>
-
-<p>Vi kontaktar dig för att meddela dig att ditt lösenord på <%= @instance %> har blivit ändrat.</p>
diff --git a/app/views/user_mailer/password_change.th.html.erb b/app/views/user_mailer/password_change.th.html.erb
deleted file mode 100644
index 948b1508a..000000000
--- a/app/views/user_mailer/password_change.th.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p>สวัสดี <%= @resource.email %>!</p>
-
-<p>เราติดต่อเข้ามาเพื่อจะแจ้งให้คุณทราบว่าพาสเวิร์ด Mastodon เปลี่ยนแล้ว </p>
diff --git a/app/views/user_mailer/password_change.tr.html.erb b/app/views/user_mailer/password_change.tr.html.erb
deleted file mode 100644
index 40f55c484..000000000
--- a/app/views/user_mailer/password_change.tr.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Merhaba <%= @resource.email %>!</p>
-
-<p> <%= @instance %>'deki parolanızın değiştirildiğini hatırlatmak isteriz.</p>
-
-<p>En içten dileklerimizle,<p>
-
-<p> <%= @instance %> ekibi</p>
-
diff --git a/app/views/user_mailer/password_change.zh-cn.html.erb b/app/views/user_mailer/password_change.zh-cn.html.erb
deleted file mode 100644
index 64e8b6b2f..000000000
--- a/app/views/user_mailer/password_change.zh-cn.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<p><%= @resource.email %>,你好呀!</p>
-
-<p>提醒一下,你在 <%= @instance %> 上的密码被更改了哦。</p>
diff --git a/app/views/user_mailer/reconfirmation_instructions.en.html.erb b/app/views/user_mailer/reconfirmation_instructions.en.html.erb
deleted file mode 100644
index 31866a3c8..000000000
--- a/app/views/user_mailer/reconfirmation_instructions.en.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Hello <%= @resource.unconfirmed_email %>!</p>
-
-<p>You requested a change to the email address you use on <%= @instance %>.</p>
-
-<p>To confirm your new email, please click on the following link:<br>
-<%= link_to 'Confirm my email address', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>If the above link did not work, copy and paste this URL into your address bar: <br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>Please also check out our <%= link_to 'terms and conditions', terms_url %>.</p>
-
-<p>Sincerely,<p>
-
-<p>The <%= @instance %> team</p>
diff --git a/app/views/user_mailer/reconfirmation_instructions.html.haml b/app/views/user_mailer/reconfirmation_instructions.html.haml
new file mode 100644
index 000000000..3ae226093
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.html.haml
@@ -0,0 +1,60 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_email.svg'), alt: ''
+
+                              %h1= t 'devise.mailer.reconfirmation_instructions.title'
+                              %p.lead= t 'devise.mailer.reconfirmation_instructions.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to confirmation_url(@resource, confirmation_token: @token) do
+                                    %span= t 'devise.mailer.confirmation_instructions.action'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %p= t 'devise.mailer.reconfirmation_instructions.extra'
diff --git a/app/views/user_mailer/reconfirmation_instructions.ja.html.erb b/app/views/user_mailer/reconfirmation_instructions.ja.html.erb
deleted file mode 100644
index caa53032a..000000000
--- a/app/views/user_mailer/reconfirmation_instructions.ja.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-<p>こんにちは<%= @resource.unconfirmed_email %>さん</p>
-
-<p><%= @instance %>で使っているメールアドレスの変更をあなたがリクエストしました。</p>
-
-<p>新しいメールアドレスを確認するには次のリンクをクリックしてください:<br>
-<%= link_to 'わたしのメールアドレスを確認する', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>上記のリンクがうまくいかなかった場合はこのURLをコピーしてアドレスバーに貼り付けてください:<br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>また<%= link_to '利用規約', terms_url %>もご確認ください。</p>
-
-<p><%= @instance %>チームより</p>
diff --git a/app/views/user_mailer/reconfirmation_instructions.oc.html.erb b/app/views/user_mailer/reconfirmation_instructions.oc.html.erb
deleted file mode 100644
index d5404e49c..000000000
--- a/app/views/user_mailer/reconfirmation_instructions.oc.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Bonjorn <%= @resource.unconfirmed_email %> !</p>
-
-<p>Avètz demandat a cambiar vòstra adreça de corrièl qu’utilizatz per <%= @instance %>.</p>
-
-<p>Per confirmar vòstra novèla adreça, mercés de clicar lo ligam seguent :<br>
-<%= link_to 'Confirmar mon adreça', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Se lo ligam al dessús fonciona pas, copiatz e pegatz aquesta URL a la barra d’adreça :<br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>Mercés de gaitar tanben nòstres <%= link_to 'terms and conditions', terms_url %>.</p>
-
-<p>Amistosament,<p>
-
-<p>La còla <%= @instance %></p>
diff --git a/app/views/user_mailer/reconfirmation_instructions.pl.html.erb b/app/views/user_mailer/reconfirmation_instructions.pl.html.erb
deleted file mode 100644
index 57cdc42e1..000000000
--- a/app/views/user_mailer/reconfirmation_instructions.pl.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<p>Witaj, <%= @resource.unconfirmed_email %>!</p>
-
-<p>Dokonano próby zmiany adresu e-mail, którego używasz na <%= @instance %>.</p>
-
-<p>Aby potwierdzić posiadanie tego adresu e-mail, kliknij na poniższy odnośnik:<br>
-<%= link_to 'Potwierdź mój adres e-mail', confirmation_url(@resource, confirmation_token: @token) %></p>
-
-<p>Jeżeli ten odnośnik nie działa, wklej następujący adres w pasek adresu Twojej przeglądarki: <br>
-<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
-
-<p>Pamiętaj o przeczytaniu naszych <%= link_to 'zasad użytkowania', terms_url %>.</p>
-
-<p>Z pozdrowieniami,<p>
-
-<p>Zespół <%= @instance %></p>
diff --git a/app/views/user_mailer/reconfirmation_instructions.zh-cn.text.erb b/app/views/user_mailer/reconfirmation_instructions.zh-cn.text.erb
new file mode 100644
index 000000000..977d78137
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.zh-cn.text.erb
@@ -0,0 +1,10 @@
+<%= @resource.email %>,你好呀!
+
+你正在更改你在 <%= @instance %> 使用的电子邮件地址。
+
+点击下面的链接以确认操作:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+记得读一读我们的使用条款哦:<%= terms_url %>
+
+来自 <%= @instance %> 管理团队
diff --git a/app/views/user_mailer/reset_password_instructions.ar.html.erb b/app/views/user_mailer/reset_password_instructions.ar.html.erb
deleted file mode 100644
index d9d5520ec..000000000
--- a/app/views/user_mailer/reset_password_instructions.ar.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>صباح الخير <%= @resource.email %>&nbsp;!</p>
-
-<p>لقد طلب أحدهم رابط تعديل كلمة مرور ماستدون الخاصة بك. يمكنك المتابعة و مواصلة التعديل على الرابط التالي.</p>
-
-<p><%= link_to 'تعديل الكلمة السرية', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>يمكنك تجاهل هذه الرسالة إن لم تكن من طلب ذلك.</p>
-<p>لن يتم تعديل كلمة المرور الخاصة بك و ستبقى نفسها إلا إذا قمت بالضغط على الرابط أعلاه.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.ca.html.erb b/app/views/user_mailer/reset_password_instructions.ca.html.erb
deleted file mode 100644
index 6f76c441d..000000000
--- a/app/views/user_mailer/reset_password_instructions.ca.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Hola <%= @resource.email %>!</p>
-
-<p>Algú ha sol·licitat un enllaç per canviar la contrasenya a mastodont.cat. Això es pot fer a través del següent enllaç.</p>
-
-<p><%= link_to 'Canviar la contrasenya', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Si no has sol·licitat aquest canvi, si us plau, ignora aquest correu.</p>
-<p>La teva contrasenya no canviarà fins que accedeix a l'enllaç de dalt per crear-ne una de nova.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.en.html.erb b/app/views/user_mailer/reset_password_instructions.en.html.erb
deleted file mode 100644
index cfb129e22..000000000
--- a/app/views/user_mailer/reset_password_instructions.en.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Hello <%= @resource.email %>!</p>
-
-<p>Someone has requested a link to change your password on <%= @instance %>. You can do this through the link below.</p>
-
-<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>If you didn't request this, please ignore this email.</p>
-<p>Your password won't change until you access the link above and create a new one.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.es.html.erb b/app/views/user_mailer/reset_password_instructions.es.html.erb
deleted file mode 100644
index 4eeb6601d..000000000
--- a/app/views/user_mailer/reset_password_instructions.es.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>¡Hola, <%= @resource.email %>!</p>
-
-<p>Alguien pidió un enlace para cambiar tu contraseña en <%= @instance %>. Puedes hacer esto con el siguiente enlace.</p>
-
-<p><%= link_to 'Cambiar mi contraseña', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Si no fuiste tú, por favor ignora este mensaje.</p>
-<p>Tu contraseña no cambiará hasta que ingreses al enlace y crees una nueva.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.fa.html.erb b/app/views/user_mailer/reset_password_instructions.fa.html.erb
deleted file mode 100644
index 835e81311..000000000
--- a/app/views/user_mailer/reset_password_instructions.fa.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p dir="rtl">سلام <%= @resource.email %>!</p>
-
-<p dir="rtl">یک نفر درخواست کرده تا رمز شما در ماستدون عوض شود. برای این کار پیوند زیر را به‌کار ببرید.</p>
-
-<p dir="rtl"><%= link_to 'تغییر رمز', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p dir="rtl">اگر شما چنین درخواستی نداده‌اید، لطفاً این ایمیل را نادیده بگیرید.</p>
-<p dir="rtl">تا وقتی که شما پیوند بالا را نبینید و رمز تازه‌ای نسازید، رمز شما عوض نخواهد شد.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.fi.html.erb b/app/views/user_mailer/reset_password_instructions.fi.html.erb
deleted file mode 100644
index 53be0b62b..000000000
--- a/app/views/user_mailer/reset_password_instructions.fi.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Hei <%= @resource.email %>!</p>
-
-<p>Joku on pyytänyt salasanvaihto Mastodonissa. Voit tehdä sen allaolevassa linkissä.</p>
-
-<p><%= link_to 'Vaihda salasanani', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Jos et pyytänyt vaihtoa, poista tämä viesti.</p>
-<p>Salasanaasi ei vaihdeta ennen kuin menet ylläolevaan linkkiin ja luot uuden.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.fr.html.erb b/app/views/user_mailer/reset_password_instructions.fr.html.erb
deleted file mode 100644
index db55c5884..000000000
--- a/app/views/user_mailer/reset_password_instructions.fr.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Bonjour <%= @resource.email %>&nbsp;!</p>
-
-<p>Quelqu’un a demandé à réinitialiser votre mot de passe sur Mastodon. Vous pouvez effectuer la réinitialisation en cliquant sur le lien ci-dessous.</p>
-
-<p><%= link_to 'Modifier mon mot de passe', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Si vous n’êtes pas à l’origine de cette demande, vous pouvez ignorer ce message.</p>
-<p>Votre mot de passe ne sera pas modifié tant que vous n’accéderez pas au lien ci-dessus et n’en choisirez pas un nouveau.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.he.html.erb b/app/views/user_mailer/reset_password_instructions.he.html.erb
deleted file mode 100644
index 5d0930839..000000000
--- a/app/views/user_mailer/reset_password_instructions.he.html.erb
+++ /dev/null
@@ -1,10 +0,0 @@
-<div lang="he" dir="rtl">
-<p>שלום <%= @resource.email %>!</p>
-
-<p>מישהו ביקש לינק להחלפת סיסמתך במסטודון. באפשרותך לעשות זאת ע"י בלחיצה על הקישורית שבהמשך.</p>
-
-<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>אם בקשה זו לא הגיעה ממך, אפשר להתעלם מההודעה.</p>
-<p>סיסמתך לא תוחלף לפני שהקישורית תיפתח בדפדפן וסיסמא חדשה תוכנס.</p>
-</div>
\ No newline at end of file
diff --git a/app/views/user_mailer/reset_password_instructions.html.haml b/app/views/user_mailer/reset_password_instructions.html.haml
new file mode 100644
index 000000000..c0e6775d4
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.html.haml
@@ -0,0 +1,60 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_lock_open.svg'), alt: ''
+
+                              %h1= t 'devise.mailer.reset_password_instructions.title'
+                              %p.lead= t 'devise.mailer.reset_password_instructions.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_password_url(@resource, reset_password_token: @token) do
+                                    %span= t 'devise.mailer.reset_password_instructions.action'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %p= t 'devise.mailer.reset_password_instructions.extra'
diff --git a/app/views/user_mailer/reset_password_instructions.id.html.erb b/app/views/user_mailer/reset_password_instructions.id.html.erb
deleted file mode 100644
index f07e175ae..000000000
--- a/app/views/user_mailer/reset_password_instructions.id.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Hai <%= @resource.email %>!</p>
-
-<p>Seseorang telah melakukan permintaan link untuk merubah kata sandi anda di Mastodon. Anda bisa melakukan ini melalui link dibawah ini.</p>
-
-<p><%= link_to 'Ubah kata sandi saya', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Jika anda tidak memintanya, mohon abaikan email ini.</p>
-<p>Password anda tidak akan diubah kecuali anda mengakses link di atas dan menggantinya.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.it.html.erb b/app/views/user_mailer/reset_password_instructions.it.html.erb
deleted file mode 100644
index cc411dad3..000000000
--- a/app/views/user_mailer/reset_password_instructions.it.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Ciao <%= @resource.email %>!</p>
-
-<p>Qualcuno ha richiesto un link per cambiare la tua password su Mastodon. Lo puoi fare tramite il link qui sotto.</p>
-
-<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Se non l'hai richiesto tu, ignora questa email.</p>
-<p>La tua password non verrà cambiata finché non accedi al link qui sopra e ne crei una nuova.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.ja.html.erb b/app/views/user_mailer/reset_password_instructions.ja.html.erb
deleted file mode 100644
index d0d7203f4..000000000
--- a/app/views/user_mailer/reset_password_instructions.ja.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>こんにちは<%= @resource.email %>さん</p>
-
-<p>Mastodonアカウントのパスワードの変更がリクエストされました。以下のリンクをクリックして操作を完了できます。</p>
-
-<p><%= link_to 'パスワードを変更', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>このメールに身に覚えのない場合は無視してください。</p>
-<p>上記のリンクにアクセスし、変更をしない限りパスワードは変更されません。</p>
diff --git a/app/views/user_mailer/reset_password_instructions.nl.html.erb b/app/views/user_mailer/reset_password_instructions.nl.html.erb
deleted file mode 100644
index ca1748145..000000000
--- a/app/views/user_mailer/reset_password_instructions.nl.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-<p>Hallo <%= @resource.email %>!</p>
-
-<p>Er heeft iemand een nieuw wachtwoord aangevraagd voor Mastodon. Je kan op de link hieronder klikken om jouw wach
-twoord te veranderen.</p>
-
-<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Je kan deze e-mail negeren wanneer jij dit niet hebt aangevraagd.</p>
-<p>Jouw wachtwoord blijft onveranderd wanneer je niet op bovenstaande link klikt.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.no.html.erb b/app/views/user_mailer/reset_password_instructions.no.html.erb
deleted file mode 100644
index 398e95e4e..000000000
--- a/app/views/user_mailer/reset_password_instructions.no.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-
-<p>Hei <%= @resource.email %>!</p>
-
-<p>Noen har forespurt en lenke til å bytte passord på din Mastodon-bruker. Du kan gjøre det ved å følge lenken under.</p>
-
-<p><%= link_to 'Endre mitt passord', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Hvis du ikke vil endre ditt passord kan du ignorere denne epost.</p>
-<p>Passordet ditt blir ikke endret før du følger lenken over og endrer det.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.oc.html.erb b/app/views/user_mailer/reset_password_instructions.oc.html.erb
deleted file mode 100644
index 92e4b8f8b..000000000
--- a/app/views/user_mailer/reset_password_instructions.oc.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Bonjorn <%= @resource.email %> !</p>
-
-<p>Qualqu’un a demandat la reïnicializacion de vòstre senhal per Mastodon. Podètz realizar la reïnicializacion en clicant sul ligam çai-jos.</p>
-
-<p><%= link_to 'Modificar mon senhal', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>S’avètz pas res demandat, fasquètz pas cas a aqueste corrièl.</p>
-<p>Vòstre senhal cambiarà pas se clicatz pas sul ligam e que ne causissètz pas un novèl.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.pl.html.erb b/app/views/user_mailer/reset_password_instructions.pl.html.erb
deleted file mode 100644
index 2a9913a1d..000000000
--- a/app/views/user_mailer/reset_password_instructions.pl.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-<p>Witaj, <%= @resource.email %>!</p>
-
-<p>Ktoś próbował zmienić Twoje hasło na <%= @instance %>. Możesz zrobić to klikając w 
-poniższy link.</p>
-
-<p><%= link_to 'Zmień moje hasło', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Jeżeli to nie Ty, zignoruj ten e-mail.</p>
-<p>Twoje hasło nie zostanie zmienione, dopóki nie użyjesz linku z podobnej wiadomości.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb b/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb
deleted file mode 100644
index 9b21aae92..000000000
--- a/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Olá, <%= @resource.email %>!</p>
-
-<p>Alguém solicitou um link para mudar a sua senha na instância <%= @instance %>. Você pode fazer isso através do link abaixo:</p>
-
-<p><%= link_to 'Mudar a minha senha', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Se você não solicitou isso, por favor ignore este e-mail.</p>
-<p>A senha não será modificada até que você acesse o link acima e crie uma nova.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.ru.html.erb b/app/views/user_mailer/reset_password_instructions.ru.html.erb
deleted file mode 100644
index a24c9cc6c..000000000
--- a/app/views/user_mailer/reset_password_instructions.ru.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Здравствуйте, <%= @resource.email %>!</p>
-
-<p>Кто-то отправил запрос о сбросе пароля для Вашего аккаунта Mastodon. Вы можете использовать для этого следующую ссылку:</p>
-
-<p><%= edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Если Вы не запрашивали сброс пароля, пожалуйста, проигнорируйте это сообщение.</p>
-<p>Ваш пароль не будет изменен, пока вы не проследуете по вышеприведенной ссылке и не создадите новый.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb b/app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb
deleted file mode 100644
index 7dede16b2..000000000
--- a/app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Zdravo <%= @resource.email %>!</p>
-
-<p>Neko je zatražio link za promenu lozinke na instanci <%= @instance %>. Ovo možete uraditi klikom na link ispod.</p>
-
-<p><%= link_to 'Promeni moju lozinku', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Ignorišite ovu poruku, ako niste Vi bili ti koji ste zatražili promenu lozinke.</p>
-<p>Lozinka se neće promeniti sve dok ne kliknete link iznad i ne napravite novu lozinku.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.sr.html.erb b/app/views/user_mailer/reset_password_instructions.sr.html.erb
deleted file mode 100644
index be8d0c3ed..000000000
--- a/app/views/user_mailer/reset_password_instructions.sr.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Здраво <%= @resource.email %>!</p>
-
-<p>Неко је затражио линк за промену лозинке на инстанци <%= @instance %>. Ово можете урадити кликом на линк испод.</p>
-
-<p><%= link_to 'Промени моју лозинку', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Игноришите ову поруку, ако нисте Ви били ти који сте затражили промену лозинке.</p>
-<p>Лозинка се неће променити све док не кликнете линк изнад и не направите нову лозинку.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.sv.html.erb b/app/views/user_mailer/reset_password_instructions.sv.html.erb
deleted file mode 100644
index f38d2a39f..000000000
--- a/app/views/user_mailer/reset_password_instructions.sv.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>Hej <%= @resource.email %>!</p>
-
-<p>SNågon har begärt en länk för att ändra ditt lösenord på <%= @instance %>. Du kan göra det genom länken nedan.</p>
-
-<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Om du inte begärt detta, ignorerar du det här e-postmeddelandet.</p>
-<p>Ditt lösenord ändras inte förrän du öppnar länken ovan och skapar en ny.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.th.html.erb b/app/views/user_mailer/reset_password_instructions.th.html.erb
deleted file mode 100644
index 1fe1c2725..000000000
--- a/app/views/user_mailer/reset_password_instructions.th.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p>สวัสดี <%= @resource.email %>!</p>
-
-<p>มีการแจ้งขอลิงค์เปลี่ยนแปลงพาสเวิร์ดเข้า Mastodon ของคุณ. คุณสามารถแก้ไขพาสเวิร์ดได้ผ่านทางลิงค์ด้านล่างนี้.</p>
-
-<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>ถ้าคุณไม่ได้เป็นคนขอลิงค์นี้, กรุณาข้ามเมล์นี้ไป.</p>
-<p>พาร์สเวิร์ดของคุณจะยังไม่ถูกเปลี่ยนจนกว่าคุณจะเข้าไปในลิงค์ด้านบนนี้เพื่อสร้างพาสเวิร์ดใหม่.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.tr.html.erb b/app/views/user_mailer/reset_password_instructions.tr.html.erb
deleted file mode 100644
index d19c6c498..000000000
--- a/app/views/user_mailer/reset_password_instructions.tr.html.erb
+++ /dev/null
@@ -1,14 +0,0 @@
-<p>Merhaba <%= @resource.email %></p>
-
-<p>Biri <%= @instance %> üzerinden sizin hesabınız için parola değişim isteği gönderdi. Aşağıdaki linke tıklayarak bunu gerçekleştirebilirsiniz.</p>
-
-<p><%= link_to 'Parolamı değiştir', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>Eğer bu isteği siz göndermediyseniz, lütfen bu e-postayı görmezden geliniz.</p>
-
-<p>Siz bu linke tıklayıp yeni bir parola girene kadar parolanız değişmeyecektir.</p>
-
-<p>En içten dileklerimizle,<p>
-
-<p> <%= @instance %> ekibi</p>
-
diff --git a/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb b/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb
deleted file mode 100644
index 124305675..000000000
--- a/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<p><%= @resource.email %>,你好呀!</p>
-
-<p>有人想修改你在 <%= @instance %> 上的密码呢。如果你确实想修改密码的话,点击下面的链接吧:</p>
-
-<p><%= link_to '修改密码', edit_password_url(@resource, reset_password_token: @token) %></p>
-
-<p>如果你不想修改密码的话,还请忽略这封邮件哦。</p>
-<p>在你点击上面的链接并修改密码前,你的密码是不会改变的。</p>
diff --git a/app/workers/digest_mailer_worker.rb b/app/workers/digest_mailer_worker.rb
index 028db89a9..21f1c357a 100644
--- a/app/workers/digest_mailer_worker.rb
+++ b/app/workers/digest_mailer_worker.rb
@@ -9,7 +9,7 @@ class DigestMailerWorker
 
   def perform(user_id)
     @user = User.find(user_id)
-    deliver_digest if user_receives_digest?
+    deliver_digest if @user.allows_digest_emails?
   end
 
   private
@@ -18,8 +18,4 @@ class DigestMailerWorker
     NotificationMailer.digest(user.account).deliver_now!
     user.touch(:last_emailed_at)
   end
-
-  def user_receives_digest?
-    user.settings.notification_emails['digest']
-  end
 end
diff --git a/app/workers/scheduler/email_scheduler.rb b/app/workers/scheduler/email_scheduler.rb
new file mode 100644
index 000000000..24d0c0ebe
--- /dev/null
+++ b/app/workers/scheduler/email_scheduler.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+require 'sidekiq-scheduler'
+
+class Scheduler::EmailScheduler
+  include Sidekiq::Worker
+
+  def perform
+    eligible_users.find_each do |user|
+      next unless user.allows_digest_emails?
+      DigestMailerWorker.perform_async(user.id)
+    end
+  end
+
+  private
+
+  def eligible_users
+    User.confirmed
+        .joins(:account)
+        .where(accounts: { silenced: false, suspended: false })
+        .where(disabled: false)
+        .where('current_sign_in_at < ?', 20.days.ago)
+        .where('last_emailed_at IS NULL OR last_emailed_at < ?', 20.days.ago)
+  end
+end
diff --git a/config/application.rb b/config/application.rb
index dc488ea8a..c1bf3cef2 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -9,6 +9,7 @@ Bundler.require(*Rails.groups)
 require_relative '../app/lib/exceptions'
 require_relative '../lib/paperclip/gif_transcoder'
 require_relative '../lib/paperclip/video_transcoder'
+require_relative '../lib/paperclip/audio_transcoder'
 require_relative '../lib/mastodon/snowflake'
 require_relative '../lib/mastodon/version'
 
@@ -74,12 +75,17 @@ module Mastodon
 
     config.active_job.queue_adapter = :sidekiq
 
+    #config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) do
     config.middleware.insert_before 0, Rack::Cors do
       allow do
         origins  '*'
         resource '/@:username',  headers: :any, methods: [:get], credentials: false
         resource '/api/*',       headers: :any, methods: [:post, :put, :delete, :get, :patch, :options], credentials: false, expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id']
         resource '/oauth/token', headers: :any, methods: [:post], credentials: false
+        resource '/assets/*', headers: :any, methods: [:get, :head, :options]
+        resource '/stylesheets/*', headers: :any, methods: [:get, :head, :options]
+        resource '/javascripts/*', headers: :any, methods: [:get, :head, :options]
+        resource '/packs/*', headers: :any, methods: [:get, :head, :options]
       end
     end
 
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 5705ffcfe..a691ef873 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -91,9 +91,14 @@ Rails.application.configure do
   config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym
 
   config.action_dispatch.default_headers = {
-    'Server'                 => 'Mastodon',
-    'X-Frame-Options'        => 'DENY',
-    'X-Content-Type-Options' => 'nosniff',
-    'X-XSS-Protection'       => '1; mode=block',
+    'Server'                  => 'Mastodon',
+    'X-Frame-Options'         => 'DENY',
+    'X-Content-Type-Options'  => 'nosniff',
+    'X-XSS-Protection'        => '1; mode=block',
+    'Content-Security-Policy' => "frame-ancestors 'none'; object-src 'none'; script-src 'self' https://dev-static.glitch.social ; base-uri 'none';" , 
+    'Referrer-Policy'         => 'no-referrer, strict-origin-when-cross-origin',
+    'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload',
+    'X-Clacks-Overhead' => 'GNU Natalie Nguyen'
+
   }
 end
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index 014055804..9e1b620f5 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -61,3 +61,4 @@ ignore_unused:
   - 'errors.429'
   - 'admin.accounts.roles.*'
   - 'admin.action_logs.actions.*'
+  - 'themes.default'
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
index f2bf17364..53b39718d 100644
--- a/config/initializers/assets.rb
+++ b/config/initializers/assets.rb
@@ -8,6 +8,6 @@ Rails.application.config.assets.version = '1.0'
 
 # Precompile additional assets.
 # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
-# Rails.application.config.assets.precompile += %w(application_public.js custom.css)
+# Rails.application.config.assets.precompile += %w()
 
 Rails.application.config.assets.initialize_on_precompile = true
diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb
new file mode 100644
index 000000000..04ed31646
--- /dev/null
+++ b/config/initializers/locale.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names', '*.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names', '*.{rb,yml}').to_s]
diff --git a/config/initializers/premailer_rails.rb b/config/initializers/premailer_rails.rb
new file mode 100644
index 000000000..31c02dffa
--- /dev/null
+++ b/config/initializers/premailer_rails.rb
@@ -0,0 +1,6 @@
+require_relative '../../lib/mastodon/premailer_webpack_strategy'
+
+Premailer::Rails.config.merge!(remove_ids: true,
+                               adapter: :nokogiri,
+                               generate_text_part: false,
+                               strategies: [PremailerWebpackStrategy])
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index b38fb302b..b35452f04 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -53,7 +53,7 @@ class Rack::Attack
     req.ip if req.api_request?
   end
 
-  throttle('protected_paths', limit: 5, period: 5.minutes) do |req|
+  throttle('protected_paths', limit: 25, period: 5.minutes) do |req|
     req.ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX
   end
 
diff --git a/config/initializers/suppress_csrf_warnings.rb b/config/initializers/suppress_csrf_warnings.rb
new file mode 100644
index 000000000..410ab585b
--- /dev/null
+++ b/config/initializers/suppress_csrf_warnings.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+ActionController::Base.log_warning_on_csrf_failure = false
diff --git a/config/locales/activerecord.gl.yml b/config/locales/activerecord.gl.yml
new file mode 100644
index 000000000..e38131454
--- /dev/null
+++ b/config/locales/activerecord.gl.yml
@@ -0,0 +1,13 @@
+---
+gl:
+  activerecord:
+    errors:
+      models:
+        account:
+          attributes:
+            username:
+              invalid: só letras, números e liñas baixas
+        status:
+          attributes:
+            reblog:
+              taken: do estado xa existe
diff --git a/config/locales/activerecord.pt.yml b/config/locales/activerecord.pt.yml
new file mode 100644
index 000000000..556fcfc4f
--- /dev/null
+++ b/config/locales/activerecord.pt.yml
@@ -0,0 +1,13 @@
+---
+pt:
+  activerecord:
+    errors:
+      models:
+        account:
+          attributes:
+            username:
+              invalid: apenas letras, números e underscores
+        status:
+          attributes:
+            reblog:
+              taken: do status já existe
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 6dc8bc1bb..82e8e998a 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -5,13 +5,17 @@ ar:
     about_this: عن مثيل الخادوم هذا
     closed_registrations: التسجيلات في مثيل الخادوم هذا مُغلقة حاليًا.
     contact: للتواصل معنا
+    contact_missing: غير محدد
     contact_unavailable: غير متوفر
     description_headline: ما هو %{domain}?
     domain_count_after: خوادم أخرى
     domain_count_before: متصل بـ
     features:
+      humane_approach_title: أسلوب يعيد الإعتبار للإنسان
       not_a_product_title: إنك إنسان و لست سلعة
+      real_conversation_title: مبني لتحقيق تواصل حقيقي
     find_another_instance: إبحث عن مثيل خادوم آخر
+    generic_description: "%{domain} هو سيرفر من بين سيرفرات الشبكة"
     hosted_on: ماستدون مُستضاف على %{domain}
     learn_more: تعلم المزيد
     other_instances: خوادم أخرى
@@ -35,15 +39,21 @@ ar:
     reserved_username: إسم المستخدم محجوز
     roles:
       admin: المدير
+      moderator: مُشرِف
     unfollow: إلغاء المتابعة
   admin:
     account_moderation_notes:
+      account: مُشرِف
       created_at: التاريخ
       delete: حذف
     accounts:
       are_you_sure: متأكد ؟
       by_domain: النطاق
       confirm: تأكيد
+      confirmed: مؤكَّد
+      disable: تعطيل
+      disable_two_factor_authentication: تعطيل 2FA
+      disabled: معطَّل
       display_name: عرض الإسم
       domain: النطاق
       edit: تعديل
@@ -53,13 +63,23 @@ ar:
       followers: المتابِعون
       follows: يتابع
       ip: عنوان الإيبي
+      location:
+        all: الكل
+        title: الموقع
       media_attachments: الوسائط المرفقة
+      moderation:
+        all: الكل
+      most_recent_ip: أحدث عنوان إيبي
       order:
+        most_recent: الأحدث
         title: الترتيب
       profile_url: رابط الملف الشخصي
+      protocol: البروتوكول
       role: التصريحات
       roles:
         admin: مدير
+        moderator: مشرف
+        staff: الفريق
         user: مستخدِم
       search: البحث
       statuses: المنشورات
@@ -113,13 +133,18 @@ ar:
         delete: حذف
       media:
         title: الوسائط
+    subscriptions:
+      confirmed: مؤكَّد
+      topic: الموضوع
     title: الإدارة
   application_mailer:
-    salutation: "%{name},"
+    salutation: "%{name}،"
     settings: 'تغيير تفضيلات البريد الإلكتروني : %{link}'
     signature: إشعارات ماستدون من %{instance}
     view: 'View:'
   applications:
+    created: تم إنشاء التطبيق بنجاح
+    destroyed: تم حذف التطبيق بنجاح
     invalid_url: إن الرابط المقدم غير صالح
   auth:
     change_password: الهوية
@@ -134,8 +159,11 @@ ar:
     reset_password: إعادة تعيين كلمة المرور
     set_new_password: تعيين كلمة مرور جديدة
   authorize_follow:
-    error: Unfortunately, there was an error looking up the remote account
+    error: يا للأسف، وقع هناك خطأ إثر عملية البحث عن الحساب عن بعد
     follow: إتبع
+    follow_request: 'لقد قمت بإرسال طلب متابعة إلى :'
+    post_follow:
+      web: واصل إلى الويب
     title: إتباع %{acct}
   datetime:
     distance_in_words:
@@ -153,8 +181,15 @@ ar:
       x_seconds: "%{count}ث"
   deletes:
     bad_password_msg: محاولة جيدة يا هاكرز ! كلمة السر خاطئة
+    confirm_password: قم بإدخال كلمتك السرية الحالية للتحقق من هويتك
     proceed: حذف حساب
     success_msg: تم حذف حسابك بنجاح
+  errors:
+    '403': ليس لك الصلاحيات الكافية لعرض هذه الصفحة.
+    '404': إنّ الصفحة التي تبحث عنها لا وجود لها أصلا.
+    '410': إنّ الصفحة التي تبحث عنها لم تعد موجودة.
+    '500':
+      title: هذه الصفحة خاطئة
   exports:
     blocks: قمت بحظر
     csv: CSV
@@ -163,13 +198,15 @@ ar:
   followers:
     domain: النطاق
     followers_count: عدد المتابِعين
+    purge: تنحية من بين متابعيك
+    unlocked_warning_title: إنّ حسابك غير مقفل
   generic:
     changes_saved_msg: تم حفظ التعديلات بنجاح !
     powered_by: مدعوم بـ %{link}
     save_changes: حفظ التغييرات
     validation_errors:
-      one: Something isn't quite right yet! Please review the error below
-      other: Something isn't quite right yet! Please review %{count} errors below
+      one: لا يزال هناك خلل ما إلى حد الآن. يُرجى إعادة النظر في الخطأ أسفله
+      other: هناك شيء ليس على ما يرام ! رجاءًا تحقق من الأخطاء الـ %{count} أسفله
   imports:
     preface: You can import certain data like all the people you are following or blocking into your account on this instance, from files created by an export on another instance.
     success: تم تحميل بياناتك بنجاح وسيتم معالجتها في الوقت المناسب
@@ -177,8 +214,16 @@ ar:
       blocking: قائمة المحظورين
       following: قائمة المستخدمين المتبوعين
     upload: تحميل
+  invites:
+    delete: تعطيل
+    expires_in:
+      '1800': 30 دقيقة
+      '21600': 6 ساعات
+      '3600': ساعة
+      '43200': 12 ساعة
+      '86400': يوم واحد
   landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse.."
-  landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
+  landing_strip_signup_html: إن كنت لا تملك واحدا، يمكنك <a href="%{sign_up_path}">التسجيل مِن هنا</a>.
   lists:
     errors:
       limit: لقد بلغت الحد الأقصى للقوائم
@@ -188,9 +233,11 @@ ar:
       too_many: لا يمكن إرفاق أكثر من 4 ملفات
   migrations:
     acct: username@domain للحساب الجديد
+    currently_redirecting: 'تم تحويل رابط ملفك الشخصي إلى :'
+    proceed: حفظ
   notification_mailer:
     digest:
-      body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'
+      body: 'هذه هي الأخبار المختصرة التي قد فاتتك على %{instance}وذلك منذ آخر زيارة لك في  %{since} :'
       mention: "%{name} أشار إليك في :"
       new_followers_summary:
         one: لقد حصلت على متابع جديد !
@@ -200,19 +247,19 @@ ar:
         other: "%{count} إشعارات جديدة منذ زيارتك الأخيرة \U0001F418"
     favourite:
       body: 'أُعجب %{name} بمنشورك :'
-      subject: "%{name} favourited your status"
+      subject: أُعجِب %{name} بمنشورك
     follow:
       body: "%{name} من متتبعيك الآن !"
       subject: "%{name} من متتبعيك الآن"
     follow_request:
-      body: "%{name} has requested to follow you"
-      subject: 'Pending follower: %{name}'
+      body: طلب %{name} متابعتك
+      subject: 'متابع مُعلّق : %{name}'
     mention:
-      body: 'You were mentioned by %{name} in:'
+      body: 'أشار إليك %{name} في :'
       subject: لقد قام %{name} بذِكرك
     reblog:
-      body: 'Your status was boosted by %{name}:'
-      subject: "%{name} boosted your status"
+      body: 'قام %{name} بترقية منشورك :'
+      subject: قام %{name} بترقية منشورك
   number:
     human:
       decimal_units:
@@ -227,50 +274,104 @@ ar:
   pagination:
     next: التالي
     prev: السابق
+    truncate: "&hellip;"
   preferences:
     languages: اللغات
     other: إعدادات أخرى
     publishing: النشر
+    web: الويب
+  push_notifications:
+    favourite:
+      title: أعجب %{name} بمنشورك
+    follow:
+      title: "%{name} من متتبعيك الآن"
+    group:
+      title: "%{count} إخطارات"
+    mention:
+      action_boost: ترقية
+      action_expand: عرض المزيد
+      title: أشار إليك %{name}
+    reblog:
+      title: قام %{name} بترقية منشورك
   remote_follow:
     acct: قم بإدخال عنوان حسابك username@domain الذي من خلاله تود المتابعة
-    missing_resource: Could not find the required redirect URL for your account
+    missing_resource: تعذر العثور على رابط التحويل المطلوب الخاص بحسابك
     proceed: أكمل المتابعة
     prompt: 'إنك  بصدد متابعة :'
+  sessions:
+    activity: آخر نشاط
+    browser: المتصفح
+    browsers:
+      alipay: أليباي
+      blackberry: بلاك بيري
+      chrome: كروم
+      edge: مايكروسوفت إيدج
+      firefox: فايرفكس
+      generic: متصفح مجهول
+      ie: إنترنت إكسبلورر
+      opera: أوبرا
+      qq: متصفح كيوكيو
+      safari: سفاري
+    current_session: الجلسة الحالية
+    description: "%{browser} على %{platform}"
+    ip: عنوان الإيبي
+    platforms:
+      android: أندرويد
+      blackberry: بلاك بيري
+      chrome_os: نظام كروم أواس
+      firefox_os: نظام فايرفكس أواس
+      linux: لينكس
+      mac: ماك
+      other: نظام مجهول
+      windows: ويندوز
+    title: الجلسات
   settings:
     authorized_apps: التطبيقات المرخص لها
     back: عودة إلى ماستدون
+    development: التطوير
     edit_profile: تعديل الملف الشخصي
     export: تصدير البيانات
     followers: المتابِعون المُرَخّصون
     import: إستيراد
+    notifications: الإخطارات
     preferences: التفضيلات
     settings: الإعدادات
-    two_factor_authentication: اثبات هويّة مزدوج
+    two_factor_authentication: إثبات الهويّة المزدوج
+    your_apps: تطبيقاتك
   statuses:
     open_in_web: إفتح في الويب
     over_character_limit: تم تجاوز حد الـ %{max} حرف المسموح بها
+    pin_errors:
+      ownership: لا يمكن تدبيس تبويق نشره شخص آخر
     show_more: أظهر المزيد
+    title: '%{name} : "%{quote}"'
     visibilities:
       private: إعرض فقط لمتتبعيك
+      private_long: إعرضه لمتتبعيك فقط
       public: للعامة
+      public_long: يمكن للجميع رؤيته
       unlisted: Public, but do not display on the public timeline
   stream_entries:
     click_to_show: إضغط للعرض
     reblogged: رقى
     sensitive_content: محتوى حساس
+  terms:
+    title: شروط الخدمة وسياسة الخصوصية على %{instance}
+  themes:
+    default: ماستدون
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
   two_factor_authentication:
-    code_hint: Enter the code generated by your authenticator app to confirm
-    description_html: If you enable <strong>two-factor authentication</strong>, logging in will require you to be in possession of your phone, which will generate tokens for you to enter.
+    code_hint: قم بإدخال الرمز المُوَلّد عبر تطبيق المصادقة للتأكيد
+    description_html: في حال تفعيل <strong>المصادقة بخطوتين </strong>، فتسجيل الدخول يتتطلب منك أن يكون بحوزتك هاتفك النقال قصد توليد الرمز الذي سيتم إدخاله.
     disable: تعطيل
     enable: تفعيل
     enabled: نظام المصادقة بخطوتين مُفعَّل
     enabled_success: تم تفعيل إثبات الهوية المزدوج بنجاح
     generate_recovery_codes: توليد رموز الإسترجاع
-    instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
-    manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
+    instructions_html: "<strong>قم بمسح رمز الكيو آر عبر Google Authenticator أو أي تطبيق TOTP على جهازك</strong>. من الآن فصاعدا سوف يقوم ذاك التطبيق بتوليد رموز يجب عليك إدخالها عند تسجيل الدخول."
+    manual_instructions: 'في حالة تعذّر مسح رمز الكيو آر أو طُلب منك إدخال يدوي، يُمْكِنك إدخال هذا النص السري على التطبيق :'
     recovery_codes: النسخ الإحتياطي لرموز الإسترجاع
     recovery_codes_regenerated: تم إعادة توليد رموز الإسترجاع الإحتياطية بنجاح
     setup: تنشيط
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index 455a85ceb..5fd5b3f6d 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -116,6 +116,7 @@ ca:
       roles:
         admin: Administrador
         moderator: Moderador
+        staff: Personal
         user: Usuari
       salmon_url: URL Salmon
       search: Cerca
@@ -160,6 +161,7 @@ ca:
         update_status: "%{name} estat actualizat per %{target}"
       title: Registre d'auditoria
     custom_emojis:
+      by_domain: Domini
       copied_msg: S'ha creat correctament la còpia local del emoji
       copy: Copia
       copy_failed_msg: No s'ha pogut fer una còpia local d'aquest emoji
@@ -458,6 +460,9 @@ ca:
     title: Convida persones
   landing_strip_html: "<strong>%{name}</strong> és un usuari/a de %{link_to_root_path}. Pots seguir-lo/la o interactuar amb ell/a si tens un compte a qualsevol node del fediverse."
   landing_strip_signup_html: Si no en tens, pots <a href="%{sign_up_path}">registrar-te aquí</a>.
+  lists:
+    errors:
+      limit: Has assolit la quantitat màxima de llistes
   media_attachments:
     validations:
       images_and_video: No es pot adjuntar un vídeo a una publicació que ja contingui imatges
@@ -596,6 +601,7 @@ ca:
       private: No es pot fixar el toot no públic
       reblog: No es pot fixar un impuls
     show_more: Mostrar més
+    title: '%{name}: "%{quote}"'
     visibilities:
       private: Només seguidors
       private_long: Mostra només als seguidors
@@ -678,8 +684,6 @@ ca:
 
       <p>Originalment adaptat a la <a href="https://github.com/discourse/discourse">política de privadesa del Discurs</a>.</p>
     title: "%{instance} Condicions del servei i política de privadesa"
-  themes:
-    default: Mastodont
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
diff --git a/config/locales/devise.ar.yml b/config/locales/devise.ar.yml
index bb91cb372..28a03ff12 100644
--- a/config/locales/devise.ar.yml
+++ b/config/locales/devise.ar.yml
@@ -3,11 +3,16 @@ ar:
   devise:
     confirmations:
       confirmed: تم التحقق من عنوان بريدك الإلكتروني بنجاح.
+      send_instructions: سوف تتلقى بعد بضع دقائق رسالةً إلكترونيةً تضم تعليمات التأكيد. إن لم تتلق الرسالة، الرجاء التحقق من إنها ليست ضمن ملف الرسائل غير المرغوب فيها.
+      send_paranoid_instructions: إن كان عنوان بريدك الإلكتروني موجودا في قاعدة بياناتنا سوف تتلقّى الإرشادات اللازمة لتأكيده خلال بضع دقائق. يُرجى الإطلاع على الرسائل المتلقاة في البريد غير المرغوب فيه أيضا للتحقق من تلقي الرسالة.
     failure:
       already_authenticated: لقد تم تسجيل دخولك من قبل.
       inactive: لم يتم تنشيط حسابك بعد.
+      invalid: "%{authentication_keys} أو كلمة سر خاطئة."
       last_attempt: بإمكانك إعادة المحاولة مرة واحدة قبل أن يتم قفل حسابك.
       locked: إن حسابك مقفل.
+      not_found_in_database: "%{authentication_keys} أو كلمة سر خاطئة."
+      timeout: لقد إنتهت مدة صلاحية جلستك. قم بتسجيل الدخول من جديد للمواصلة.
       unauthenticated: يجب عليك تسجيل الدخول أو إنشاء حساب قبل المواصلة.
       unconfirmed: يجب عليك تأكيد عنوان بريدك الإلكتروني قبل المواصلة.
     mailer:
@@ -19,12 +24,17 @@ ar:
         subject: 'ماستدون : تعليمات إستعادة كلمة المرور'
       unlock_instructions:
         subject: 'ماستدون : تعليمات فك القفل'
+    omniauth_callbacks:
+      failure: تعذرت المصادقة من %{kind} بسبب "%{reason}".
+      success: تمت المصادقة بنجاح عبر حساب %{kind}.
     passwords:
       updated: تم تغيير كلمة المرور بنجاح. أنت مسجل الآن.
       updated_not_active: تم تغيير كلمة المرور بنجاح.
     registrations:
       destroyed: إلى اللقاء ! لقد تم إلغاء حسابك. نتمنى أن نراك مجددا.
       signed_up: أهلا وسهلا ! تم تسجيل دخولك بنجاح.
+      signed_up_but_inactive: لقد تمت عملية إنشاء حسابك بنجاح إلاّ أنه لا يمكننا تسجيل دخولك إلاّ بعد قيامك بتفعيله.
+      signed_up_but_unconfirmed: لقد تم إرسال رسالة تحتوي على رابط للتفعيل إلى عنوان بريدك الإلكتروني. بالضغط على الرابط سوف يتم تفعيل حسابك. لذا يُرجى إلقاء نظرة على ملف الرسائل غير المرغوب فيها إنْ لم تَعثُر على الرسالة السالفة الذِكر.
       updated: تم تحديث حسابك بنجاح.
     sessions:
       already_signed_out: تم تسجيل خروجك بنجاح.
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
index c5ae583ff..20938e47b 100644
--- a/config/locales/devise.en.yml
+++ b/config/locales/devise.en.yml
@@ -17,15 +17,32 @@ en:
       unconfirmed: You have to confirm your email address before continuing.
     mailer:
       confirmation_instructions:
+        action: Verify email address
+        explanation: You have created an account on %{host} with this email address. You are one click away from activating it. If this wasn't you, please ignore this email.
+        extra_html: Please also check out <a href="%{terms_path}">the rules of the instance</a> and <a href="%{policy_path}">our terms of service</a>.
         subject: 'Mastodon: Confirmation instructions for %{instance}'
+        title: Verify email address
       email_changed:
+        explanation: 'The email address for your account is being changed to:'
+        extra: If you did not change your email, it is likely that someone has gained access to your account. Please change your password immediately or contact the instance admin if you're locked out of your account.
         subject: 'Mastodon: Email changed'
+        title: New email address
       password_change:
+        explanation: The password for your account has been changed.
+        extra: If you did not change your password, it is likely that someone has gained access to your account. Please change your password immediately or contact the instance admin if you're locked out of your account.
         subject: 'Mastodon: Password changed'
+        title: Password changed
       reconfirmation_instructions:
+        explanation: Confirm the new address to change your email.
+        extra: If this change wasn't initiated by you, please ignore this email. The email address for the Mastodon account won't change until you access the link above.
         subject: 'Mastodon: Confirm email for %{instance}'
+        title: Verify email address
       reset_password_instructions:
+        action: Change password
+        explanation: You requested a new password for your account.
+        extra: If you didn't request this, please ignore this email. Your password won't change until you access the link above and create a new one.
         subject: 'Mastodon: Reset password instructions'
+        title: Password reset
       unlock_instructions:
         subject: 'Mastodon: Unlock instructions'
     omniauth_callbacks:
diff --git a/config/locales/devise.gl.yml b/config/locales/devise.gl.yml
new file mode 100644
index 000000000..8a2b5d563
--- /dev/null
+++ b/config/locales/devise.gl.yml
@@ -0,0 +1,61 @@
+---
+gl:
+  devise:
+    confirmations:
+      confirmed: O seu enderezo de email foi confirmado con éxito.
+      send_instructions: Nuns minutos recibirá un correo electrónico con instruccións sobre como confirmar o seu enderezo de correo.  Comprobe por favor o cartafol de spam se non recibe este correo.
+      send_paranoid_instructions: Si o seu enderezo existe na nosa base de datos, recibirá nuns minutos un correo con instruccións sobre como confirmar o enderezo de correo. Por favor comprobe o cartafol de spam si non recibe este correo.
+    failure:
+      already_authenticated: Xa está conectada.
+      inactive: A súa conta aínda non foi activada.
+      invalid: Contrasinal ou %{authentication_keys} non válidos.
+      last_attempt: Quédalle un intento antes de que a conta sexa bloqueada.
+      locked: A súa conta foi bloqueada.
+      not_found_in_database: Contrasinal ou %{authentication_keys} non válidos.
+      timeout: Caducou a sesión. Por favor conéctese de novo para seguir.
+      unauthenticated: Precisa rexistrarse ou conectarse para continuar.
+      unconfirmed: Debe confirmar o seu enderezo de correo antes de continuar.
+    mailer:
+      confirmation_instructions:
+        subject: 'Mastodon: Instruccións de confirmación para %{instance}'
+      password_change:
+        subject: 'Mastodon: contrasinal cambiado'
+      reset_password_instructions:
+        subject: 'Mastodon: Instruccións para restablecer o contrasinal'
+      unlock_instructions:
+        subject: 'Mastodon: Instruccións para desbloquear'
+    omniauth_callbacks:
+      failure: Non podemos autenticala desde %{kind} porque "%{reason}".
+      success: Autenticouse con éxito desde a conta %{kind}.
+    passwords:
+      no_token: Non pode acceder a esta páxina vindo desde un correo de restablecemento de contrasinal. Si vostede chega desde un correo de restablecemento de contrasinal, por favor asegúrese de que utiliza o URL completo proporcionado.
+      send_instructions: Si o seu enderezo de correo existe na nosa base de datos,  nuns minutos recibirá unha ligazón para recuperar o contrasinal. Por favor comprobe o seu cartafol de spam si non recibe este correo.
+      send_paranoid_instructions: Si o seu enderezo de correo existe na nosa base de datos, recibirá nuns minutos unha ligazón para recuperar o contrasinal. Por favor comprobe o seu cartafol de spam si non recibe este correo.
+      updated: Cambiou o contrasinal con éxito. Agora xa está conectada.
+      updated_not_active: Cambiouse o seu contrasinal correctamente.
+    registrations:
+      destroyed: Adeus! A súa conta cancelouse con éxito. Agardamos vela de novo.
+      signed_up: Ben vida! Rexistrouse con éxito.
+      signed_up_but_inactive: Rexistrouse correctamente. Porén, aínda non podemos conectala porque a súa conta aínda non foi activada.
+      signed_up_but_locked: Rexistrouse correctamente. Porén, non podemos conectala porque a conta está bloqueada.
+      signed_up_but_unconfirmed: Foi enviada unha mensaxe con unha ligazón de confirmación ao seu enderezo electrónico. Por favor siga a ligazón para activar a súa conta. Por favor comprobe o cartafol de spam si non recibe este correo.
+      update_needs_confirmation: Actualizou a súa conta correctamente, pero precisamos verificar o seu enderezo. Por favor comprobe o seu email e siga a ligazón de confirmación para confirmar o seu novo enderezo. Por favor comprobe o cartafol de spam si non recibe este correo.
+      updated: A súa conta foi actualizada correctamente.
+    sessions:
+      already_signed_out: Desconectouse con éxito.
+      signed_in: Conectouse correctamente.
+      signed_out: Desconectouse correctamente.
+    unlocks:
+      send_instructions: Recibirá nuns minutos un correo con instrucións sobre como desbloquear a súa conta. Por favor comprobe o cartafol de spam si non recibe este correo.
+      send_paranoid_instructions: Si a conta existe, recibirá un correo nuns minutos sobre como desbloquear a súa conta. Por favor comprobe o cartafol de spam si non recibe este correo.
+      unlocked: A súa conta foi desbloqueada correctamente. Por favor, conéctese para continuar.
+  errors:
+    messages:
+      already_confirmed: xa foi confirmada, por favor intente conectarse
+      confirmation_period_expired: precisa ser confirmada en %{period}, por favor solicite unha nova
+      expired: caducou, por favor solicite unha nova
+      not_found: non se atopou
+      not_locked: non foi bloqueada
+      not_saved:
+        one: '1 erro evitou que %{resource} fose gardada:'
+        other: "%{count} erros evitaron que %{resource} fose gardada:"
diff --git a/config/locales/devise.zh-CN.yml b/config/locales/devise.zh-CN.yml
index 5560e12b3..dd6b48795 100644
--- a/config/locales/devise.zh-CN.yml
+++ b/config/locales/devise.zh-CN.yml
@@ -17,15 +17,34 @@ zh-CN:
       unconfirmed: 继续操作前请先确认你的帐户。
     mailer:
       confirmation_instructions:
-        subject: Mastodon 帐户确认信息
+        action: 验证电子邮件地址
+        explanation: 你在 %{host} 上使用这个电子邮件地址创建了一个帐户。只需点击下面的按钮,即可完成激活。如果你并没有创建过帐户,请忽略此邮件。
+        extra_html: 请记得阅读<a href="%{terms_path}">本实例的相关规定</a>和<a href="%{policy_path}">我们的使用条款</a>。
+        subject: Mastodon:确认 %{instance} 帐户信息
+        title: 验证电子邮件地址
       email_changed:
-        subject: Mastodon 电子邮件地址已被修改
+        explanation: 你的帐户的电子邮件地址即将变更为:
+        extra: 如果你并没有请求更改你的电子邮件地址,则他人很有可能已经入侵你的帐户。请立即更改你的密码;如果你已经无法访问你的帐户,请联系实例的管理员请求协助。
+        subject: Mastodon:电子邮件地址已被更改
+        title: 新电子邮件地址
       password_change:
-        subject: Mastodon 密码已被重置
+        explanation: 你的帐户的密码已被更改。
+        extra: 如果你并没有请求更改你的密码,则他人很有可能已经入侵你的帐户。请立即更改你的密码;如果你已经无法访问你的帐户,请联系实例的管理员请求协助。
+        subject: Mastodon:密码已被更改
+        title: 密码已被重置
+      reconfirmation_instructions:
+        explanation: 请确认你的新电子邮件地址以完成更改。
+        extra: 如果你并没有请求本次变更,请忽略此邮件。Mastodon 帐户的电子邮件地址只有在你点击上面的链接后才会更改。
+        subject: Mastodon:确认 %{instance} 电子邮件地址
+        title: 验证电子邮件地址
       reset_password_instructions:
-        subject: Mastodon 重置密码信息
+        action: 更改密码
+        explanation: 你正在请求更改帐户的密码。
+        extra: 如果你并没有请求本次变更,请忽略此邮件。你的密码只有在你点击上面的链接并输入新密码后才会更改。
+        subject: Mastodon:重置密码信息
+        title: 重置密码
       unlock_instructions:
-        subject: Mastodon 帐户解锁信息
+        subject: Mastodon:帐户解锁信息
     omniauth_callbacks:
       failure: 由于%{reason},无法从%{kind}获得授权。
       success: 成功地从%{kind}获得授权。
diff --git a/config/locales/doorkeeper.gl.yml b/config/locales/doorkeeper.gl.yml
index 863438454..dc9a04f18 100644
--- a/config/locales/doorkeeper.gl.yml
+++ b/config/locales/doorkeeper.gl.yml
@@ -1,9 +1,11 @@
+---
 gl:
   activerecord:
     attributes:
       doorkeeper/application:
         name: Nome do aplicativo
         redirect_uri: URI a redireccionar
+        scopes: Permisos
         website: Sitio web do aplicativo
     errors:
       models:
@@ -31,3 +33,87 @@ gl:
       help:
         native_redirect_uri: Utilice %{native_redirect_uri} para probas locais
         redirect_uri: Utilice unha liña por URI
+        scopes: Separar permisos con espazos. Deixar en blanco para utilizar os permisos por omisión.
+      index:
+        application: Aplicativo
+        callback_url: URL de chamada
+        delete: Eliminar
+        name: Nome
+        new: Novo aplicativo
+        scopes: Permisos
+        show: Mostrar
+        title: Os seus aplicativos
+      new:
+        title: Novo aplicativo
+      show:
+        actions: Accións
+        application_id: Chave do cliente
+        callback_urls: URLs de chamada
+        scopes: Permisos
+        secret: Chave secreta do cliente
+        title: 'Aplicativo: %{name}'
+    authorizations:
+      buttons:
+        authorize: Autorizar
+        deny: Denegar
+      error:
+        title: Algo fallou
+      new:
+        able_to: Poderá
+        prompt: O aplicativo %{client_name} solicita acceso a súa conta
+        title: Autorización necesaria
+      show:
+        title: Copie este código de autorización e pégueo no aplicativo.
+    authorized_applications:
+      buttons:
+        revoke: Retirar autorización
+      confirmations:
+        revoke: Está segura?
+      index:
+        application: Aplicativo
+        created_at: Autorizado
+        date_format: "%d-%m-%Y %H:%M:%S"
+        scopes: Permisos
+        title: Os seus aplicativos autorizados
+    errors:
+      messages:
+        access_denied: O propietario do recurso ou o servidor autorizado denegaron a petición.
+        credential_flow_not_configured: O fluxo do Contrasinal de Credenciais do Dono do Recurso  fallou debido a que Doorkeeper.configure.resource_owner_from_credentials non están configuradas.
+        invalid_client: A autenticación do cliente fallou debido a un cliente descoñecido, non se incluíu autenticación do cliente, ou o método de autenticación non está soportado.
+        invalid_grant: A validación da autorización proporcionada non é valida, caducou, foi rexeitada, non coincide a redirección URI utilizada na petición de autorización, ou foi proporcionada para outro cliente.
+        invalid_redirect_uri: A uri de redirección incluída non é válida.
+        invalid_request: A petición fáltalle un parámetro requerido, inclúe un valor de parámetro non soportado, ou de algún xeito non ten o formato axeitado.
+        invalid_resource_owner: As credenciais do dono do recurso proporcionadas non son válidas, ou o dono do recurso non pode ser atopado
+        invalid_scope: O permiso solicitado non é válido, descoñecido, ou mal formado.
+        invalid_token:
+          expired: O testemuño de acceso caducou
+          revoked: O testemuño de acceso foi rexeitado
+          unknown: O testemuño de acceso non é válido
+        resource_owner_authenticator_not_configured: O dono do recurso fallou debido a Doorkeeper.configure.resource_owner_authenticator non estar configurado.
+        server_error: O servidor de autorización atopou un problema non agardado que evitou completar a petición.
+        temporarily_unavailable: O servidor de autorización non pode atender a petición en este momento debido a unha sobrecarga puntual ou mantemento do servidor.
+        unauthorized_client: O cliente non está autorizado a realizar a petición utilizando este método.
+        unsupported_grant_type: O método para proporcionar autorización non está soportado polo servidor de autorización.
+        unsupported_response_type: O servidor de autorización non soporta este tipo de resposta.
+    flash:
+      applications:
+        create:
+          notice: Aplicativo creado.
+        destroy:
+          notice: Aplicativo eliminado.
+        update:
+          notice: Aplicativo actualizado.
+      authorized_applications:
+        destroy:
+          notice: Aplicativo rexeitado.
+    layouts:
+      admin:
+        nav:
+          applications: Aplicativos
+          oauth2_provider: Proveedor OAuth2
+      application:
+        title: Precisa autorización OAuth
+    scopes:
+      follow: seguir, bloquear, desbloquear e deixar de seguir contas
+      read: ler os datos da súa conta
+      write: publicar no seu nome
diff --git a/config/locales/doorkeeper.nl.yml b/config/locales/doorkeeper.nl.yml
index 5c9c9047f..7ad10f45b 100644
--- a/config/locales/doorkeeper.nl.yml
+++ b/config/locales/doorkeeper.nl.yml
@@ -84,11 +84,11 @@ nl:
         invalid_redirect_uri: De opgegeven redirect-URI is ongeldig.
         invalid_request: Het verzoek mist een vereiste parameter, bevat een niet ondersteunde parameterwaarde of is anderszins onjuist.
         invalid_resource_owner: De verstrekte resource-eigenaargegevens zijn ongeldig of de resource-eigenaar kan niet worden gevonden
-        invalid_scope: De opgevraagde scope is ongeldig, onbekend of onjuist.
+        invalid_scope: De opgevraagde toestemming is ongeldig, onbekend of onjuist.
         invalid_token:
-          expired: Het toegangssleutel is verlopen
-          revoked: Het toegangssleutel is geweigerd
-          unknown: Het toegangssleutel is ongeldig
+          expired: Toegangscode verlopen
+          revoked: Toegangscode ingetrokken
+          unknown: Toegangscode ongeldig
         resource_owner_authenticator_not_configured: Het opzoeken van de resource-eigenaar is mislukt omdat Doorkeeper.configure.resource_owner_authenticator niet is ingesteld.
         server_error: De autorisatieserver is is een onverwachte situatie tegengekomen die het verzoek verhinderde.
         temporarily_unavailable: De autorisatieserver is momenteel niet in staat het verzoek te behandelen als gevolg van een tijdelijke overbelasting of onderhoud aan de server.
diff --git a/config/locales/doorkeeper.pt.yml b/config/locales/doorkeeper.pt.yml
index 039b90ffa..e76cd01fd 100644
--- a/config/locales/doorkeeper.pt.yml
+++ b/config/locales/doorkeeper.pt.yml
@@ -5,7 +5,7 @@ pt:
       doorkeeper/application:
         name: Nome da Aplicação
         redirect_uri: URL de redirecionamento
-        scopes: Scopes
+        scopes: Autorizações
         website: Site da Aplicação
     errors:
       models:
@@ -33,13 +33,14 @@ pt:
       help:
         native_redirect_uri: Usa %{native_redirect_uri} para testes locais
         redirect_uri: Utiliza uma linha por URI
-        scopes: Separate scopes with spaces. Leave blank to use the default scopes.
+        scopes: Separa autorizações com espaços. Deixa em branco para usar autorizações predefinidas.
       index:
         application: Aplicações
-        callback_url: Callback URL
+        callback_url: URL de retorno
         delete: Eliminar
         name: Nome
         new: Nova Aplicação
+        scopes: Autorizações
         show: Mostrar
         title: As tuas aplicações
       new:
@@ -48,7 +49,7 @@ pt:
         actions: Ações
         application_id: Id de Aplicação
         callback_urls: Callback urls
-        scopes: Scopes
+        scopes: Autorizações
         secret: Segredo
         title: 'Aplicação: %{name}'
     authorizations:
@@ -72,27 +73,28 @@ pt:
         application: Aplicação
         created_at: Criada em
         date_format: "%Y-%m-%d %H:%M:%S"
+        scopes: Autorizações
         title: As tuas aplicações autorizadas
     errors:
       messages:
-        access_denied: The resource owner or authorization server denied the request.
-        credential_flow_not_configured: Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.
-        invalid_client: Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.
-        invalid_grant: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.
-        invalid_redirect_uri: The redirect uri included is not valid.
-        invalid_request: The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.
-        invalid_resource_owner: The provided resource owner credentials are not valid, or resource owner cannot be found
-        invalid_scope: The requested scope is invalid, unknown, or malformed.
+        access_denied: O proprietário do recurso ou servidor de autorização negou o pedido.
+        credential_flow_not_configured: As credenciais da palavra-passe do proprietário do recurso falhou devido a que Doorkeeper.configure.resource_owner_from_credentials não foram configuradas.
+        invalid_client: Autenticação do cliente falhou por causa de um cliente desconhecido, nenhum cliente de autenticação incluído ou método de autenticação não suportado.
+        invalid_grant: A concessão de autorização fornecida é inválida, expirou, foi revogada, não corresponde à URI de redirecionamento usada no pedido de autorização ou foi emitida para outro cliente.
+        invalid_redirect_uri: A URI de redirecionamento incluída não é válida.
+        invalid_request: A solicitação não possui um parâmetro requerido, inclui um valor  não suportado ou tem outro tipo de formato incorreto.
+        invalid_resource_owner: As credenciais do proprietário do recurso não são válidas ou o proprietário do recurso não pode ser encontrado
+        invalid_scope: O âmbito solicitado é inválido, desconhecido ou tem um formato incorreto.
         invalid_token:
           expired: O token de acesso expirou
           revoked: O token de acesso foi revogado
           unknown: O token de acesso é inválido
-        resource_owner_authenticator_not_configured: Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfiged.
-        server_error: The authorization server encountered an unexpected condition which prevented it from fulfilling the request.
-        temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.
-        unauthorized_client: The client is not authorized to perform this request using this method.
-        unsupported_grant_type: The authorization grant type is not supported by the authorization server.
-        unsupported_response_type: The authorization server does not support this response type.
+        resource_owner_authenticator_not_configured: A procura pelo proprietário do recurso falhou porque Doorkeeper.configure.resource_owner_authenticator não foi configurado.
+        server_error: O servidor de autorização encontrou uma condição inesperada que impediu o cumprimento do pedido .
+        temporarily_unavailable: O servidor de autorização não é capaz de lidar com o pedido devido a uma sobrecarga ou mantenimento do servidor.
+        unauthorized_client: O cliente não está autorizado a realizar esta solicitação usando este método.
+        unsupported_grant_type: O tipo de concessão de autorização não é suportado pelo servidor de autorização.
+        unsupported_response_type: O servidor de autorização não suporta este tipo de resposta.
     flash:
       applications:
         create:
@@ -108,10 +110,10 @@ pt:
       admin:
         nav:
           applications: Aplicações
-          oauth2_provider: OAuth2 Provider
+          oauth2_provider: Fornecedor OAuth2
       application:
         title: Autorização OAuth necessária
     scopes:
       follow: siga, bloqueie, desbloqueie, e deixa de seguir contas
       read: tenha acesso aos dados da tua conta
-      write: públique por ti
+      write: publique por ti
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e4425b424..bc1e98c56 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -338,10 +338,13 @@ en:
       body: "%{reporter} has reported %{target}"
       subject: New report for %{instance} (#%{id})
   application_mailer:
+    notification_preferences: Change e-mail preferences
     salutation: "%{name},"
     settings: 'Change e-mail preferences: %{link}'
     signature: Mastodon notifications from %{instance}
     view: 'View:'
+    view_profile: View Profile
+    view_status: View status
   applications:
     created: Application successfully created
     destroyed: Application successfully deleted
@@ -432,6 +435,7 @@ en:
     changes_saved_msg: Changes successfully saved!
     powered_by: powered by %{link}
     save_changes: Save changes
+    use_this: Use this
     validation_errors:
       one: Something isn't quite right yet! Please review the error below
       other: Something isn't quite right yet! Please review %{count} errors below
@@ -464,6 +468,14 @@ en:
       expires_at: Expires
       uses: Uses
     title: Invite people
+  keyword_mutes:
+    add_keyword: Add keyword
+    edit: Edit
+    edit_keyword: Edit keyword
+    keyword: Keyword
+    match_whole_word: Match whole word
+    remove: Remove
+    remove_all: Remove all
   landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
   landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
   lists:
@@ -482,29 +494,38 @@ en:
     title: Moderation
   notification_mailer:
     digest:
-      body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'
+      action: View all notifications
+      body: Here is a brief summary of the messages you missed since your last visit on %{since}
       mention: "%{name} mentioned you in:"
       new_followers_summary:
-        one: You have acquired one new follower! Yay!
-        other: You have gotten %{count} new followers! Amazing!
+        one: Also, you have acquired one new follower while being away! Yay!
+        other: Also, you have acquired %{count} new followers while being away! Amazing!
       subject:
         one: "1 new notification since your last visit \U0001F418"
         other: "%{count} new notifications since your last visit \U0001F418"
+      title: In your absence…
     favourite:
       body: 'Your status was favourited by %{name}:'
       subject: "%{name} favourited your status"
+      title: New favourite
     follow:
       body: "%{name} is now following you!"
       subject: "%{name} is now following you"
+      title: New follower
     follow_request:
+      action: Manage follow requests
       body: "%{name} has requested to follow you"
       subject: 'Pending follower: %{name}'
+      title: New follow request
     mention:
+      action: Reply
       body: 'You were mentioned by %{name} in:'
       subject: You were mentioned by %{name}
+      title: New mention
     reblog:
       body: 'Your status was boosted by %{name}:'
       subject: "%{name} boosted your status"
+      title: New boost
   number:
     human:
       decimal_units:
@@ -552,12 +573,14 @@ en:
       blackberry: Blackberry
       chrome: Chrome
       edge: Microsoft Edge
+      electron: Electron
       firefox: Firefox
       generic: Unknown browser
       ie: Internet Explorer
       micro_messenger: MicroMessenger
       nokia: Nokia S40 Ovi Browser
       opera: Opera
+      otter: Otter
       phantom_js: PhantomJS
       qq: QQ Browser
       safari: Safari
@@ -590,8 +613,10 @@ en:
     development: Development
     edit_profile: Edit profile
     export: Data export
+    flavours: Flavours
     followers: Authorized followers
     import: Import
+    keyword_mutes: Muted keywords
     migrate: Account migration
     notifications: Notifications
     preferences: Preferences
@@ -690,8 +715,6 @@ en:
 
       <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
     title: "%{instance} Terms of Service and Privacy Policy"
-  themes:
-    default: Mastodon
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 18b93b08e..d9084787d 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -678,8 +678,6 @@ es:
 
       <p>Adaptado originalmente del <a href="https://github.com/discourse/discourse">discurso de las políticas de privacidad</a>.</p>
     title: Términos del Servicio y Políticas de Privacidad de %{instance}
-  themes:
-    default: Mastodon
   time:
     formats:
       default: "%d de %b del %Y, %H:%M"
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index 1e3bd0e8b..94d4e7594 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -39,6 +39,7 @@ fa:
     followers: پیگیران
     following: پی می‌گیرد
     media: عکس و ویدیو
+    moved_html: "%{name} حساب خود را به %{new_profile_link} منتقل کرده است:"
     nothing_here: این‌جا چیزی نیست!
     people_followed_by: کسانی که %{name} پی می‌گیرد
     people_who_follow: کسانی که %{name} را پی می‌گیرند
@@ -48,6 +49,7 @@ fa:
     reserved_username: این نام کاربری در دسترس نیست
     roles:
       admin: مدیر
+      moderator: ناظم
     unfollow: پایان پیگیری
   admin:
     account_moderation_notes:
@@ -59,13 +61,19 @@ fa:
       destroyed_msg: یادداشت مدیر با موفقیت پاک شد!
     accounts:
       are_you_sure: آیا مطمئن هستید؟
+      by_domain: دامین
       confirm: تأیید
       confirmed: تأیید شد
+      demote: تنزل‌دادن
+      disable: غیرفعال
       disable_two_factor_authentication: غیرفعال‌سازی ورود دومرحله‌ای
+      disabled: غیرفعال
       display_name: نمایش به نام
       domain: دامین
       edit: ویرایش
       email: ایمیل
+      enable: فعال
+      enabled: فعال
       feed_url: نشانی فید
       followers: پیگیران
       followers_url: نشانی پیگیران
@@ -77,7 +85,9 @@ fa:
         local: محلی
         remote: غیرمستقیم
         title: مکان
+      login_status: وضعیت ورود
       media_attachments: ضمیمه‌های تصویری
+      memorialize: تبدیل به یادمان
       moderation:
         all: همه
         silenced: بی‌صدا شده
@@ -94,6 +104,7 @@ fa:
       outbox_url: نشانی صندوق خروجی
       perform_full_suspension: انجام تعلیق کامل
       profile_url: نشانی نمایه
+      promote: ترفیع‌دادن
       protocol: پروتکل
       public: عمومی
       push_subscription_expires: عضویت از راه PuSH منقضی شد
@@ -101,6 +112,12 @@ fa:
       reset: بازنشانی
       reset_password: بازنشانی رمز
       resubscribe: اشتراک دوباره
+      role: اجازه‌ها
+      roles:
+        admin: مدیر
+        moderator: ناظم
+        staff: کارمند
+        user: کاربر
       salmon_url: نشانی Salmon
       search: جستجو
       shared_inbox_url: نشانی صندوق ورودی مشترک
@@ -117,7 +134,34 @@ fa:
       unsubscribe: لغو اشتراک
       username: نام کاربری
       web: وب
+    action_logs:
+      actions:
+        confirm_user: "%{name} نشانی ایمیل کاربر %{target} را تأیید کرد"
+        create_custom_emoji: "%{name} شکلک تازهٔ %{target} را بارگذاشت"
+        create_domain_block: "%{name} دامین %{target} را مسدود کرد"
+        create_email_domain_block: "%{name} دامین ایمیل %{target} را مسدود کرد"
+        demote_user: "%{name} مقام کاربر %{target} را تنزل داد"
+        destroy_domain_block: "%{name} دامین %{target} را باز کرد"
+        destroy_email_domain_block: "%{name} دامین ایمیل %{target} را باز کرد"
+        destroy_status: "%{name} نوشته‌ای از %{target} را پاک کرد"
+        disable_2fa_user: "%{name} اجبار ورود دومرحله‌ای را برای کاربر %{target} غیرفعال کرد"
+        disable_custom_emoji: "%{name} شکلک %{target} را غیرفعال کرد"
+        disable_user: "%{name} ورود را برای کاربر %{target} غیرفعال کرد"
+        enable_custom_emoji: "%{name} شکلک %{target} را فعال کرد"
+        enable_user: "%{name} ورود را برای کاربر %{target} فعال کرد"
+        memorialize_account: "%{name} حساب کاربر %{target} را تبدیل به صفحهٔ یادمان کرد"
+        promote_user: "%{name} کاربر %{target} را ترفیع داد"
+        reset_password_user: "%{name} رمز کاربر %{target} را بازنشاند"
+        resolve_report: "%{name} گزارش %{target} را نادیده گرفت"
+        silence_account: "%{name} حساب کاربر %{target} را خاموش (بی‌صدا) کرد"
+        suspend_account: "%{name} حساب کاربر %{target} را تعلیق کرد"
+        unsilence_account: "%{name} حساب کاربر %{target} را روشن (باصدا) کرد"
+        unsuspend_account: "%{name} حساب کاربر %{target} را از تعلیق خارج کرد"
+        update_custom_emoji: "%{name} شکلک %{target} را به‌روز کرد"
+        update_status: "%{name} نوشتهٔ %{target} را به‌روز کرد"
+      title: سیاههٔ بازرسی
     custom_emojis:
+      by_domain: دامین
       copied_msg: نسخهٔ محلی شکلک با موفقیت ساخته شد
       copy: نسخه‌برداری
       copy_failed_msg: نشد که نسخهٔ محلی این شکلک ساخته شود
@@ -130,11 +174,16 @@ fa:
       enable: فعال‌سازی
       enabled_msg: این شکلک با موفقیت فعال شد
       image_hint: پروندهٔ PNG حداکثر 50KB
+      listed: فهرست‌شده
       new:
         title: افزودن شکلک سفارشی
+      overwrite: بازنویسی
       shortcode: کد کوتاه
       shortcode_hint: دست‌کم ۲ نویسه و تنها شامل حروف، اعداد و زیرخط
       title: شکلک‌های سفارشی
+      unlisted: فهرست‌نشده
+      update_failed_msg: این شکلک نتوانست به‌روز شود
+      updated_msg: شکلک با موفقیت به‌روز شد!
       upload: بارگذاری
     domain_blocks:
       add_new: افزودن تازه
@@ -145,7 +194,7 @@ fa:
         create: مسدودسازی
         hint: مسدودسازی دامین جلوی فهرست‌شدن حساب‌ها در پایگاه داده را نمی‌گیرد، بلکه به طور خودکار روش‌های مدیریتی را روی فعالیت‌های فعلی و گذشتهٔ آن حساب‌ها اعمال می‌کند.
         severity:
-          desc_html: "<strong>بی‌صداکردن</strong> یک حساب نوشته‌های آن را برای همه (به جز پیگیرانش) ناپدید می‌کند. <strong>معلق‌کردن</strong> حساب همهٔ نوشته‌ها، تصویرها، و اطلاعات حساب را پاک می‌کند."
+          desc_html: "<strong>بی‌صداکردن</strong> یک حساب نوشته‌های آن را برای همه (به جز پیگیرانش) ناپدید می‌کند. <strong>معلق‌کردن</strong> حساب همهٔ نوشته‌ها، تصویرها، و اطلاعات حساب را پاک می‌کند. اگر فقط می‌خواهید جلوی تصویرها و ویدیوها را بگیرید <strong>هیچ</strong> را برگزینید."
           noop: هیچ
           silence: بی‌صداکردن
           suspend: معلق‌کردن
@@ -184,6 +233,13 @@ fa:
       reset: بازنشانی
       search: جستجو
       title: سرورهای شناخته‌شده
+    invites:
+      filter:
+        all: همه
+        available: در دسترس
+        expired: منقضی‌شده
+        title: فیلتر
+      title: دعوت‌ها
     reports:
       action_taken_by: انجام‌دهنده
       are_you_sure: آیا مطمئن هستید؟
@@ -222,11 +278,17 @@ fa:
         deletion:
           desc_html: هر کسی بتواند حساب خود را پاک کند
           title: فعال‌سازی پاک‌کردن حساب
+        min_invite_role:
+          disabled: هیچ کس
+          title: اجازهٔ دعوت به
         open:
           desc_html: همه بتوانند حساب باز کنند
           title: امکان ثبت نام
+      show_staff_badge:
+        desc_html: نمایش علامت همکار روی صفحهٔ کاربر
+        title: نمایش علامت همکار
       site_description:
-        desc_html: روی صفحهٔ اصلی نمایش می‌یابد و همچنین به عنوان تگ‌های HTML.<br>می‌توانید HTML بنویسید, به‌ویژه <code>&lt;a&gt;</code> و <code>&lt;em&gt;</code>.
+        desc_html: معرفی کوتاهی که روی صفحهٔ اصلی و همچنین به عنوان فرادادهٔ صفحه‌ها نمایش می‌یابد. می‌توانید HTML بنویسید, به‌ویژه <code>&lt;a&gt;</code> و <code>&lt;em&gt;</code>.
         title: دربارهٔ سایت
       site_description_extended:
         desc_html: جای خوبی برای نوشتن سیاست‌های کاربری، قانون‌ها، راهنماها، و هر چیزی که ویژهٔ این سرور است. تگ‌های HTML هم مجاز است
@@ -270,7 +332,7 @@ fa:
       body: کاربر %{reporter} کاربر %{target} را گزارش داد
       subject: گزارش تازه‌ای برای %{instance} (#%{id})
   application_mailer:
-    salutation: "%{name},"
+    salutation: "%{name}،"
     settings: 'تغییر تنظیمات ایمیل: %{link}'
     signature: اعلان‌های ماستدون از %{instance}
     view: 'نمایش:'
@@ -283,15 +345,17 @@ fa:
     warning: خیلی مواظب این اطلاعات باشید و آن را به هیچ کس ندهید!
     your_token: کد دسترسی شما
   auth:
-    agreement_html: پیش از عضو شدن باید <a href="%{rules_path}">شرایط استفاده</a> و <a href="%{terms_path}">سیاست رازداری</a> ما را بپذیرید.
+    agreement_html: پیش از عضو شدن باید <a href="%{rules_path}">قوانین این سرور</a> و <a href="%{terms_path}">شرایط استفادهٔ</a> ما را بپذیرید.
     change_password: امنیت
     delete_account: پاک‌کردن حساب
     delete_account_html: اگر می‌خواهید حساب خود را پاک کنید، از <a href="%{path}">این‌جا</a> پیش بروید. از شما درخواست تأیید خواهد شد.
     didnt_get_confirmation: راهنمایی برای تأیید را دریافت نکردید؟
     forgot_password: رمزتان را گم کرده‌اید؟
-    invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one.
+    invalid_reset_password_token: کد بازنشانی رمز نامعتبر یا منقضی شده است. لطفاً کد دیگری درخواست کنید.
     login: ورود
     logout: خروج
+    migrate_account: نقل مکان به یک حساب دیگر
+    migrate_account_html: اگر می‌خواهید این حساب را به حساب دیگری منتقل کنید، <a href="%{path}">این‌جا را کلیک کنید</a>.
     register: عضو شوید
     resend_confirmation: راهنمایی برای تأیید را دوباره بفرست
     reset_password: بازنشانی رمز
@@ -339,7 +403,7 @@ fa:
     '500':
       content: شرمنده، یک چیزی از سمت ما اشتباه شده.
       title: این صفحه درست نیست
-    noscript_html: برای استفاده از نسخهٔ تحت وب ماستدون، لطفاً جاوااسکریپت را فعال کنید. یا به جایش می‌توانید یک اپ ماستدون را به‌کار ببرید.
+    noscript_html: برای استفاده از نسخهٔ تحت وب ماستدون، لطفاً جاوااسکریپت را فعال کنید. یا به جایش می‌توانید <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">یک اپ ماستدون</a> را به‌کار ببرید.
   exports:
     blocks: حساب‌های مسدودشده
     csv: CSV
@@ -373,12 +437,43 @@ fa:
       following: فهرست پی‌گیری‌ها
       muting: فهرست بی‌صداشده‌ها
     upload: بارگذاری
+  in_memoriam_html: به یادبود.
+  invites:
+    delete: غیرفعال‌سازی
+    expired: منقضی‌شده
+    expires_in:
+      '1800': ۳۰ دقیقه
+      '21600': ۶ ساعت
+      '3600': ۱ ساعت
+      '43200': ۱۲ ساعت
+      '86400': ۱ روز
+    expires_in_prompt: هیچ وقت
+    generate: ساختن
+    max_uses:
+      one: ۱ بار
+      other: "%{count} بار"
+    max_uses_prompt: نامحدود
+    prompt: با ساختن و اشتراک‌گذاری یک پیوند، اجازهٔ دسترسی به این سرور را به دیگران بدهید
+    table:
+      expires_at: تاریخ انقضا
+      uses: استفاده‌ها
+    title: دعوت دیگران
   landing_strip_html: "<strong>%{name}</strong> کاربری روی %{link_to_root_path} است. شما با داشتن حساب روی هر سروری می‌توانید نوشته‌های او را پیگیری کرده یا با او ارتباط داشته باشید."
   landing_strip_signup_html: اگر هنوز حسابی ندارید <a href="%{sign_up_path}">این‌جا حساب باز کنید</a>.
+  lists:
+    errors:
+      limit: از این بیشتر نمی‌شود فهرست داشت
   media_attachments:
     validations:
       images_and_video: نمی‌توان برای نوشته‌ای که تصویر دارد ویدیو بارگذاری کرد
       too_many: نمی‌توان بیشتر از ۴ تصویر بارگذاری کرد
+  migrations:
+    acct: username@domain حساب تازه
+    currently_redirecting: 'نمایهٔ شما منتقل می‌شود به:'
+    proceed: ذخیره
+    updated_msg: تنظیمات نقل مکان حساب شما با موفقیت به‌روز شد!
+  moderation:
+    title: مدیریت
   notification_mailer:
     digest:
       body: 'خلاصه‌ای از آن‌چه از زمان آخرین بازدید شما در %{since} روی %{instance} رخ داد :'
@@ -491,6 +586,7 @@ fa:
     export: برون‌سپاری داده‌ها
     followers: پیگیران مورد تأیید
     import: درون‌ریزی
+    migrate: انتقال حساب
     notifications: اعلان‌ها
     preferences: ترجیحات
     settings: تنظیمات
@@ -500,11 +596,12 @@ fa:
     open_in_web: بازکردن در وب
     over_character_limit: از حد مجاز %{max} حرف فراتر رفتید
     pin_errors:
-      limit: نوشته‌های ثابت بیش از حد
+      limit: از این بیشتر نمی‌شود نوشته‌های ثابت داشت
       ownership: نوشته‌های دیگران را نمی‌توان ثابت کرد
       private: نوشته‌های غیرعمومی را نمی‌توان ثابت کرد
       reblog: بازبوق‌ها را نمی‌توان ثابت کرد
     show_more: نمایش
+    title: '%{name}: "%{quote}"'
     visibilities:
       private: خصوصی
       private_long: نمایش تنها به پیگیران
@@ -587,12 +684,14 @@ fa:
 
       <p>این نوشته اقتباسی است از <a href="https://github.com/discourse/discourse">سیاست رازداری Discourse</a>.</p>
     title: شرایط استفاده و سیاست رازداری %{instance}
+  themes:
+    default: ماستدون
   time:
     formats:
       default: "%d %b %Y, %H:%M"
   two_factor_authentication:
     code_hint: برای تأیید، کدی را که برنامهٔ تأییدکننده ساخته است وارد کنید
-    description_html: اگر <strong>ورود دومرحله‌ای</strong> را فعال کنید، برای ورود به سیستم به تلفن خود نیاز خواهید داشت تا برایتان یک کد موقتی بسازد
+    description_html: اگر <strong>ورود دومرحله‌ای</strong> را فعال کنید، برای ورود به سیستم به تلفن خود نیاز خواهید داشت تا برایتان یک کد موقتی بسازد.
     disable: غیرفعال‌کردن
     enable: فعال‌کردن
     enabled: ورود دومرحله‌ای فعال است
@@ -603,7 +702,7 @@ fa:
     manual_instructions: 'اگر نمی‌توانید کدها را اسکن کنید و باید آن‌ها را دستی وارد کنید، متن کد امنیتی این‌جاست:'
     recovery_codes: پشتیبان‌گیری از کدهای بازیابی
     recovery_codes_regenerated: کدهای بازیابی با موفقیت ساخته شدند
-    recovery_instructions_html: اگر تلفن خود را گم کردید، می‌توانید با یکی از کدهای بازیابی زیر کنترل حساب خود را به دست بگیرید. این کدها را در جای امنی نگه دارید، مثلاً آن‌ها را چاپ کنید و کنار سایر مدارک مهم خود قرار دهید
+    recovery_instructions_html: اگر تلفن خود را گم کردید، می‌توانید با یکی از کدهای بازیابی زیر کنترل حساب خود را به دست بگیرید. <strong>این کدها را در جای امنی نگه دارید.</strong> مثلاً آن‌ها را چاپ کنید و کنار سایر مدارک مهم خود قرار دهید
     setup: راه اندازی
     wrong_code: کدی که وارد کردید نامعتبر بود! آیا ساعت سرور و ساعت دستگاه شما درست تنظیم شده‌اند؟
   users:
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 33a307d74..adcb11f18 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -684,8 +684,6 @@ fr:
 
       <p>Originellement adapté à partir de la politique de confidentialité de <a href="https://github.com/discourse/discourse">Discourse</a>.</p>
     title: "%{instance} Conditions d’utilisations et politique de confidentialité"
-  themes:
-    default: Mastodon
   time:
     formats:
       default: "%d %b %Y, %H:%M"
diff --git a/config/locales/gl.yml b/config/locales/gl.yml
new file mode 100644
index 000000000..ca72bf183
--- /dev/null
+++ b/config/locales/gl.yml
@@ -0,0 +1,710 @@
+---
+gl:
+  about:
+    about_hashtag_html: Estas son mensaxes públicas etiquetadas con <strong>#%{hashtag}</strong>. Pode interactuar con elas si ten unha conta nalgures do fediverso.
+    about_mastodon_html: Mastodon é unha rede social que se basea en protocolos web abertos e libres, software de código aberto. É descentralizada como o correo electrónico.
+    about_this: Sobre
+    closed_registrations: O rexistro en esta instancia está pechado en este intre. Porén! Pode atopar unha instancia diferente para obter unha conta e ter acceso exactamente a misma rede desde alí.
+    contact: Contacto
+    contact_missing: Non establecido
+    contact_unavailable: N/A
+    description_headline: Qué é %{domain}?
+    domain_count_after: outras instancias
+    domain_count_before: Conectada a
+    extended_description_html: |
+      <h3>Un bo lugar para regras</h3>
+      <p>A descrición extendida aínda non se proporcionou.</p>
+    features:
+      humane_approach_body: Aprendendo dos erros de outras redes, Mastodon intenta tomar decisións éticas de deseño para loitar contra os usos incorrectos da rede.
+      humane_approach_title: Unha aproximación máis humana
+      not_a_product_body: Mastodon non é unha rede comercial. Sen anuncios, sen minería de datos, sen xardíns privados. Non hai autoridade centralizada.
+      not_a_product_title: Vostede é unha persona, non un producto
+      real_conversation_body: Con 500 caracteres a súa disposición e soporte para contido ao por menor e avisos sobre o contido, pode expresarse vostede do xeito que queira.
+      real_conversation_title: Construído para conversacións reais
+      within_reach_body: Existen múltiples aplicativos para iOS, Android e outras plataformas grazas a un entorno API amigable para o desenvolvedor que lle permite estar ao tanto cos seus amigos en calquer lugar.
+      within_reach_title: Sempre en contacto
+    find_another_instance: Atope outra instancia
+    generic_description: "%{domain} é un servidor na rede"
+    hosted_on: Mastodon aloxado en %{domain}
+    learn_more: Coñeza máis
+    other_instances: Listado de instancias
+    source_code: Código fonte
+    status_count_after: estados
+    status_count_before: Quen escribeu
+    user_count_after: usuarias
+    user_count_before: Inicio de
+    what_is_mastodon: Qué é Mastodon?
+  accounts:
+    follow: Seguir
+    followers: Seguidoras
+    following: Seguindo
+    media: Medios
+    moved_html: "%{name} mudouse a %{new_profile_link}:"
+    nothing_here: Nada por aquí!
+    people_followed_by: Personas que segue %{name}
+    people_who_follow: Personas que seguen a %{name}
+    posts: Mensaxes
+    posts_with_replies: Toots e respostas
+    remote_follow: Seguimento remoto
+    reserved_username: O nome de usuaria está reservado
+    roles:
+      admin: Admin
+      moderator: Mod
+    unfollow: Deixar de seguir
+  admin:
+    account_moderation_notes:
+      account: Moderador
+      create: Crear
+      created_at: Data
+      created_msg: Nota a moderación creada con éxito!
+      delete: Eliminar
+      destroyed_msg: Nota a moderación destruída con éxito!
+    accounts:
+      are_you_sure: Está segura?
+      by_domain: Dominio
+      confirm: Confirmar
+      confirmed: Confirmado
+      demote: Degradar
+      disable: Deshabilitar
+      disable_two_factor_authentication: Deshabilitar 2FA
+      disabled: Deshabilitado
+      display_name: Mostrar nome
+      domain: Dominio
+      edit: Editar
+      email: E-mail
+      enable: Habilitar
+      enabled: Habilitado
+      feed_url: URL fonte
+      followers: Seguidoras
+      followers_url: URL das seguidoras
+      follows: Segue
+      inbox_url: URL da Caixa de entrada
+      ip: IP
+      location:
+        all: Todo
+        local: Local
+        remote: Remoto
+        title: Lugar
+      login_status: Estado da conexión
+      media_attachments: Anexos de medios
+      memorialize: Convertir a lembranza
+      moderation:
+        all: Todo
+        silenced: Acalado
+        suspended: Suspendido
+        title: Moderación
+      moderation_notes: Notas de moderación
+      most_recent_activity: Actividade máis recente
+      most_recent_ip: IP máis recente
+      not_subscribed: Non suscrita
+      order:
+        alphabetic: Alfabética
+        most_recent: Máis recente
+        title: Orde
+      outbox_url: URL caixa de saída
+      perform_full_suspension: Suspender completamente
+      profile_url: URL do perfil
+      promote: Promocionar
+      protocol: Protocolo
+      public: Público
+      push_subscription_expires: A suscrición PuSH caduca
+      redownload: Actualizar avatar
+      reset: Restablecer
+      reset_password: Restablecer contrasinal
+      resubscribe: Voltar a suscribir
+      role: Permisos
+      roles:
+        admin: Administrador
+        moderator: Moderador
+        staff: Membresía
+        user: Usuaria
+      salmon_url: URL Salmon
+      search: Busca
+      shared_inbox_url: URL da caixa de entrada compartida
+      show:
+        created_reports: Informes creados por esta conta
+        report: informar
+        targeted_reports: Informes feitos sobre esta conta
+      silence: Acalar
+      statuses: Estados
+      subscribe: Subscribir
+      title: Contas
+      undo_silenced: Desfacer acalar
+      undo_suspension: Desfacer suspensión
+      unsubscribe: Non subscribir
+      username: Nome de usuaria
+      web: Web
+    action_logs:
+      actions:
+        confirm_user: "%{name} comfirmou o enderezo de correo da usuaria %{target}"
+        create_custom_emoji: "%{name} subeu un novo emoji %{target}"
+        create_domain_block: "%{name} bloqueou o dominio %{target}"
+        create_email_domain_block: "%{name} engadeu a lista negra o dominio de correo %{target}"
+        demote_user: "%{name} degradou a usuaria %{target}"
+        destroy_domain_block: "%{name} desbloqueou o dominio %{target}"
+        destroy_email_domain_block: "%{name} meteu na lista blanca de correo o dominio %{target}"
+        destroy_status: "%{name} eliminou o estado de %{target}"
+        disable_2fa_user: "%{name} deshabilitou o requerimento 2FA para usuaria %{target}"
+        disable_custom_emoji: "%{name} deshabilitou emoji %{target}"
+        disable_user: "%{name} deshabilitou a conexión para a usuaria  %{target}"
+        enable_custom_emoji: "%{name} habilitou emoji %{target}"
+        enable_user: "%{name} habilitou a conexión para a usuaria %{target}"
+        memorialize_account: "%{name} converteu a conta de  %{target} nunha páxina para a lembranza"
+        promote_user: "%{name} promoveu a usuaria %{target}"
+        reset_password_user: "%{name} restableceu o contrasinal da usuaria %{target}"
+        resolve_report: "%{name} rexeitou o informe %{target}"
+        silence_account: "%{name} acalou a conta de %{target}"
+        suspend_account: "%{name} suspendeu a conta de %{target}"
+        unsilence_account: "%{name} deulle voz a conta de %{target}"
+        unsuspend_account: "%{name} activou a conta de %{target}"
+        update_custom_emoji: "%{name} actualizou emoji %{target}"
+        update_status: "%{name} actualizou un estado de %{target}"
+      title: Rexistro de auditoría
+    custom_emojis:
+      by_domain: Dominio
+      copied_msg: Creouse con éxito unha copia local dos emoji
+      copy: Copiar
+      copy_failed_msg: Non se puido facer copia local de ese emoji
+      created_msg: Creou o emoji con satisfactoriamente!
+      delete: Eliminar
+      destroyed_msg: Emojo destruído satisfactoriamente!
+      disable: Deshabilitar
+      disabled_msg: Deshabilitouse correctamente ese emoji
+      emoji: Emoji
+      enable: Habilitar
+      enabled_msg: Habilitouse correctamente ese emoji
+      image_hint: PNG ate 50KB
+      listed: Listado
+      new:
+        title: Engadir novo emoji personalizado
+      overwrite: Sobrescribir
+      shortcode: Código corto
+      shortcode_hint: Cando menos 2 caracteres, só caracteres alfanuméricos e subliñados
+      title: Emojis personalizados
+      unlisted: Non listado
+      update_failed_msg: Non se puido actualizar ese emoji
+      updated_msg: Actualizouse correctamente o emoji!
+      upload: Subir
+    domain_blocks:
+      add_new: Engadir novo
+      created_msg: Estase a procesar o bloqueo do dominio
+      destroyed_msg: Desfixose a acción de bloqueo de dominio
+      domain: Dominio
+      new:
+        create: Crear bloque
+        hint: O bloqueo do dominio non previrá a creación de entradas de contas na base de datos, pero aplicará de xeito retroactivo e automático regras específicas de moderación sobre esas contas.
+        severity:
+          desc_html: "<strong>Silenciar</strong> fará invisibles as mensaxes das contas para calquera que non os siga. <strong>Suspender</strong> eliminará todo o contido das contas, ficheiros de medios, e datos de perfil. Utilice <strong>Ningún</strong> si só quere rexeitar ficheiros de medios."
+          noop: Ningún
+          silence: Silenciar
+          suspend: Suspender
+        title: Novo bloqueo de dominio
+      reject_media: Rexeitar ficheiros de medios
+      reject_media_hint: Eliminar ficheiros de medios almacenados localmente e rexeita descargalos no futuro. Irrelevante para as suspensións
+      severities:
+        noop: Ningún
+        silence: Silenciar
+        suspend: Suspender
+      severity: Severidade
+      show:
+        affected_accounts:
+          one: Afectoulle a unha conta na base de datos
+          other: Afectoulle a %{count} contas na base de datos
+        retroactive:
+          silence: Non silenciar todas as contas existentes de este dominio
+          suspend: Non suspender todas as contas existentes de este dominio
+        title: Desfacer o bloqueo de dominio para %{domain}
+        undo: Desfacer
+      title: Bloqueos de domino
+      undo: Desfacer
+    email_domain_blocks:
+      add_new: Engadir novo
+      created_msg: Engadeuse correctamente o dominio de email a lista negra
+      delete: Eliminar
+      destroyed_msg: Eliminouse correctamente o dominio de e-mail da lista negra
+      domain: Dominio
+      new:
+        create: Engadir dominio
+        title: Nova entrada la lista negra de e-mail
+      title: Lista negra de E-mail
+    instances:
+      account_count: Contas coñecidas
+      domain_name: Dominio
+      reset: Restablecer
+      search: Buscar
+      title: Instancias coñecidas
+    invites:
+      filter:
+        all: Todo
+        available: Dispoñible
+        expired: Cadudado
+        title: Filtro
+      title: Convida
+    reports:
+      action_taken_by: Acción tomada por
+      are_you_sure: Está segura?
+      comment:
+        label: Comentario
+        none: Nada
+      delete: Eliminar
+      id: ID
+      mark_as_resolved: Marcar como resolto
+      nsfw:
+        'false': Non agochar anexos de medios
+        'true': Agochar anexos de medios
+      report: 'Informe #%{id}'
+      report_contents: Contidos
+      reported_account: Conta reportada
+      reported_by: Reportada por
+      resolved: Resolto
+      silence_account: Acalar conta
+      status: Estado
+      suspend_account: Suspender conta
+      target: Obxetivo
+      title: Informes
+      unresolved: Non resolto
+      view: Vista
+    settings:
+      bootstrap_timeline_accounts:
+        desc_html: Separar múltiples nomes de usuaria con vírgulas. Só funcionarán as contas locais non bloqueadas. Si baldeiro, por omisión son todos os local admin.
+        title: Seguimentos por omisión para novas usuarias
+      contact_information:
+        email: e-mail de traballo
+        username: Nome de usuaria de contacto
+      registrations:
+        closed_message:
+          desc_html: Mostrado na páxina de portada cando o rexistro está pechado. Pode utilizar etiquetas HTML
+          title: Mensaxe de rexistro pechado
+        deletion:
+          desc_html: Permitirlle a calquera que elimine a súa conta
+          title: Abrir o borrado da conta
+        min_invite_role:
+          disabled: Ninguén
+          title: Permitir convites por
+        open:
+          desc_html: Permitir que calquera poida crear unha conta
+          title: Abrir rexistro
+      show_staff_badge:
+        desc_html: Mostrar unha insignia de membresía nunha páxina de usuaria
+        title: Mostrar insigna de membresía
+      site_description:
+        desc_html: Parágrafo de presentación na páxina principal e nas meta etiquetas. Pode utilizar etiquetas HTML, en particular <code>&lt;a&gt;</code> e <code>&lt;em&gt;</code>.
+        title: Descrición da instancia
+      site_description_extended:
+        desc_html: Un bo lugar para o seu código de conducta, regras, guías e outras cousas que distingan a súa instancia. Pode utilizar etiquetas HTML
+        title: Información extendida da personalización
+      site_terms:
+        desc_html: Pode escribir a súa propia política de intimidade, termos de servizo ou aclaracións legais. Pode utilizar etiquetas HTML
+        title: Termos de servizo personalizados
+      site_title: Nome da instancia
+      thumbnail:
+        desc_html: Utilizado para vistas previsas vía OpenGraph e API. Recoméndase 1200x630px
+        title: Icona da instancia
+      timeline_preview:
+        desc_html: Mostrar liña de tempo pública na páxina de inicio
+        title: vista previa da liña temporal
+      title: Axustes do sitio
+    statuses:
+      back_to_account: Voltar a páxina da conta
+      batch:
+        delete: Eliminar
+        nsfw_off: NSFW apagado
+        nsfw_on: NSFW acendido
+      execute: Executar
+      failed_to_execute: Fallou a execución
+      media:
+        hide: Agochar medios
+        show: Mostar medios
+        title: Medios
+      no_media: Sen medios
+      title: Estados da conta
+      with_media: con medios
+    subscriptions:
+      callback_url: URL de chamada
+      confirmed: Confirmado
+      expires_in: Caduca en
+      last_delivery: Última entrega
+      title: WebSub
+      topic: Asunto
+    title: Administración
+  admin_mailer:
+    new_report:
+      body: "%{reporter} informou sobre %{target}"
+      subject: Novo informe sobre %{instance} (#%{id})
+  application_mailer:
+    salutation: "%{name},"
+    settings: 'Mudar as preferencias de e-mail: %{link}'
+    signature: Notificacións Mastodon de %{instance}
+    view: 'Vista:'
+  applications:
+    created: Creouse con éxito este aplicativo
+    destroyed: Eliminouse con éxito o aplicativo
+    invalid_url: A URL proporcionada non é válida
+    regenerate_token: Votar a xenerar o testemuño de acceso
+    token_regenerated: Rexenerouse con éxito o testemuño de acceso
+    warning: Teña moito tino con estos datos. Nunca os comparta con ninguén!
+    your_token: O seu testemuño de acceso
+  auth:
+    agreement_html: Rexistrándose acorda seguir <a href="%{rules_path}">as normas da instancia</a> e <a href="%{terms_path}">os termos do servizo</a>.
+    change_password: Seguridade
+    delete_account: Eliminar conta
+    delete_account_html: Se desexa eliminar a súa conta, pode <a href="%{path}">facelo aquí</a>. Pediráselle confirmación.
+    didnt_get_confirmation: Non recibeu as instruccións de confirmación?
+    forgot_password: Esqueceu o contrasinal?
+    invalid_reset_password_token: O testemuño para restablecer o contrasinal non é válido ou caducou. Por favor solicite un novo.
+    login: Conectar
+    logout: Desconectar
+    migrate_account: Mover a unha conta diferente
+    migrate_account_html: Si desexa redirixir esta conta hacia outra diferente, pode <a href="%{path}">configuralo aquí</a>.
+    register: Rexistro
+    resend_confirmation: Voltar a enviar intruccións de confirmación
+    reset_password: Restablecer contrasinal
+    set_new_password: Establecer novo contrasinal
+  authorize_follow:
+    error: Desgraciadamente, algo fallou ao buscar a conta remota
+    follow: Seguir
+    follow_request: 'Enviou unha petición de seguimento a:'
+    following: 'Parabéns! Está a seguir a:'
+    post_follow:
+      close: Ou, pode pechar esta ventá.
+      return: Voltar ao perfil da usuaria
+      web: Ir a web
+    title: Seguir %{acct}
+  datetime:
+    distance_in_words:
+      about_x_hours: "%{count}h"
+      about_x_months: "%{count}mes"
+      about_x_years: "%{count}a"
+      almost_x_years: "%{count}a"
+      half_a_minute: Agora
+      less_than_x_minutes: "%{count}m"
+      less_than_x_seconds: Agora
+      over_x_years: "%{count}a"
+      x_days: "%{count}d"
+      x_minutes: "%{count}m"
+      x_months: "%{count}mes"
+      x_seconds: "%{count}s"
+  deletes:
+    bad_password_msg: Bo intento, hackers! Contrasinal incorrecto
+    confirm_password: Introduza o seu contrasinal para verificar a súa identidade
+    description_html: Esto eliminará de xeito <strong>permanente e irreversible</strong> o contido da súa conta e será desactivada. O seu nome de usuaria permanecerá reservado para evitar futuras confusións de identidades.
+    proceed: Eliminar conta
+    success_msg: A súa conta eliminouse correctamente
+    warning_html: Só se garantiza a eliminación de contido de esta instancia. O contido que foi compartido con outras instancias é probable que deixe rastros. O servidores fora de liña e servidores que se desuscribiron das súas actualizacións non actualizarán as súas bases de datos.
+    warning_title: Dispoñibilidade do contido espallado
+  errors:
+    '403': Non ten permiso para ver esta páxina.
+    '404': A páxina que está a buscar non existe.
+    '410': A páxina que busca xa non existe.
+    '422':
+      content: Fallou a verificación de seguridade. Está bloqueando as cookies?
+      title: Fallou a verficación de seguridade
+    '429': Acelerado
+    '500':
+      content: Sentímolo, pero algo do noso lado falloou.
+      title: Esta páxina non é correcta
+    noscript_html: Para utilizar a aplicación web de Mastodon debe habilitar JavaScript. De xeito alternativo, intente unha das <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">apps nativas</a> para Mastodon da súa plataforma.
+  exports:
+    blocks: A bloquear
+    csv: CSV
+    follows: A seguir
+    mutes: Acalou
+    storage: Almacenamento de medios
+  followers:
+    domain: Dominio
+    explanation_html: Se quere asegurar a intimidade dos seus estados, debe ser consciente de quen a está a seguir. <strong>Os seus estados privados son enviados a todas as instancias onde ten seguidoras</strong>. Podería querer revisalas, e elminar seguidoras si non confía que a súa intimidade sexa respetada polos administradores ou o software de esa instancia.
+    followers_count: Número de seguidoras
+    lock_link: Bloquear a súa conta
+    purge: Eliminar das seguidoras
+    success:
+      one: En proceso de bloquear seguidoras de un dominio...
+      other: No proceso de bloquear seguidoras de %{count} dominios...
+    true_privacy_html: Por favor teña en conta que <strong>a verdadeira intimidade só pode ser conseguida con cifrado de extremo-a-extremo</strong>.
+    unlocked_warning_html: Calquera pode seguila para inmediatamente ver os seus estados privados. %{lock_link} para poder revisar e rexeitar seguidoras.
+    unlocked_warning_title: A súa conta non está pechada
+  generic:
+    changes_saved_msg: Cambios gardados correctamente!!
+    powered_by: a funcionar grazas a %{link}
+    save_changes: Gardar cambios
+    validation_errors:
+      one: Algo non está ben de todo! Por favor revise abaixo o erro
+      other: Algo aínda non está ben! Por favor revise os %{count} erros abaixo
+  imports:
+    preface: Pode importar os datos que exportou de outra instancia, tales como a lista de usuarias que está a seguir ou bloquear.
+    success: Os seus datos foron correctamente subidos e serán procesados ao momento
+    types:
+      blocking: Lista de bloqueo
+      following: Lista de seguimento
+      muting: Lista de usuarias acaladas
+    upload: Subir
+  in_memoriam_html: Lembranzas.
+  invites:
+    delete: Desactivar
+    expired: Caducou
+    expires_in:
+      '1800': 30 minutos
+      '21600': 6 horas
+      '3600': 1 hora
+      '43200': 12 horas
+      '86400': 1 día
+    expires_in_prompt: Nunca
+    generate: Xerar
+    max_uses:
+      one: 1 uso
+      other: "%{count} usos"
+    max_uses_prompt: Sen límite
+    prompt: Xerar e compartir ligazóns con outras para permitir acceso a esta instancia
+    table:
+      expires_at: Caduca
+      uses: Usos
+    title: Convidar xente
+  landing_strip_html: "<strong>%{name}</strong> é unha usuaria en %{link_to_root_path}.  Pode seguilas ou interactuar con elas si ten unha conta en algún lugar do fediverso."
+  landing_strip_signup_html: Si non, pode <a href="%{sign_up_path}">rexistrarse aquí</a>.
+  lists:
+    errors:
+      limit: Acadou o número máximo de listas
+  media_attachments:
+    validations:
+      images_and_video: Non pode anexar un vídeo a un estado que xa contén imaxes
+      too_many: Non pode anexar máis de 4 ficheiros
+  migrations:
+    acct: nomeusuaria@dominio da nova conta
+    currently_redirecting: 'O seu perfil está listo para redirixir a:'
+    proceed: Gardar
+    updated_msg: O axuste de migración da conta actualizouse correctamente!
+  moderation:
+    title: Moderación
+  notification_mailer:
+    digest:
+      body: 'Aquí ten un breve sumario do que perdeu en %{instance} desde a última visita en %{since}:'
+      mention: "%{name} mencionouna en:"
+      new_followers_summary:
+        one: Ten unha nova seguidora! Ben!
+        other: Obtivo %{count} novas seguidoras! Tremendo!
+      subject:
+        one: "1 nova notificación desde a súa última visita \U0001F418"
+        other: "%{count} novas notificacións desde a súa última visita \U0001F418"
+    favourite:
+      body: 'O seu estado foi marcado favorito por %{name}:'
+      subject: "%{name} marcou favorito o seu estado"
+    follow:
+      body: "%{name} agora está a seguila!"
+      subject: "%{name} agora está a seguila"
+    follow_request:
+      body: "%{name} solicitou poder seguila"
+      subject: 'Seguidora pendente: %{name}'
+    mention:
+      body: 'Foi mencionada por %{name} en:'
+      subject: Vostede foi mencionada por %{name}
+    reblog:
+      body: 'O seu estado foi promocionado por %{name}:'
+      subject: "%{name} promocionou o seu estado"
+  number:
+    human:
+      decimal_units:
+        format: "%n%u"
+        units:
+          billion: B
+          million: M
+          quadrillion: Q
+          thousand: K
+          trillion: T
+  pagination:
+    next: Seguinte
+    prev: Previo
+    truncate: "&hellip;"
+  preferences:
+    languages: Idiomas
+    other: Outro
+    publishing: Publicando
+    web: Web
+  push_notifications:
+    favourite:
+      title: "%{name} marcou favorito o seu estado"
+    follow:
+      title: "%{name} agora está a seguila"
+    group:
+      title: "%{count} notificacións"
+    mention:
+      action_boost: Promover
+      action_expand: Mostar máis
+      action_favourite: Favorito
+      title: "%{name} mencionouna"
+    reblog:
+      title: "%{name} promocionou un dos seus estados"
+  remote_follow:
+    acct: Introduza o seu nomedeusuaria@dominio desde onde quere facer seguimento
+    missing_resource: Non se puido atopar o URL de redirecionamento requerido para a súa conta
+    proceed: Proceda para seguir
+    prompt: 'Vostede vai seguir:'
+  sessions:
+    activity: Última actividade
+    browser: Navegador
+    browsers:
+      alipay: Alipay
+      blackberry: Blackberry
+      chrome: Chrome
+      edge: Microsoft Edge
+      firefox: Firefox
+      generic: Navegador descoñecido
+      ie: Internet Explorer
+      micro_messenger: MicroMessenger
+      nokia: Navegador Nokia S40 Ovi
+      opera: Opera
+      phantom_js: PhantomJS
+      qq: Navegador QQ
+      safari: Safari
+      uc_browser: UCBrowser
+      weibo: Weibo
+    current_session: Sesión actual
+    description: "%{browser} en %{platform}"
+    explanation: Estos son os navegadores web nos que actualmente ten sesión aberta.
+    ip: IP
+    platforms:
+      adobe_air: Adobe Air
+      android: Android
+      blackberry: Blackberry
+      chrome_os: ChromeOS
+      firefox_os: Firefox OS
+      ios: iOS
+      linux: Linux
+      mac: Mac
+      other: plataforma descoñecida
+      windows: Windows
+      windows_mobile: Windows Mobile
+      windows_phone: Windows Phone
+    revoke: Revocar
+    revoke_success: A sesión revocouse con éxito
+    title: Sesións
+  settings:
+    authorized_apps: Apps autorizados
+    back: Voltar a Mastodon
+    delete: Eliminación da conta
+    development: Desenvolvemento
+    edit_profile: Editar perfil
+    export: Exportar datos
+    followers: Seguidoras autorizadas
+    import: Importar
+    migrate: Migrar conta
+    notifications: Notificacións
+    preferences: Preferencias
+    settings: Axustes
+    two_factor_authentication: Validar Doble Factor
+    your_apps: Os seus aplicativos
+  statuses:
+    open_in_web: Abrir na web
+    over_character_limit: Excedeu o límite de caracteres %{max}
+    pin_errors:
+      limit: Xa fixou o número máximo permitido de mensaxes
+      ownership: Non pode fixar a mensaxe de outra usuaria
+      private: As mensaxes non-públicas non poden ser fixadas
+      reblog: Non se poden fixar as mensaxes promovidas
+    show_more: Mostrar máis
+    title: '%{name}: "%{quote}"'
+    visibilities:
+      private: Só seguidoras
+      private_long: Mostrar só as seguidoras
+      public: Público
+      public_long: Visible para calquera
+      unlisted: Non listado
+      unlisted_long: Visible para calquera, pero non listado en liñas de tempo públicas
+  stream_entries:
+    click_to_show: Pulse para mostrar
+    pinned: Mensaxe fixada
+    reblogged: promocionada
+    sensitive_content: Contido sensible
+  terms:
+    body_html: |
+      <h2>Política de intimidade</h2>
+
+      <h3 id="collect">Qué información recollemos?</h3>
+
+      <p>Recollemos información sobre vostede cando se rexistra no noso sitio web e recollemos datos cando participa no foro lendo, escribindo e evaluando o contido compartido aquí. </p>
+
+      <p>Cando se rexistra no noso sitio, podería solicitarselle o seu nome e enderezo de correo electrónico. Vostede podería, porén, visitar o noso sitio sin rexistrarse. O seu enderezo de correo será verificado con un correo con unha ligazón única. Si esa ligazón é visitada, saberemos que vostede controla ese enderezo de correo.</p>
+
+      <p>Cando se rexistra e publica, almacenamos o enderezo IP desde onde publicou. Poderiamos tamén gardar rexistros do servidor que inclúan a IP de cada petición ao realizada ao servidor. </p>
+
+      <h3 id="use">Con qué fin utilizamos a súa información?</h3>
+
+      <p>Toda a información recollida de vostede podería ser utilizada dos seguintes xeitos:</p>
+
+      <ul>
+        <li>Para individualizar a súa experiencia  &mdash; a súa información axúdanos a respostar mellor as súas necesidades individuais.</li>
+        <li>Para mellorar o noso sitio &mdash;esforzámonos en mellorar o que ofrece o noso sitio baseándonos na información e críticas que vostede nos proporciona.</li>
+        <li>Para mellorar o servizo ao cliente &mdash;a súa información axúdanos a respostar máis eficientemente as súas peticións de servizo ao cliente e axuda.</li>
+        <li>Para enviar correos electrónicos periodicamente &mdash; O enderezo de correo que nos proporciona podería ser utilizado para enviarlle información, notificacións que solicitou sobre cambios ou asuntos  ou en resposta ao ser nome de usuaria, responder a enquisas, e/ou outras peticións ou cuestións.</li>
+      </ul>
+
+      <h3 id="protect">Cómo protexemos a súa información?</h3>
+
+      <p>Implementamos varias medidas de seguridade para manter a seguiridade da súa información personal cando introduce, envía ou accede a súa información personal.</p>
+
+      <h3 id="data-retention">Qué é a política de retención dos seus datos?</h3>
+
+      <p>Faremos un sincero esforzo para:</p>
+
+      <ul>
+        <li>Manter rexistros do sistema con enderezos IP de todas as peticións feitas a este servidor no seu nome non máis de 90 días.</li>
+        <li>Manter os enderezos IP asociados a usuarias rexistradas e as súas mensaxes non máis de 5 anos. </li>
+      </ul>
+
+      <h3 id="cookies">Utilizamos cookies?</h3>
+
+      <p>Si. As cookies son pequenos ficheiros que un sitio ou o seu proveedor de servizo transfire ao disco duro da súa computadora a través do navegador web (se vostede o permite). Estas cookies permiten ao sitio recoñer o seu navegador e, si ten unha conta rexistrada, asocialo coa súa conta.</p>
+
+      <p>Utilizamos cookies para comprender e gardar as súas preferencias para futuras visitas e compilar datos agregados sobre o tráfico do sitio e interacción co sitio de tal xeito que no futuro poidamos ofrecer unha mellor experiencia de uso do sitio e ferramentas. Poderiamos contratar servizos de terceiros para axudarnos a entender mellor as nosas visitantes. Estos proveedores de servizo non teñen permiso para utilizar a información recollida no noso nome excepto para axudarnos a xestionar e mellorar o noso negocio. </p>
+
+      <h3 id="disclose">Mostramos información a terceiros alleos?</h3>
+
+      <p>Non vendemos, nin negociamos con, ou transmitimos de outros xeitos a axentes terceiros alleos a información que a información que a identifica personalmente. Esto non inclúe terceiros de confianza que non axudan a operar o sitio, xestionar o negocio, ou servila, así estas partes se comprometan coa confidencialidade dos datos. Poderiamos  revelar a súa información cando creamos que facelo así é axeitado para cumplir coa lei, cumplir coas normas do sitio ou protexer os nosos dereitos, propiedades ou seguridade e os de outras. Porén, non se proporcionará información identificable a terceiros para publicidade, márquetin ou outros usos.</p>
+
+      <h3 id="third-party">Ligazóns a terceiros</h3>
+
+      <p>De xeito ocasional, a nosa discreción, poderiamos incluír ofertas de productos e servizos de terceiros no noso sitio. Estos sitios de terceiros teñen políticas de intimidade propias. Polo tanto non temos responsabilidade ou obligacións polo contido e actividades de esos sitios. Con todo, procuramos protexer a integridade do noso sitio e agradecemos calquer opinión e crítica sobre estos sitios.</p>
+
+      <h3 id="coppa">Cumplimento coa Children's Online Privacy Protection Act</h3>
+
+      <p>O noso sitio, productos e servizos están dirixidos as personas con 13 anos como mínimo. Si este servidor está en USA e vostede ten menos de 13 anos, a requerimento da COPPA (<a href="https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Children's Online Privacy Protection Act</a>) non utilice este sitio web.</p>
+
+      <h3 id="online">Política de intimidade só en liña</h3>
+
+      <p>Esta política de intimidade aplícase só a información recollida no noso sitio e non a información recollida fora de liña. </p>
+
+      <h3 id="consent">O seu consentimento</h3>
+
+      <p>Utilizando o noso sitio, vostede acepta esta política de intimidade.</p>
+
+      <h3 id="changes">Cambios na política de intimidade</h3>
+
+      <p>Si decidimos cambiar a nosa política de intimidade publicaremos esos cambios en esta páxina.</p>
+
+      <p>Este documento ten licenza CC-BY-SA. Foi actualizado o 31 de maio de 2013.</p>
+
+      <p>Adaptado do orixinal <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
+    title: "%{instance} Termos do Servizo e Política de Intimidade"
+  themes:
+    default: Mastodon
+  time:
+    formats:
+      default: "%d %b, %Y, %H:%M"
+  two_factor_authentication:
+    code_hint: Introducir o código xerado polo seu aplicativo de autenticación para confirmar
+    description_html: Si habilita a <strong>autenticación de doble factor</strong>, a conexión pediralle estar en posesión do seu teléfono, que xerará testemuños para poder entrar.
+    disable: Deshabilitar
+    enable: Habilitar
+    enabled: A autenticación de doble-factor está habilitada
+    enabled_success: Habilitouse con éxito a autenticación de doble-factor
+    generate_recovery_codes: Xerar códigos de recuperación
+    instructions_html: "<strong>Escanee este código QR en Google Authenticator ou aplicativo TOTP similar no seu teléfono</strong>. Desde agora, este aplicativo xerará testemuños que vostede deberá introducir ao conectarse."
+    lost_recovery_codes: Os códigos de recuperación permítenlle recuperar o acceso a súa conta si perde o teléfono. Si perde os códigos de recuperación, pode restauralos aquí. Os seus códigos de recuperación anteriores serán invalidados.
+    manual_instructions: 'Si non pode escanear o código QR e precisa introducilo manualmente, aquí está o testemuño secreto en texto plano:'
+    recovery_codes: Códigos de recuperación do respaldo
+    recovery_codes_regenerated: Códigos de recuperación xerados correctamente
+    recovery_instructions_html: Si perdese o acceso ao seu teléfono, pode utilizar un dos códigos inferiores de recuperación para recuperar o acceso a súa conta. <strong>Garde os códigos en lugar seguro</strong>. Por exemplo, pode imprimilos e gardalos xunto con outros documentos importantes.
+    setup: Configurar
+    wrong_code: O código introducido non é válido! Son correctas as horas no dispositivo e o servidor?
+  users:
+    invalid_email: O enderezo de correo non é válido
+    invalid_otp_token: Código de doble-factor non válido
+    signed_in_as: 'Rexistrada como:'
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index f3f23b8f6..44b89e859 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -351,7 +351,7 @@ ja:
     warning: このデータは気をつけて取り扱ってください。不特定多数の人と共有しないでください!
     your_token: アクセストークン
   auth:
-    agreement_html: 登録すると <a href="%{rules_path}">利用規約</a> と <a href="%{terms_path}">プライバシーポリシー</a> に同意したことになります。
+    agreement_html: 登録すると <a href="%{rules_path}">インスタンスのルール</a> と <a href="%{terms_path}">利用規約</a> に従うことに同意したことになります。
     change_password: セキュリティ
     delete_account: アカウントの削除
     delete_account_html: アカウントを削除したい場合、<a href="%{path}">こちら</a> から手続きが行えます。削除する前に、確認画面があります。
@@ -432,6 +432,7 @@ ja:
     changes_saved_msg: 正常に変更されました!
     powered_by: powered by %{link}
     save_changes: 変更を保存
+    use_this: これを使う
     validation_errors:
       one: エラーが発生しました! 以下のエラーを確認してください
       other: エラーが発生しました! 以下の%{count}個のエラーを確認してください
@@ -464,6 +465,14 @@ ja:
       expires_at: 有効期限
       uses: 使用
     title: 新規ユーザーの招待
+  keyword_mutes:
+    add_keyword: キーワードを追加
+    edit: 編集
+    edit_keyword: キーワードを編集
+    keyword: キーワード
+    match_whole_word: 単語全体が一致
+    remove: 削除
+    remove_all: すべて削除
   landing_strip_html: "<strong>%{name}</strong> さんはインスタンス %{link_to_root_path} のユーザーです。アカウントさえ持っていればフォローしたり会話したりできます。"
   landing_strip_signup_html: もしお持ちでないなら <a href="%{sign_up_path}">こちら</a> からサインアップできます。
   lists:
@@ -590,6 +599,7 @@ ja:
     development: 開発
     edit_profile: プロフィールを編集
     export: データのエクスポート
+    flavours: フレーバー
     followers: 信頼済みのインスタンス
     import: データのインポート
     migrate: アカウントの引っ越し
@@ -607,6 +617,7 @@ ja:
       private: 非公開のトゥートを固定することはできません
       reblog: ブーストされたトゥートを固定することはできません
     show_more: もっと見る
+    title: '%{name}: "%{quote}"'
     visibilities:
       private: 非公開
       private_long: フォロワーにのみ表示されます
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 9e3505a72..997dc4856 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -48,8 +48,8 @@ ko:
     remote_follow: 리모트 팔로우
     reserved_username: 이 아이디는 예약되어 있습니다.
     roles:
-      admin: Admin
-      moderator: 모드
+      admin: 관리자
+      moderator: 모더레이터
     unfollow: 팔로우 해제
   admin:
     account_moderation_notes:
@@ -105,7 +105,7 @@ ko:
       perform_full_suspension: 완전히 정지시키기
       profile_url: 프로필 URL
       promote: 모더레이터로 승급
-      protocol: Protocol
+      protocol: 프로토콜
       public: 전체 공개
       push_subscription_expires: PuSH 구독 기간 만료
       redownload: 아바타 업데이트
@@ -133,7 +133,7 @@ ko:
       undo_suspension: 정지 해제
       unsubscribe: 구독 해제
       username: 아이디
-      web: Web
+      web: 웹
     action_logs:
       actions:
         confirm_user: "%{name}이 %{target}의 이메일 주소를 컨펌했습니다"
@@ -338,9 +338,12 @@ ko:
       body: "%{reporter} 가 %{target} 를 신고했습니다"
       subject: "%{instance} 에 새 신고 등록됨 (#%{id})"
   application_mailer:
+    notification_preferences: 메일 설정 변경
     settings: '메일 설정을 변경: %{link}'
     signature: Mastodon %{instance} 인스턴스로에서 알림
     view: 'View:'
+    view_profile: 프로필 보기
+    view_status: 게시물 보기
   applications:
     created: 애플리케이션이 작성되었습니다.
     destroyed: 애플리케이션이 삭제되었습니다.
@@ -481,6 +484,7 @@ ko:
     title: 모더레이션
   notification_mailer:
     digest:
+      action: 모든 알림 보기
       body: "%{instance} 에서 마지막 로그인 뒤로 일어난 일:"
       mention: "%{name} 님이 답장했습니다:"
       new_followers_summary:
@@ -489,21 +493,29 @@ ko:
       subject:
         one: "1건의 새로운 알림 \U0001F418"
         other: "%{count}건의 새로운 알림 \U0001F418"
+      title: 당신이 없는 동안에…
     favourite:
       body: "%{name} 님이 내 툿을 즐겨찾기에 등록했습니다."
       subject: "%{name} 님이 내 툿을 즐겨찾기에 등록했습니다"
+      title: 새 즐겨찾기
     follow:
       body: "%{name} 님이 나를 팔로우 했습니다"
       subject: "%{name} 님이 나를 팔로우 했습니다"
+      title: 새 팔로워
     follow_request:
+      action: 팔로우 요청 관리
       body: "%{name} 님이 내게 팔로우 요청을 보냈습니다."
       subject: "%{name} 님이 보낸 팔로우 요청"
+      title: 새 팔로우 요청
     mention:
+      action: 답장
       body: "%{name} 님이 답장을 보냈습니다:"
       subject: "%{name} 님이 답장을 보냈습니다"
+      title: 새 멘션
     reblog:
       body: "%{name} 님이 내 툿을 부스트 했습니다:"
       subject: "%{name} 님이 내 툿을 부스트 했습니다"
+      title: 새 부스트
   number:
     human:
       decimal_units:
@@ -547,38 +559,38 @@ ko:
     activity: 마지막 활동
     browser: 브라우저
     browsers:
-      alipay: Alipay
-      blackberry: Blackberry
-      chrome: Chrome
-      edge: Microsoft Edge
-      firefox: Firefox
+      alipay: 알리페이
+      blackberry: 블랙베리
+      chrome: 크롬
+      edge: 엣지
+      firefox: 파이어폭스
       generic: 알 수 없는 브라우저
-      ie: Internet Explorer
+      ie: IE
       micro_messenger: MicroMessenger
-      nokia: Nokia S40 Ovi Browser
-      opera: Opera
+      nokia: 노키아 S40 Ovi 브라우저
+      opera: 오페라
       phantom_js: PhantomJS
-      qq: QQ Browser
-      safari: Safari
+      qq: QQ 브라우저
+      safari: 사파리
       uc_browser: UCBrowser
-      weibo: Weibo
+      weibo: 웨이보
     current_session: 현재 세션
-    description: "%{browser} on %{platform}"
+    description: "%{platform}의 %{browser}"
     explanation: 내 Mastodon 계정에 현재 로그인 중인 웹 브라우저 목록입니다.
     ip: IP
     platforms:
-      adobe_air: Adobe Air
-      android: Android
-      blackberry: Blackberry
-      chrome_os: ChromeOS
-      firefox_os: Firefox OS
+      adobe_air: 어도비 에어
+      android: 안드로이드
+      blackberry: 블랙베리
+      chrome_os: 크롬OS
+      firefox_os: 파이어폭스OS
       ios: iOS
-      linux: Linux
-      mac: Mac
+      linux: 리눅스
+      mac: 맥
       other: 알 수 없는 플랫폼
-      windows: Windows
-      windows_mobile: Windows Mobile
-      windows_phone: Windows Phone
+      windows: 윈도우즈
+      windows_mobile: 윈도우즈 모바일
+      windows_phone: 윈도우즈 폰
     revoke: 삭제
     revoke_success: 세션이 삭제되었습니다.
     title: 세션
@@ -621,8 +633,6 @@ ko:
     sensitive_content: 민감한 컨텐츠
   terms:
     title: "%{instance} 이용약관과 개인정보 취급 방침"
-  themes:
-    default: Mastodon
   time:
     formats:
       default: "%Y년 %m월 %d일 %H:%M"
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 83c556708..a0add9bfa 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -2,7 +2,7 @@
 nl:
   about:
     about_hashtag_html: Dit zijn openbare toots die getagged zijn met <strong>#%{hashtag}</strong>. Je kunt er op reageren of iets anders mee doen als je op Mastodon (of ergens anders in de fediverse) een account hebt.
-    about_mastodon_html: Mastodon is een <em>vrij, gratis en open-source</em> sociaal netwerk. Een <em>gedecentraliseerd</em> alternatief voor commerciële platforms. Het voorkomt de risico's van een enkel bedrijf dat jouw communicatie monopoliseert. Kies een server die je vertrouwt &mdash; welke je ook kiest, je kunt met elke andere server communiceren. Iedereen kan een eigen Mastodon-server draaien en naadloos deelnemen in het <em>sociale netwerk</em>.
+    about_mastodon_html: Mastodon is een sociaal netwerk dat gebruikt maakt van open webprotocollen en vrije software. Het is net zoals e-mail gedecentraliseerd.
     about_this: Over deze server
     closed_registrations: Registreren op deze server is momenteel uitgeschakeld.
     contact: Contact
@@ -684,8 +684,6 @@ nl:
 
       <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
     title: "%{instance} Terms of Service and Privacy Policy"
-  themes:
-    default: Mastodon
   time:
     formats:
       default: "%d %B %Y om %H:%M"
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index 94bd0a592..8aa6d2731 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -116,6 +116,7 @@ pl:
       roles:
         admin: Administrator
         moderator: Moderator
+        staff: Ekipa
         user: Użytkownik
       salmon_url: Adres Salmon
       search: Szukaj
@@ -160,7 +161,7 @@ pl:
         update_status: "%{name} zaktualizował wpis użytkownika %{target}"
       title: Dziennik działań administracyjnych
     custom_emojis:
-      by_domain: Według domeny
+      by_domain: Domeny
       copied_msg: Pomyślnie utworzono lokalną kopię emoji
       copy: Kopiuj
       copy_failed_msg: Nie udało się utworzyć lokalnej kopii emoji
@@ -351,7 +352,7 @@ pl:
     warning: Przechowuj te dane ostrożnie. Nie udostępniaj ich nikomu!
     your_token: Twój token dostępu
   auth:
-    agreement_html: Rejestrując się, oświadczasz, że zapoznałeś się z <a href="%{rules_path}">naszymi zasadami użytkowania</a> i <a href="%{terms_path}">polityką prywatności</a>.
+    agreement_html: Rejestrując się, oświadczasz, że zapoznałeś się z <a href="%{rules_path}">informacjami o instancji</a> i <a href="%{terms_path}">zasadami korzystania z usługi</a>.
     change_password: Bezpieczeństwo
     delete_account: Usunięcie konta
     delete_account_html: Jeżeli chcesz usunąć konto, <a href="%{path}">przejdź tutaj</a>. Otrzymasz prośbę o potwierdzenie.
@@ -432,6 +433,7 @@ pl:
     changes_saved_msg: Ustawienia zapisane!
     powered_by: uruchomione na %{link}
     save_changes: Zapisz zmiany
+    use_this: Użyj tego
     validation_errors:
       one: Coś jest wciąż nie tak! Przyjrzyj się błędowi poniżej
       other: Coś jest wciąż nie tak! Przejrzyj błędy (%{count}) poniżej
@@ -466,6 +468,14 @@ pl:
       expires_at: Wygaśnie po
       uses: Użycia
     title: Zaproś użytkowników
+  keyword_mutes:
+    add_keyword: Dodaj słowo kluczowe
+    edit: Edytuj
+    edit_keyword: Edytuj słowo kluczowe
+    keyword: Słowo kluczowe
+    match_whole_word: Uwzględniaj całe słowo
+    remove: Usuń
+    remove_all: Usuń wszystkie
   landing_strip_html: "<strong>%{name}</strong> ma konto na %{link_to_root_path}. Możesz je śledzić i wejść z nim w interakcję jeśli masz konto gdziekolwiek w Fediwersum."
   landing_strip_signup_html: Jeśli jeszcze go nie masz, możesz <a href="%{sign_up_path}">stworzyć konto</a>.
   lists:
@@ -596,8 +606,10 @@ pl:
     development: Tworzenie aplikacji
     edit_profile: Edytuj profil
     export: Eksportowanie danych
+    flavours: Odmiany
     followers: Autoryzowani śledzący
     import: Importowanie danych
+    keyword_mutes: Wyciszone słowa
     migrate: Migracja konta
     notifications: Powiadomienia
     preferences: Preferencje
@@ -696,8 +708,6 @@ pl:
 
       <p>Tekst bazuje na <a href="https://github.com/discourse/discourse">polityce prywatności Discourse</a>.</p>
     title: Zasady korzystania i polityka prywatności %{instance}
-  themes:
-    default: Mastodon
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml
index e83a6cf42..d4bf72da3 100644
--- a/config/locales/pt-BR.yml
+++ b/config/locales/pt-BR.yml
@@ -1,10 +1,10 @@
 ---
 pt-BR:
   about:
-    about_hashtag_html: Estes são toots públicos com a hashtag <strong>#%{hashtag}</strong>. Voce pode interagir com eles se tiver uma conta em qualquer lugar no fediverso.
+    about_hashtag_html: Estes são toots públicos com a hashtag <strong>#%{hashtag}</strong>. Você pode interagir com eles se tiver uma conta em qualquer lugar no fediverso.
     about_mastodon_html: Mastodon é uma rede social baseada em protocolos abertos e software gratuito e de código aberto. É descentralizada como e-mail.
     about_this: Sobre
-    closed_registrations: Cadastros estão atualmente fechados nesta instância. No entanto! Você pode procurar uma instância diferente na qual possa criar uma conta e acessar a mesma rede por lá.
+    closed_registrations: Os cadastros estão atualmente fechados nesta instância. No entanto, você pode procurar uma instância diferente na qual possa criar uma conta e acessar a mesma rede por lá.
     contact: Contato
     contact_missing: Não definido
     contact_unavailable: N/A
@@ -21,15 +21,15 @@ pt-BR:
       not_a_product_title: Você é uma pessoa e não um produto
       real_conversation_body: Com 500 caracteres à sua disposição e suporte para conteúdo granular e avisos de conteúdo, você pode se expressar da maneira que desejar.
       real_conversation_title: Feito para conversas reais
-      within_reach_body: Vários apps para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores proporcionam que você possa se manter atualizado sobre seus amigos de qualquer lugar.
-      within_reach_title: Sempre a seu alcance
+      within_reach_body: Vários apps para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores permitem que você possa se manter atualizado sobre seus amigos de qualquer lugar.
+      within_reach_title: Sempre ao seu alcance
     find_another_instance: Encontre outra instância
     generic_description: "%{domain} é um servidor na rede"
     hosted_on: Mastodon hospedado em %{domain}
     learn_more: Saiba mais
     other_instances: Lista de instâncias
-    source_code: Código fonte
-    status_count_after: postagens
+    source_code: Código-fonte
+    status_count_after: publicações
     status_count_before: Autores de
     user_count_after: usuários
     user_count_before: Casa de
@@ -85,7 +85,7 @@ pt-BR:
         local: Local
         remote: Remoto
         title: Localização
-      login_status: Status de login
+      login_status: Situação de login
       media_attachments: Mídia(s) anexada(s)
       memorialize: Tornar um memorial
       moderation:
@@ -96,13 +96,13 @@ pt-BR:
       moderation_notes: Notas de moderação
       most_recent_activity: Atividade mais recente
       most_recent_ip: IP mais recente
-      not_subscribed: Não inscrito
+      not_subscribed: Não está inscrito
       order:
         alphabetic: Alfabética
         most_recent: Mais recente
         title: Ordem
-      outbox_url: URL da Outbox
-      perform_full_suspension: Efetue suspensão total
+      outbox_url: URL da caixa de saída
+      perform_full_suspension: Aplicar suspensão total
       profile_url: URL do perfil
       promote: Promover
       protocol: Protocolo
@@ -116,19 +116,20 @@ pt-BR:
       roles:
         admin: Administrador
         moderator: Moderador
+        staff: Equipe
         user: Usuário
       salmon_url: URL Salmon
       search: Pesquisar
-      shared_inbox_url: URL da Inbox Compartilhada
+      shared_inbox_url: URL da caixa de entrada compartilhada
       show:
         created_reports: Denúncias criadas por esta conta
         report: relatórios
         targeted_reports: Denúncias feitas sobre esta conta
-      silence: Silêncio
+      silence: Silenciar
       statuses: Postagens
       subscribe: Inscrever-se
       title: Contas
-      undo_silenced: Desativar silêncio
+      undo_silenced: Retirar silenciamento
       undo_suspension: Retirar suspensão
       unsubscribe: Desinscrever-se
       username: Nome de usuário
@@ -160,6 +161,7 @@ pt-BR:
         update_status: "%{name} atualizou o estado de %{target}"
       title: Auditar relatório
     custom_emojis:
+      by_domain: Domínio
       copied_msg: Cópia local do emoji criada com sucesso
       copy: Copiar
       copy_failed_msg: Não foi possível criar uma cópia local deste emoji
@@ -343,7 +345,7 @@ pt-BR:
     warning: Tenha cuidado com estes dados. Nunca compartilhe com alguém!
     your_token: Seu token de acesso
   auth:
-    agreement_html: Cadastrando-se você concorda com <a href="%{rules_path}">nossos termos de serviço</a> e <a href="%{terms_path}">política de privacidade</a>.
+    agreement_html: Cadastrando-se você concorda em seguir <a href="%{rules_path}">as regras da instância</a> e <a href="%{terms_path}">os nossos termos de serviço</a>.
     change_password: Segurança
     delete_account: Excluir conta
     delete_account_html: Se você deseja excluir a sua conta, você pode <a href="%{path}">prosseguir para cá</a>. Uma confirmação será requisitada.
@@ -594,11 +596,12 @@ pt-BR:
     open_in_web: Abrir na web
     over_character_limit: limite de caracteres de %{max} excedido
     pin_errors:
-      limit: Muitos toots fixados
+      limit: Você já fixou o máximo de toots possíveis
       ownership: Toots de outras pessoas não podem ser fixados
       private: Toot não-público não pode ser fixado
       reblog: Um compartilhamento não pode ser fixado
     show_more: Mostrar mais
+    title: '%{name}: "%{quote}"'
     visibilities:
       private: Apenas seguidores
       private_long: Mostrar apenas para seguidores
@@ -681,8 +684,6 @@ pt-BR:
 
       <p>Originalmente adaptado da <a href="https://github.com/discourse/discourse">política de privacidade do Discourse</a>.</p>
     title: "%{instance} Termos de Serviço e Política de Privacidade"
-  themes:
-    default: Mastodon
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index c476bac59..c0056af4f 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -4,16 +4,16 @@ pt:
     about_hashtag_html: Estes são toots públicos marcados com <strong>#%{hashtag}</strong>. Podes interagir com eles se tiveres uma conta Mastodon.
     about_mastodon_html: Mastodon é uma rede social baseada em protocolos abertos da web e software livre e gratuito. É descentralizado como e-mail.
     about_this: Sobre esta instância
-    closed_registrations: Novos registos estão fechados nesta instância.
+    closed_registrations: Novos registos estão fechados nesta instância. No entanto! Podes procurar uma instância diferente na qual criar uma conta e obter acesso à mesma rede desde lá.
     contact: Contacto
-    contact_missing: Não definido
-    contact_unavailable: N/A
+    contact_missing: Não configurado
+    contact_unavailable: n.d.
     description_headline: O que é o %{domain}?
     domain_count_after: outras instâncias
     domain_count_before: Ligado a
     extended_description_html: |
       <h3>Um bom lugar para regras</h3>
-      <p>A descrição da instância ainda não foi feita.</p>
+      <p>A descrição estendida ainda não foi configurada.</p>
     features:
       humane_approach_body: Aprendendo com erros de outras redes sociais, Mastodon tem como objetivo fazer decisões éticas de design para combater o utilização errada de redes sociais.
       humane_approach_title: Uma abordagem mais humana
@@ -87,6 +87,7 @@ pt:
         title: Local
       login_status: Estado de início de sessão
       media_attachments: Media anexa
+      memorialize: Converter em memorial
       moderation:
         all: Todos
         silenced: Silenciados
@@ -100,12 +101,13 @@ pt:
         alphabetic: Alfabética
         most_recent: Mais recente
         title: Ordem
+      outbox_url: URL da caixa de saída
       perform_full_suspension: Fazer suspensão completa
       profile_url: URL do perfil
       promote: Promover
       protocol: Protocolo
       public: Público
-      push_subscription_expires: PuSH subscription expires
+      push_subscription_expires: A Inscrição PuSH expira
       redownload: Atualizar avatar
       reset: Restaurar
       reset_password: Reset palavra-passe
@@ -114,8 +116,9 @@ pt:
       roles:
         admin: Administrador
         moderator: Moderador
+        staff: Equipa
         user: Utilizador
-      salmon_url: Salmon URL
+      salmon_url: URL Salmon
       search: Pesquisar
       shared_inbox_url: URL da caixa de entrada compartilhada
       show:
@@ -124,9 +127,11 @@ pt:
         targeted_reports: Relatórios feitos sobre esta conta
       silence: Silêncio
       statuses: Status
+      subscribe: Inscrever-se
       title: Contas
       undo_silenced: Desfazer silenciar
       undo_suspension: Desfazer supensão
+      unsubscribe: Cancelar inscrição
       username: Usuário
       web: Web
     action_logs:
@@ -137,6 +142,48 @@ pt:
         create_email_domain_block: "%{name} adicionou na lista negra o domínio de correio electrónico %{target}"
         demote_user: "%{name} rebaixou o utilizador %{target}"
         destroy_domain_block: "%{name} desbloqueou o domínio %{target}"
+        destroy_email_domain_block: "%{name} adicionou na lista branca o domínio de correio electrónico %{target}"
+        disable_2fa_user: "%{name} desactivou o requerimento de autenticação em dois passos para o utilizador %{target}"
+        disable_custom_emoji: "%{name} desabilitou o emoji %{target}"
+        disable_user: "%{name} desativou o acesso para o utilizador %{target}"
+        enable_custom_emoji: "%{name} habilitou o emoji %{target}"
+        enable_user: "%{name} ativou o acesso para o utilizador %{target}"
+        memorialize_account: "%{name} transformou a conta de %{target} em um memorial"
+        promote_user: "%{name} promoveu o utilizador %{target}"
+        reset_password_user: "%{name} restabeleceu a palavra-passe do utilizador %{target"
+        resolve_report: "%{name} recusou o relatório %{target}"
+        silence_account: "%{name} silenciou a conta de %{target}"
+        suspend_account: "%{name} suspendeu a conta de %{target}"
+        unsilence_account: "%{name} desativou o silêncio de %{target}"
+        unsuspend_account: "%{name} desativou a suspensão de  %{target}"
+        update_custom_emoji: "%{name} atualizou o emoji %{target}"
+        update_status: "%{name} atualizou o estado de %{target}"
+      title: Registo de auditoria
+    custom_emojis:
+      by_domain: Domínio
+      copied_msg: Cópia local do emoji criada com sucesso
+      copy: Copiar
+      copy_failed_msg: Não foi possível criar uma cópia local deste emoji
+      created_msg: Emoji criado com sucesso!
+      delete: Apagar
+      destroyed_msg: Emoji destruído com sucesso!
+      disable: Desativar
+      disabled_msg: Desativado com sucesso este emoji
+      emoji: Emoji
+      enable: Ativar
+      enabled_msg: Ativado com sucesso este emoji
+      image_hint: PNG de até 50KB
+      listed: Listado
+      new:
+        title: Adicionar novo emoji customizado
+      overwrite: Sobrescrever
+      shortcode: Código de atalho
+      shortcode_hint: Pelo menos 2 caracteres, apenas caracteres alfanuméricos e underscores
+      title: Emojis customizados
+      unlisted: Não listado
+      update_failed_msg: Não foi possível atualizar esse emoji
+      updated_msg: Emoji atualizado com sucesso!
+      upload: Enviar
     domain_blocks:
       add_new: Adicionar novo
       created_msg: Bloqueio do domínio está a ser processado
@@ -146,13 +193,15 @@ pt:
         create: Criar bloqueio
         hint: O bloqueio de dominio não vai previnir a criação de entradas na base de dados, mas irá retroativamente e automaticamente aplicar métodos de moderação específica nessas contas.
         severity:
-          desc_html: "<strong>Silenciar</strong> irá fazer com que os posts dessas contas sejam invisíveis para todos que não a seguem. <strong>Supender</strong> irá eliminar todo o conteúdo guardado dessa conta, mídia e informação de perfil."
+          desc_html: "<strong>Silenciar</strong> irá fazer com que os posts dessas contas sejam invisíveis para todos que não a seguem. <strong>Supender</strong> irá eliminar todo o conteúdo guardado dessa conta, media e informação de perfil.Usa <strong>Nenhum</strong> se  apenas desejas rejeitar arquivos de media."
+          noop: Nenhum
           silence: Silenciar
           suspend: Suspender
         title: Novo bloqueio de domínio
-      reject_media: Rejeitar ficheiros de mídia
-      reject_media_hint: Remove localmente arquivos armazenados e rejeita fazer guardar novos no futuro. Irrelevante na suspensão.
+      reject_media: Rejeitar ficheiros de media
+      reject_media_hint: Remove localmente arquivos armazenados e rejeita fazer guardar novos no futuro. Irrelevante na suspensão
       severities:
+        noop: Nenhum
         silence: Silenciar
         suspend: Suspender
       severity: Severidade
@@ -167,11 +216,32 @@ pt:
         undo: Anular
       title: Bloqueio de domínio
       undo: Anular
+    email_domain_blocks:
+      add_new: Adicionar novo
+      created_msg: Bloqueio de domínio de email criado com sucesso
+      delete: Eliminar
+      destroyed_msg: Bloqueio de domínio de email excluído com sucesso
+      domain: Domínio
+      new:
+        create: Adicionar domínio
+        title: Novo bloqueio de domínio de email
+      title: Bloqueio de Domínio de Email
     instances:
       account_count: Contas conhecidas
       domain_name: Domínio
+      reset: Restaurar
+      search: Pesquisar
       title: Instâncias conhecidas
+    invites:
+      filter:
+        all: Todos
+        available: Disponíveis
+        expired: Expirados
+        title: Filtro
+      title: Convites
     reports:
+      action_taken_by: Ação tomada por
+      are_you_sure: Tens a certeza?
       comment:
         label: Comentário
         none: Nenhum
@@ -179,17 +249,20 @@ pt:
       id: ID
       mark_as_resolved: Marcar como resolvido
       report: 'Denúncia #%{id}'
+      report_contents: Conteúdos
       reported_account: Conta denunciada
       reported_by: Denúnciada por
       resolved: Resolvido
       silence_account: Conta silenciada
       status: Estado
       suspend_account: Conta suspensa
-      target: Target
+      target: Alvo
       title: Denúncias
       unresolved: Por resolver
       view: Ver
     settings:
+      bootstrap_timeline_accounts:
+        title: Seguidores predefinidos para novas contas
       contact_information:
         email: Inserir um endereço de email para tornar público
         username: Insira um nome de utilizador
@@ -197,16 +270,49 @@ pt:
         closed_message:
           desc_html: Mostrar na página inicial quando registos estão encerrados<br/>Podes usar tags HTML
           title: Mensagem de registos encerrados
+        deletion:
+          desc_html: Permite a qualquer um apagar a conta
+        min_invite_role:
+          disabled: Ninguém
+          title: Permitir convites de
         open:
+          desc_html: Permitir que qualquer um crie uma conta
           title: Aceitar novos registos
+      show_staff_badge:
+        desc_html: Mostrar um crachá da equipa na página de utilizador
+        title: Mostrar crachá da equipa
       site_description:
-        desc_html: Mostrar como parágrafo na página inicial e usado como meta tag.<br/>Podes usar tags HTML, em particular <code>&lt;a&gt;</code> e <code>&lt;em&gt;</code>.
+        desc_html: Mostrar como parágrafo na página inicial e usado como meta tag.Podes usar tags HTML, em particular <code>&lt;a&gt;</code> e <code>&lt;em&gt;</code>.
         title: Descrição do site
       site_description_extended:
         desc_html: Mostrar na página de mais informações<br/>Podes usar tags HTML
         title: Página de mais informações
+      site_terms:
+        desc_html: Podes escrever a tua própria política de privacidade, termos de serviço, entre outras coisas. Podes usar tags HTML
+        title: Termos de serviço customizados
       site_title: Título do site
+      thumbnail:
+        desc_html: Usada para visualizações via OpenGraph e API. Recomenda-se 1200x630px
+        title: Miniatura da instância
+      timeline_preview:
+        desc_html: Exibir a linha temporal pública na página inicial
+        title: Visualização da linha temporal
       title: Preferências do site
+    statuses:
+      back_to_account: Voltar para página da conta
+      batch:
+        delete: Eliminar
+        nsfw_off: NSFW OFF
+        nsfw_on: NSFW ON
+      execute: Executar
+      failed_to_execute: Falhou ao executar
+      media:
+        hide: Esconder média
+        show: Mostrar média
+        title: Média
+      no_media: Não há média
+      title: Estado das contas
+      with_media: Com média
     subscriptions:
       callback_url: URL de Callback
       confirmed: Confirmado
@@ -215,37 +321,172 @@ pt:
       title: WebSub
       topic: Tópico
     title: Administração
+  admin_mailer:
+    new_report:
+      body: "%{reporter} relatou %{target}"
+      subject: Novo relatório sobre %{instance} (#%{id})
   application_mailer:
+    salutation: "%{name},"
     settings: 'Alterar preferências de email: %{link}'
     signature: notificações Mastodon do %{instance}
     view: 'Ver:'
   applications:
+    created: Aplicação criada com sucesso
+    destroyed: Aplicação eliminada com sucesso
     invalid_url: O URL é inválido
+    regenerate_token: Regenerar token de acesso
+    token_regenerated: Token de acesso regenerado com sucesso
+    warning: Cuidado com estes dados. Não partilhar com ninguém!
+    your_token: O teu token de acesso
   auth:
+    agreement_html: Registando-te concordas em seguir <a href="%{rules_path}">as regras da instância</a> e <a href="%{terms_path}">os nossos termos de serviço</a>.
     change_password: Alterar palavra-passe
+    delete_account: Eliminar conta
+    delete_account_html: Se desejas eliminar a conta, podes <a href="%{path}">continua aqui</a>. Uma confirmação será pedida.
     didnt_get_confirmation: Não recebeu o email de confirmação?
     forgot_password: Esqueceste a palavra-passe?
+    invalid_reset_password_token: Token de modificação da palavra-passe é inválido ou expirou. Por favor, solicita um novo.
     login: Entrar
+    logout: Sair
+    migrate_account: Mudar para uma conta diferente
+    migrate_account_html: Se desejas redirecionar esta conta para uma outra podes<a href="%{path}">configurar isso aqui</a>.
     register: Registar
     resend_confirmation: Reenviar instruções de confirmação
     reset_password: Criar nova palavra-passe
     set_new_password: Editar palavra-passe
+  authorize_follow:
+    error: Infelizmente, ocorreu um erro ao buscar a conta remota
+    follow: Seguir
+    follow_request: 'Enviaste uma solicitação de seguidor para:'
+    following: 'Sucesso! Agora estás a seguir a:'
+    post_follow:
+      close: Ou podes simplesmente fechar esta janela.
+      return: Voltar ao perfil do utilizador
+    title: Seguir %{acct}
+  datetime:
+    distance_in_words:
+      about_x_hours: "%{count}h"
+      about_x_months: "%{count} meses"
+      about_x_years: "%{count} anos"
+      almost_x_years: "%{count} anos"
+      half_a_minute: Justo agora
+      less_than_x_minutes: "%{count} meses"
+      less_than_x_seconds: Justo agora
+      over_x_years: "%{count} anos"
+      x_days: "%{count} dias"
+      x_minutes: "%{count} minutos"
+      x_months: "%{count} meses"
+      x_seconds: "%{count} segundos"
+  deletes:
+    bad_password_msg: Boa tentativa, hackers! Palavra-passe incorreta
+    confirm_password: Introduz a palavra-passe atual para verificar a tua identidade
+    description_html: Isto vai <strong>permanente e irreversivelmente</strong> remover conteúdo da tua conta e desativá-la. O teu nome de utilizador permanecerá reservado para prevenir futuros roubos de identidade.
+    proceed: Eliminar conta
+    success_msg: A tua conta foi eliminada com sucesso
+    warning_html: |-
+      Apenas a eliminação de conteúdo desta instância é garantido.
+      Conteúdo que tenha sido partilhado com outras instâncias muito provavelmente deixará pegadas. Servidores offline e servidores que se desinscreveram das tuas atualizações não  atualizarão as suas bases de dados.
+    warning_title: Disponibilidade de conteúdo disseminado
+  errors:
+    '403': Não tens a permissão necessária para ver esta página.
+    '404': A página que estás a procurar não existe.
+    '410': A página que estás a procurar não existe mais.
+    '422':
+      content: "A verificação de segurança falhou. \nDesativaste o uso de cookies?"
+      title: A verificação de segurança falhou
+    '500':
+      content: Desculpe, mas algo correu mal.
+      title: Esta página não está correta
+    noscript_html: Para usar o aplicativo web do Mastodon, por favor ativa o JavaScript. Alternativamente, experimenta um dos <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">apps nativos</a> para o Mastodon na sua plataforma.
+  exports:
+    blocks: Bloqueaste
+    csv: CSV
+    follows: Segues
+    mutes: Tens em silêncio
+    storage: Armazenamento de média
+  followers:
+    domain: Domínio
+    explanation_html: Se  queres garantir a privacidade das tuas publicações, deves ficar atento a quem te está a seguir.<strong>As tuas publicações privadas são enviadas para todas as instâncias nas que tens seguidores</strong>. Convém revisá-las e remover seguidores se achares que a tua privacidade não será respeitada pela equipa ou software destas instâncias.
+    followers_count: Número de seguidores
+    lock_link: Bloquear a tua conta
+    purge: Eliminar dos seguidores
+    success:
+      one: No processo de bloqueio suave de seguidores de outro domínio...
+      other: No processo de bloqueio suave de seguidores de outros %{count} domínios...
+    true_privacy_html: Por favor leva em conta que <strong>a verdadeira privacidade só pode ser alcançada através de encriptação ponto-a-ponto</strong>.
+    unlocked_warning_html: Qualquer pessoa pode seguir-te e ver as tuas publicações privadas. %{lock_link} para ser capaz de revisar e rejeitar seguidores.
+    unlocked_warning_title: A tua conta não está bloqueada
   generic:
-    changes_saved_msg: Alteraçes guardadas!
-    powered_by: powered by %{link}
+    changes_saved_msg: Alterações guardadas!
+    powered_by: fornecido por %{link}
     save_changes: Guardar alterações
     validation_errors:
       one: Algo não está correcto. Por favor vê o erro abaixo
       other: Algo não está correto. Por favor vê os %{count} erros abaixo
-  landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
+  imports:
+    preface: Podes importar dados que tenhas exportado de outra instância, como a lista de pessoas que segues ou bloqueadas.
+    success: Os teus dados foram enviados com sucesso e serão processados em breve
+    types:
+      blocking: Lista de bloqueio
+      following: Lista de pessoas que estás a seguir
+      muting: Lista de utilizadores silenciados
+    upload: Enviar
+  in_memoriam_html: Em memória.
+  invites:
+    delete: Desativar
+    expired: Expirados
+    expires_in:
+      '1800': 30 minutos
+      '21600': 6 horas
+      '3600': 1 hora
+      '43200': 12 horas
+      '86400': 1 dia
+    expires_in_prompt: Nunca
+    generate: Gerar
+    max_uses:
+      one: 1 uso
+      other: "%{count} usos"
+    max_uses_prompt: Sem limite
+    prompt: Gerar e partilhar ligações com outras pessoas para permitir acesso a essa instância
+    table:
+      expires_at: Expira
+      uses: Usos
+    title: Convidar pessoas
+  landing_strip_html: "<strong>%{name}</strong> é um utilizador em %{link_to_root_path}. Podes segui-lo ou interagir com ele se tiveres uma conta em qualquer lugar no fediverso."
   landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
+  lists:
+    errors:
+      limit: Número máximo de listas alcançado
+  media_attachments:
+    validations:
+      images_and_video: Não é possível anexar um vídeo a uma publicação que já contém imagens
+      too_many: Não é possível anexar mais de 4 arquivos
+  migrations:
+    acct: username@domain da nova conta
+    currently_redirecting: 'O teu perfil está configurado para redirecionar para:'
+    proceed: Salvar
+    updated_msg: As configurações de migração da tua conta foram atualizadas com sucesso!
+  moderation:
+    title: Moderação
   notification_mailer:
+    digest:
+      body: 'Aqui tens um breve resumo do que tens perdido em %{instance} desde o  último acesso em %{since}:'
+      mention: "%{name} mencionou-te em:"
+      new_followers_summary:
+        one: Boa! Tens um novo seguidor!
+        other: Tens %{count} novos seguidores! Fantástico!
+      subject:
+        one: "1 nova notificação desde o último acesso \U0001F418"
+        other: "%{count} novas notificações desde o  último acesso \U0001F418"
     favourite:
       body: 'O teu post foi adicionado aos favoritos por %{name}:'
       subject: "%{name} adicionou o teu post aos favoritos"
     follow:
       body: "%{name} é teu seguidor!"
       subject: "%{name} começou a seguir-te"
+    follow_request:
+      body: "%{name} solicitou autorização para te seguir"
+      subject: 'Seguidor pendente: %{name}'
     mention:
       body: 'Foste mencionado por %{name}:'
       subject: "%{name} mencionou-te"
@@ -267,43 +508,195 @@ pt:
     next: Seguinte
     prev: Anterior
     truncate: "&hellip;"
+  preferences:
+    languages: Idiomas
+    other: Outro
+    publishing: Publicação
+    web: Web
+  push_notifications:
+    favourite:
+      title: "%{name} adicionou o teu post aos favoritos"
+    follow:
+      title: "%{name} começou a seguir-te"
+    group:
+      title: "%{count} notificações"
+    mention:
+      action_boost: Partilhar
+      action_expand: Mostrar mais
+      action_favourite: Adicionar aos favoritos
+      title: "%{name} mencionou-te"
+    reblog:
+      title: "%{name} partilhou o teu post"
   remote_follow:
     acct: Entre seu usuário@domínio do qual quer seguir
     missing_resource: Não foi possível achar a URL de redirecionamento para sua conta
     proceed: Prossiga para seguir
     prompt: 'Você vai seguir:'
+  sessions:
+    activity: Última atividade
+    browser: Navegador
+    browsers:
+      alipay: Alipay
+      blackberry: Blackberry
+      chrome: Chrome
+      edge: Microsoft Edge
+      firefox: Firefox
+      generic: Navegador desconhecido
+      ie: Internet Explorer
+      micro_messenger: MicroMessenger
+      nokia: Navegador Nokia S40 Ovi
+      opera: Opera
+      phantom_js: PhantomJS
+      qq: QQ Browser
+      safari: Safari
+      uc_browser: UCBrowser
+      weibo: Weibo
+    current_session: Sessão atual
+    description: "%{browser} em %{platform}"
+    explanation: Estes são os navegadores que estão conectados com a tua conta do Mastodon.
+    ip: IP
+    platforms:
+      adobe_air: Adobe Air
+      android: Android
+      blackberry: Blackberry
+      chrome_os: ChromeOS
+      firefox_os: Firefox OS
+      ios: iOS
+      linux: Linux
+      mac: Mac
+      other: Plataforma desconhecida
+      windows: Windows
+      windows_mobile: Windows Mobile
+      windows_phone: Windows Phone
+    revoke: Revogar
+    revoke_success: Sessão revogada com sucesso
+    title: Sessões
   settings:
     authorized_apps: Aplicativos autorizados
     back: Voltar ao Mastodon
+    delete: Eliminação da conta
+    development: Desenvolvimento
     edit_profile: Editar perfil
-    export: Importar dados
+    export: Exportar dados
+    followers: Seguidores autorizados
     import: Importar
+    migrate: Migração de conta
+    notifications: Notificações
     preferences: Preferências
-    settings: Settings
-    two_factor_authentication: Autenticação Two-factor
+    settings: Configurações
+    two_factor_authentication: Autenticação em dois passos
+    your_apps: As tuas aplicações
   statuses:
     open_in_web: Abrir no browser
     over_character_limit: limite de caracter excedeu %{max}
+    pin_errors:
+      ownership: Posts de outras pessoas não podem ser fixados
+      private: Post não-público não pode ser fixado
     show_more: Mostrar mais
+    title: '%{name}: "%{quote}"'
     visibilities:
       private: Mostrar apenas para seguidores
+      private_long: Mostrar apenas para seguidores
       public: Público
+      public_long: Todos podem ver
       unlisted: Público, mas não mostre no timeline público
+      unlisted_long: Todos podem ver, porém não será postado nas timelines públicas
   stream_entries:
     click_to_show: Clique pra mostrar
+    pinned: Toot fixado
     reblogged: boosted
     sensitive_content: Conteúdo sensível
+  terms:
+    body_html: |
+      <h2>Política de privacidade</h2>
+
+      <h3 id="collect">Quais são as informações que recolhemos?</h3>
+
+      <p>Recolhemos informações quando te registas no nosso site e capturamos dados quando participas do fórum lendo, escrevendo e analisando o conteúdo aqui partilhado.</p>
+
+      <p>Quando te registas no nosso site, será requisitado que você ceda seu nome e endereço de e-mail. Você pode, porém, visitar nosso site sem se cadastrar. Seu endereço de e-mail será verificado por uma mensagem contendo um link único. Se este link for visitado, saberemos que você controla este endereço de e-mail.</p>
+
+      <p>Quando registrado e postando, nós gravamos o endereço de IP de onde a postagem se originou. Nós também podemos reter logs de serviores que incluem o endereço de IP em cada requisição para o nosso servidor.</p>
+
+      <h3 id="use">Para que usamos essas informações?</h3>
+
+      <p>Quaisquer das informações que coletamos podem ser usadas das seguintes formas:</p>
+
+      <ul>
+        <li>Para personalizar a sua experiência &mdash; suas informações nos ajudam a nos adequar melhor às suas necessidades individuais.</li>
+        <li>Para melhorar nosso site &mdash; nós continuamente nos esforçamos para aprimorar nosso site baseados na informação e no feedback que recebemos de você.</li>
+        <li>Para aprimorar o serviço ao consumidor &mdash; suas informações nos ajudam a responder efetivamente às suas requisições e solicitações por suporte.</li>
+        <li>Para mandar e-mails periódicos &mdash; O endereço de e-mail que você forneceu pode ser usado para lhe enviar informações, notificações que você requisitar sobre mudanças a determinados tópicos ou menções ao seu nome de usuário, responder requisições e/ou solicitações e perguntas.</li>
+      </ul>
+
+      <h3 id="protect">Como protegemos as suas informações?</h3>
+
+      <p>Nós implementamos uma variedade de medidas de segurança para manter a segurança de suas informações pessoais quando você insere, submete ou acessa as suas informações pessoais.</p>
+
+      <h3 id="data-retention">Qual a sua política de retenção de dados?</h3>
+
+      <p>Faremos esforços de boa fé para:</p>
+
+      <ul>
+        <li>Reter logs de servidor contendo o endereço IP de todas as requisições a este servidor por não mais que 90 dias.</li>
+        <li>Reter os endereços IP associados a usuários cadastrados e suas postagens por não mais que 5 anos.</li>
+      </ul>
+
+      <h3 id="cookies">Nós usamos cookies?</h3>
+
+      <p>Sim. Cookies são pequenos arquivos que um site ou o provedor de serviço transfere para o armazenamento interno de seu computador através de seu navegador (se você permitir). Estes cookies habilitam o site para reconhecer o seu navegador e, se você ter um cadastro, associá-lo a esta conta.</p>
+
+      <p>Nós usamos cookies para entender e salvar as suas preferências para futuras visitas e compilar dados agregados sobre o tráfego do site para que possamos oferecer melhores experiências e ferramentas no futuro. Nós podemos contratar serviços de terceiros para nos auxiliar a entender melhor nossos visitantes. Estes provedores de serviço não são autoriza usar as informações coletadas em nosso nome exceto para nos ajudar a conduzir e aprimorar nosso funcionamento.</p>
+
+      <h3 id="disclose">Nós revelamos informações para terceiros?</h3>
+
+      <p>Nós não vendemos, tocamos ou transferimos para terceiros informações pessoais que te identificam. Isso não inclui partes em que confiamos para nos ajudar a operar nosso site, conduzir nosso funcionamento ou servir você desde que estes terceiros concordem em manter essas informações em segredo. Nós também podemos prover as suas informações para obedecer ordens judiciais, reforçar nossas políticas ou proteger nossos direitos ou de outrem, propriedades ou segurança. Entretanto, informações pessoais não identificáveis podem ser enviadas para outras partes para marketing, propaganda e outros usos.</p>
+
+      <h3 id="third-party">Links de terceiros</h3>
+
+      <p>Ocasionalmente, à nossa discrição, podemos icluir ou oferecer produtos ou serviços de terceiros em nosso site. Estes terceiros têm políticas de privacidade separadas e independentes. Nós, portanto, não nos responsabilizamos pelo conteúdo e atividades destes sites de terceiros. Occasionally, at our discretion, we may include or offer third party products or services on our site. Não obstante, nós procuramos proteger a integridade de nosso site e todo feedback sobre estes sites de terceiros é bem-vindo.</p>
+
+      <h3 id="coppa">Obediência ao Ato de Proteção da Privacidade Online de Crianças</h3>
+
+      <p>Nosso site, produtos e serviços são todos direcionados a pessoas que têm pelo menos 13 anos de idade. Se este servidor estiver nos EUA, e você tiver menos de 13 anos, pelos requerimentos da COPPA (<a href="https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Children's Online Privacy Protection Act</a>) não use este site.</p>
+
+      <h3 id="online">Política de Apenas Privacidade Online</h3>
+
+      <p>Esta política de privacidade online se aplica somente a informações coletadas por nosso site e não a informações coletadas offline.</p>
+
+      <h3 id="consent">Seu Consentimento</h3>
+
+      <p>Usando o nosso site, você concorda com a nossa política de privacidade.</p>
+
+      <h3 id="changes">Mudanças em nossa Política de Privacidade</h3>
+
+      <p>Se decidirmos mudar a nossa política de privacidade, publicaremos as mudanças nesta página.</p>
+
+      <p>Este documento é CC-BY-SA. A sua última atualização aconteceu em 31 de maio de 2013.</p>
+
+      <p>Originalmente adaptado da <a href="https://github.com/discourse/discourse">política de privacidade do Discourse</a>.</p>
+    title: "%{instance} Termos de Serviço e Política de Privacidade"
+  themes:
+    default: Mastodon
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
   two_factor_authentication:
     code_hint: Entre o código gerado pelo seu aplicativo para confirmar
-    description_html: Se você habilitar <strong>autenticação two-factor </strong>, quando logar será necessário o seu telefone que vai gerar os tokens usado.
-    disable: Disabilitar
-    enable: Habilitar
-    enabled_success: Autenticador Two-factor habilitador com sucesso
+    description_html: Se ativar a <strong>autenticação em dois passos</strong>, quando logar será necessário o seu telefone que vai gerar os tokens para validação.
+    disable: Desativar
+    enable: Ativar
+    enabled: A autenticação em dois passos está ativada
+    enabled_success: Autenticação em dois passos ativada com sucesso
     generate_recovery_codes: Gerar códigos para recuperar conta
     instructions_html: "<strong>Scaneie este código QR no seu Google Authenticator ou aplicativo similar no seu telefone</strong>. A partir de agora seu aplicativo irá gerar tokens que deverão ser digitados para você logar."
     lost_recovery_codes: Códigos de recuperação permite que você recupere o acesso a sua conta se você perder seu telefone. Se você perder os códigos de recuperação, você pode regera-los aqui. Seus códigos antigos serão invalidados.
     manual_instructions: 'Se você não puder scanear o código QR e precisa digita-los manualmente, aqui está o segredo em texto.:'
+    recovery_codes: Cópia de segurança dos códigos de recuperação
     recovery_codes_regenerated: Códigos de recuperação foram gerados com sucesso
+    setup: Configurar
+    wrong_code: O código inserido é invalido! O horário do servidor e o horário do seu aparelho estão corretos?
+  users:
+    invalid_email: O endereço de e-mail é inválido
+    invalid_otp_token: Código de autenticação inválido
+    signed_in_as: 'Registado como:'
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 5eb7f256a..697a1aa27 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -39,6 +39,7 @@ ru:
     followers: Подписчики
     following: Подписан(а)
     media: Медиаконтент
+    moved_html: "%{name} переехал(а) на %{new_profile_link}:"
     nothing_here: Здесь ничего нет!
     people_followed_by: Люди, на которых подписан(а) %{name}
     people_who_follow: Подписчики %{name}
@@ -59,9 +60,13 @@ ru:
       destroyed_msg: Заметка модератора успешно удалена!
     accounts:
       are_you_sure: Вы уверены?
+      by_domain: Домен
       confirm: Подтвердить
       confirmed: Подтверждено
+      demote: Разжаловать
+      disable: Отключить
       disable_two_factor_authentication: Отключить 2FA
+      disabled: Отключено
       display_name: Отображаемое имя
       domain: Домен
       edit: Изменить
@@ -77,7 +82,9 @@ ru:
         local: Локальные
         remote: Удаленные
         title: Размещение
+      login_status: Статус аккаунта
       media_attachments: Мультимедийные вложения
+      memorialize: Превратить в Памятник
       moderation:
         all: Все
         silenced: Заглушенные
@@ -94,6 +101,7 @@ ru:
       outbox_url: URL исходящих
       perform_full_suspension: Полная блокировка
       profile_url: URL профиля
+      promote: Повысить
       protocol: Протокол
       public: Публичный
       push_subscription_expires: Подписка PuSH истекает
@@ -101,6 +109,12 @@ ru:
       reset: Сбросить
       reset_password: Сбросить пароль
       resubscribe: Переподписаться
+      role: Разрешения
+      roles:
+        admin: Администратор
+        moderator: Модератор
+        staff: Персонал
+        user: Пользователь
       salmon_url: Salmon URL
       search: Поиск
       shared_inbox_url: URL общих входящих
@@ -117,6 +131,32 @@ ru:
       unsubscribe: Отписаться
       username: Имя пользователя
       web: WWW
+    action_logs:
+      actions:
+        confirm_user: "%{name} подтвердил(а) e-mail адрес пользователя %{target}"
+        create_custom_emoji: "%{name} загрузил(а) новый эмодзи %{target}"
+        create_domain_block: "%{name} заблокировал(а) домен %{target}"
+        create_email_domain_block: "%{name} добавил(а) e-mail домен %{target} в чёрный список"
+        demote_user: "%{name} разжаловал(а) пользователя %{target}"
+        destroy_domain_block: "%{name} разблокировал(а) домен %{target}"
+        destroy_email_domain_block: "%{name} добавил(а) e-mail домен %{target} в белый список"
+        destroy_status: "%{name} удалил(а) статус пользователя %{target}"
+        disable_2fa_user: "%{name} отключил(а) двухэтапную авторизацию у пользователя %{target}"
+        disable_custom_emoji: "%{name} отключил(а) эмодзи %{target}"
+        disable_user: "%{name} запретил(а) вход пользователя %{target}"
+        enable_custom_emoji: "%{name} включил(а) эмодзи %{target}"
+        enable_user: "%{name} включил(а) вход пользователя %{target}"
+        memorialize_account: "%{name} перевел(а) аккаунт пользователя %{target} в режим памятника"
+        promote_user: "%{name} повысил(а) пользователя %{target}"
+        reset_password_user: "%{name} сбросил(а) пароль пользователя %{target}"
+        resolve_report: "%{name} dismissed report %{target}"
+        silence_account: "%{name} заглушил(а) аккаунт %{target}"
+        suspend_account: "%{name} заморозил(а) аккаунт %{target}"
+        unsilence_account: "%{name} снял(а) глушение аккаунта %{target}"
+        unsuspend_account: "%{name} разморозил(а) аккаунт %{target}"
+        update_custom_emoji: "%{name} обновил(а) эмодзи %{target}"
+        update_status: "%{name} изменил(а) статус пользователя %{target}"
+      title: Журнал событий
     custom_emojis:
       copied_msg: Локальная копия эмодзи успешно создана
       copy: Скопироват
@@ -130,11 +170,16 @@ ru:
       enable: Включить
       enabled_msg: Эмодзи успешно включено
       image_hint: PNG до 50KB
+      listed: В списке
       new:
-        title: Добавить новое эмодзи
+        title: Добавить новый эмодзи
+      overwrite: Заменить
       shortcode: Шорткод
       shortcode_hint: Как минимум 2 символа, только алфавитно-цифровые символы и подчеркивания
       title: Собственные эмодзи
+      unlisted: Не в списке
+      update_failed_msg: Невозможно обновить этот эмодзи
+      updated_msg: Эмодзи обновлён!
       upload: Загрузить
     domain_blocks:
       add_new: Добавить новую
@@ -186,6 +231,13 @@ ru:
       reset: Сбросить
       search: Поиск
       title: Известные узлы
+    invites:
+      filter:
+        all: Все
+        available: Актуальные
+        expired: Истёкшие
+        title: Фильтр
+      title: Приглашения
     reports:
       action_taken_by: 'Действие предпринято:'
       are_you_sure: Вы уверены?
@@ -199,6 +251,7 @@ ru:
         'false': Показать мультимедийные вложения
         'true': Скрыть мультимедийные вложения
       report: 'Жалоба #%{id}'
+      report_contents: Содержимое
       reported_account: Аккаунт нарушителя
       reported_by: Отправитель жалобы
       resolved: Разрешено
@@ -210,12 +263,18 @@ ru:
       unresolved: Неразрешенные
       view: Просмотреть
     settings:
+      activity_api_enabled:
+        desc_html: Подсчёт количества локальных статусов, активных пользователей и новых регистраций на еженедельной основе
+        title: Публикация агрегированной статистики активности пользователей
       bootstrap_timeline_accounts:
         desc_html: Разделяйте имена пользователей запятыми. Сработает только для локальных незакрытых аккаунтов. По умолчанию включены все локальные администраторы.
         title: Подписки по умолчанию для новых пользователей
       contact_information:
         email: Введите публичный e-mail
         username: Введите имя пользователя
+      peers_api_enabled:
+        desc_html: Домены, которые были замечены этим узлом среди всей федерации
+        title: Публикация списка обнаруженных узлов
       registrations:
         closed_message:
           desc_html: Отображается на титульной странице, когда закрыта регистрация<br>Можно использовать HTML-теги
@@ -223,9 +282,15 @@ ru:
         deletion:
           desc_html: Позволяет всем удалять собственные аккаунты
           title: Разрешить удаление аккаунтов
+        min_invite_role:
+          disabled: Никого
+          title: Разрешает приглашения от
         open:
           desc_html: Позволяет любому создавать аккаунт
           title: Открыть регистрацию
+      show_staff_badge:
+        desc_html: Показывать метку персонала на странице пользователя
+        title: Показывать метку персонала
       site_description:
         desc_html: Отображается в качестве параграфа на титульной странице и используется в качестве мета-тега.<br>Можно использовать HTML-теги, в особенности <code>&lt;a&gt;</code> и <code>&lt;em&gt;</code>.
         title: Описание сайта
@@ -293,6 +358,8 @@ ru:
     invalid_reset_password_token: Токен сброса пароля неверен или устарел. Пожалуйста, запросите новый.
     login: Войти
     logout: Выйти
+    migrate_account: Перенести аккаунт
+    migrate_account_html: Если Вы хотите перенести этот аккаунт на другой, вы можете <a href="%{path}">сделать это здесь</a>.
     register: Зарегистрироваться
     resend_confirmation: Повторить отправку инструкции для подтверждения
     reset_password: Сбросить пароль
@@ -374,12 +441,43 @@ ru:
       following: Подписки
       muting: Список глушения
     upload: Загрузить
+  in_memoriam_html: Памятник.
+  invites:
+    delete: Удалить
+    expired: Истекло
+    expires_in:
+      '1800': 30 минут
+      '21600': 6 часов
+      '3600': 1 час
+      '43200': 12 часов
+      '86400': 1 день
+    expires_in_prompt: Никогда
+    generate: Сгенерировать
+    max_uses:
+      one: 1 исп.
+      other: "%{count} исп."
+    max_uses_prompt: Без лимита
+    prompt: Генерируйте и делитесь ссылками с другими, чтобы предоставить им доступ к этому узлу.
+    table:
+      expires_at: Истекает
+      uses: Исп.
+    title: Пригласить людей
   landing_strip_html: "<strong>%{name}</strong> - пользователь на %{link_to_root_path}. Вы можете подписаться на него/нее и общаться с ним/ней, если у Вас есть аккаунт на любом узле общей сети."
   landing_strip_signup_html: Если у Вас его нет, вы можете <a href="%{sign_up_path}">зарегистрироваться здесь</a>.
+  lists:
+    errors:
+      limit: Вы достигли максимального числа списков
   media_attachments:
     validations:
       images_and_video: Нельзя добавить видео к статусу с изображениями
       too_many: Нельзя добавить более 4 файлов
+  migrations:
+    acct: имя@домен нового аккаунта
+    currently_redirecting: 'Ваш профиль будет перенаправлен на:'
+    proceed: Сохранить
+    updated_msg: Настройки миграции Вашего аккаунта обновлены!
+  moderation:
+    title: Модерация
   notification_mailer:
     digest:
       body: 'Кратко о пропущенном Вами на %{instance} с Вашего последнего захода %{since}:'
@@ -484,6 +582,8 @@ ru:
       windows: Windows
       windows_mobile: Windows Mobile
       windows_phone: Windows Phone
+    revoke: Завершить
+    revoke_success: Сессия завершена успешно
     title: Сессии
   settings:
     authorized_apps: Авторизованные приложения
@@ -494,6 +594,7 @@ ru:
     export: Экспорт данных
     followers: Авторизованные подписчики
     import: Импорт
+    migrate: Перенос аккаунта
     notifications: Уведомления
     preferences: Настройки
     settings: Опции
@@ -503,7 +604,7 @@ ru:
     open_in_web: Открыть в WWW
     over_character_limit: превышен лимит символов (%{max})
     pin_errors:
-      limit: Слишком много закрепленных статусов
+      limit: Вы закрепили максимально возможное число статусов
       ownership: Нельзя закрепить чужой статус
       private: Нельзя закрепить непубличный статус
       reblog: Нельзя закрепить продвинутый статус
@@ -517,10 +618,52 @@ ru:
       unlisted_long: Показывать всем, но не отображать в публичных лентах
   stream_entries:
     click_to_show: Показать
+    pinned: Закреплённое сообщение
     reblogged: продвинул(а)
     sensitive_content: Чувствительный контент
   terms:
+    body_html: |
+      <h2>Privacy Policy</h2>
+      <h3 id="collect">What information do we collect?</h3>
+      <p>We collect information from you when you register on our site and gather data when you participate in the forum by reading, writing, and evaluating the content shared here.</p>
+      <p>When registering on our site, you may be asked to enter your name and e-mail address. You may, however, visit our site without registering. Your e-mail address will be verified by an email containing a unique link. If that link is visited, we know that you control the e-mail address.</p>
+      <p>When registered and posting, we record the IP address that the post originated from. We also may retain server logs which include the IP address of every request to our server.</p>
+      <h3 id="use">What do we use your information for?</h3>
+      <p>Any of the information we collect from you may be used in one of the following ways:</p>
+      <ul>
+        <li>To personalize your experience &mdash; your information helps us to better respond to your individual needs.</li>
+        <li>To improve our site &mdash; we continually strive to improve our site offerings based on the information and feedback we receive from you.</li>
+        <li>To improve customer service &mdash; your information helps us to more effectively respond to your customer service requests and support needs.</li>
+        <li>To send periodic emails &mdash; The email address you provide may be used to send you information, notifications that you request about changes to topics or in response to your user name, respond to inquiries, and/or other requests or questions.</li>
+      </ul>
+      <h3 id="protect">How do we protect your information?</h3>
+      <p>We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information.</p>
+      <h3 id="data-retention">What is your data retention policy?</h3>
+      <p>We will make a good faith effort to:</p>
+      <ul>
+        <li>Retain server logs containing the IP address of all requests to this server no more than 90 days.</li>
+        <li>Retain the IP addresses associated with registered users and their posts no more than 5 years.</li>
+      </ul>
+      <h3 id="cookies">Do we use cookies?</h3>
+      <p>Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account.</p>
+      <p>We use cookies to understand and save your preferences for future visits and compile aggregate data about site traffic and site interaction so that we can offer better site experiences and tools in the future. We may contract with third-party service providers to assist us in better understanding our site visitors. These service providers are not permitted to use the information collected on our behalf except to help us conduct and improve our business.</p>
+      <h3 id="disclose">Do we disclose any information to outside parties?</h3>
+      <p>We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety. However, non-personally identifiable visitor information may be provided to other parties for marketing, advertising, or other uses.</p>
+      <h3 id="third-party">Third party links</h3>
+      <p>Occasionally, at our discretion, we may include or offer third party products or services on our site. These third party sites have separate and independent privacy policies. We therefore have no responsibility or liability for the content and activities of these linked sites. Nonetheless, we seek to protect the integrity of our site and welcome any feedback about these sites.</p>
+      <h3 id="coppa">Children's Online Privacy Protection Act Compliance</h3>
+      <p>Our site, products and services are all directed to people who are at least 13 years old. If this server is in the USA, and you are under the age of 13, per the requirements of COPPA (<a href="https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Children's Online Privacy Protection Act</a>) do not use this site.</p>
+      <h3 id="online">Online Privacy Policy Only</h3>
+      <p>This online privacy policy applies only to information collected through our site and not to information collected offline.</p>
+      <h3 id="consent">Your Consent</h3>
+      <p>By using our site, you consent to our web site privacy policy.</p>
+      <h3 id="changes">Changes to our Privacy Policy</h3>
+      <p>If we decide to change our privacy policy, we will post those changes on this page.</p>
+      <p>This document is CC-BY-SA. It was last updated May 31, 2013.</p>
+      <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
     title: Условия обслуживания и политика конфиденциальности %{instance}
+  themes:
+    default: Mastodon
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
diff --git a/config/locales/simple_form.ar.yml b/config/locales/simple_form.ar.yml
index e4c6694e9..e3876e47b 100644
--- a/config/locales/simple_form.ar.yml
+++ b/config/locales/simple_form.ar.yml
@@ -7,6 +7,7 @@ ar:
         digest: يُرسَل بعد مضيّ مدة طويلة من خمول نشاطك يحوي على تلخيص للتبويقات التي ذُكر حسابك فيها أثناء غيابك
         display_name:
           one: <span class="name-counter">1</span> حرف متبقي
+          other: <span class="name-counter">%{count}</span> حرف متبقي
         header: PNG, GIF or JPG. على الأكثر 2 ميغابيت . سوف يتم تصغيرها إلى 700x335px
         locked: يتطلب منك الموافقة يدويا على كل طلب للإشتراك بحسابك و منشوراتك تعرض لمتابعيك فقط دون غيرهم
         note: <span class="note-counter">%{count}</span> أحرف متبقية
@@ -16,6 +17,8 @@ ar:
         data: ملف CSV تم تصديره من خادوم مثيل  آخر لماستدون
       sessions:
         otp: أدخل الرمز الثنائي من هاتفك أو استخدم أحد رموز الاسترداد.
+      user:
+        filtered_languages: سوف لن تَظهَر التبويقات المُحرَّرَة باللغات المُحدّدة أدناه على خيوطك العمومية
     labels:
       defaults:
         avatar: الصورة الرمزية
@@ -25,6 +28,8 @@ ar:
         data: البيانات
         display_name: الاسم الذي يتم عرضه
         email: عنوان البريد الإلكتروني
+        expires_in: مدة نهاية الصلاحية
+        filtered_languages: اللغات المصفاة
         header: رأس الصفحة
         locale: اللغة
         locked: إجعل حسابك خاصًا
@@ -33,17 +38,23 @@ ar:
         note: السيرة الذاتية
         otp_attempt: الرمز الثنائي
         password: كلمة المرور
-        setting_auto_play_gif: تشغيل صور جيف المتحركة تلقائي
+        setting_auto_play_gif: تشغيل تلقائي لِوَسائط جيف المتحركة
         setting_boost_modal: إظهار مربع حوار التأكيد قبل القيام بالترقية
         setting_default_privacy: خصوصية المنشور
         setting_default_sensitive: دائما تحديد الوسائط كحساسة
+        setting_delete_modal: إظهار مربع حوار للتأكيد قبل حذف أي تبويق
         setting_noindex: منع محركات البحث من فهرسة ملفي الشخصي
+        setting_reduce_motion: تخفيض عدد الصور في الوسائط المتحركة
+        setting_system_font_ui: إستخدام الخط الإفتراضي للنظام
+        setting_theme: سمة الموقع
+        setting_unfollow_modal: إظهار مربع حوار للتأكيد قبل إلغاء متابعة أي حساب
         severity: الشدة
         type: نوع الإستيراد
         username: اسم المستخدم
       interactions:
         must_be_follower: حظر اشعارات الأشخاص الذين لا يتبعونك
         must_be_following: حظر اشعارات الأشخاص الذين لا تتبعهم
+        must_be_following_dm: حظر التبويقات المباشرة القادمة من الحسابات التي لا تتبعها
       notification_emails:
         digest: إرسال رسائل بريد إلكتروني ملخصة
         favourite: إبعث بريداً إلكترونياً عندما يعجب احدهم بمنشورك
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index ff1a40ccd..ceb015282 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -4,7 +4,7 @@ en:
     hints:
       defaults:
         avatar: PNG, GIF or JPG. At most 2MB. Will be downscaled to 120x120px
-        digest: Sent after a long period of inactivity with a summary of mentions you've received in your absence
+        digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
         display_name:
           one: <span class="name-counter">1</span> character left
           other: <span class="name-counter">%{count}</span> characters left
@@ -14,7 +14,7 @@ en:
           one: <span class="note-counter">1</span> character left
           other: <span class="note-counter">%{count}</span> characters left
         setting_noindex: Affects your public profile and status pages
-        setting_theme: Affects how Mastodon looks when you're logged in from any device.
+        setting_skin: Reskins the selected Mastodon flavour
       imports:
         data: CSV file exported from another Mastodon instance
       sessions:
@@ -45,10 +45,11 @@ en:
         setting_default_privacy: Post privacy
         setting_default_sensitive: Always mark media as sensitive
         setting_delete_modal: Show confirmation dialog before deleting a toot
+        setting_favourite_modal: Show confirmation dialog before favouriting
         setting_noindex: Opt-out of search engine indexing
         setting_reduce_motion: Reduce motion in animations
+        setting_skin: Skin
         setting_system_font_ui: Use system's default font
-        setting_theme: Site theme
         setting_unfollow_modal: Show confirmation dialog before unfollowing someone
         severity: Severity
         type: Import type
diff --git a/config/locales/simple_form.fa.yml b/config/locales/simple_form.fa.yml
index a19d69ff1..ddb13ae43 100644
--- a/config/locales/simple_form.fa.yml
+++ b/config/locales/simple_form.fa.yml
@@ -3,7 +3,7 @@ fa:
   simple_form:
     hints:
       defaults:
-        avatar: یکی از قالب‌های PNG یا  GIF یا JPG. بیشترین اندازه ۲ مگابایت. تصویر به اندازهٔ ۱۲۰×۱۲۰ پیکسل تبدیل خواهد شد.
+        avatar: یکی از قالب‌های PNG یا  GIF یا JPG. بیشترین اندازه ۲ مگابایت. تصویر به اندازهٔ ۱۲۰×۱۲۰ پیکسل تبدیل خواهد شد
         digest: پس از مدت طولانی عدم فعالیت فرستاده می‌شود، شامل خلاصه‌ای از مواردی که در نبودتان از شما نام برده شده
         display_name:
           one: <span class="name-counter">1</span> حرف باقی مانده
@@ -20,7 +20,7 @@ fa:
       sessions:
         otp: کد تأیید دومرحله‌ای را از تلفن خود وارد کنید یا یکی از کدهای بازیابی را به کار ببرید.
       user:
-        filtered_languages: زبان‌های انتخاب‌شده از فهرست عمومی نوشته‌هایی که می‌بینید حذف می‌شوند.
+        filtered_languages: زبان‌های انتخاب‌شده از فهرست عمومی نوشته‌هایی که می‌بینید حذف می‌شوند
     labels:
       defaults:
         avatar: تصویر نمایه
@@ -30,10 +30,12 @@ fa:
         data: داده‌ها
         display_name: نمایش به نام
         email: نشانی ایمیل
+        expires_in: تاریخ انقضا
         filtered_languages: زبان‌های فیلترشده
         header: تصویر زمینه
         locale: زبان
         locked: خصوصی‌کردن حساب
+        max_uses: بیشترین شمار استفاده
         new_password: رمز تازه
         note: دربارهٔ شما
         otp_attempt: کد ورود دومرحله‌ای
@@ -44,6 +46,7 @@ fa:
         setting_default_sensitive: همیشه تصاویر را به عنوان حساس علامت بزن
         setting_delete_modal: پیش از پاک کردن یک نوشته پیغام تأیید نشان بده
         setting_noindex: درخواست از موتورهای جستجو برای لغو فهرست‌سازی
+        setting_reduce_motion: کاستن از حرکت در پویانمایی‌ها
         setting_system_font_ui: به‌کاربردن قلم پیش‌فرض سیستم
         setting_theme: تم سایت
         setting_unfollow_modal: نمایش پیغام تأیید پیش از لغو پیگیری دیگران
@@ -53,6 +56,7 @@ fa:
       interactions:
         must_be_follower: مسدودکردن اعلان‌های همه به جز پیگیران
         must_be_following: مسدودکردن اعلان‌های کسانی که شما پی نمی‌گیرید
+        must_be_following_dm: مسدودکردن پیغام‌های خصوصی کسانی که شما پی نمی‌گیرید
       notification_emails:
         digest: خلاصه‌کردن چند اعلان در یک ایمیل
         favourite: وقتی کسی نوشتهٔ شما پسندید ایمیل بفرست
diff --git a/config/locales/simple_form.gl.yml b/config/locales/simple_form.gl.yml
index 7fa96992f..d7e5601f2 100644
--- a/config/locales/simple_form.gl.yml
+++ b/config/locales/simple_form.gl.yml
@@ -1,10 +1,10 @@
+---
 gl:
   simple_form:
     hints:
       defaults:
         avatar: PNG, GIF ou JPG. Como moito 2MB. Será reducida ate 120x120px
-        digest: Enviar despois de un período longo de inactividade con un resumo das
-          mencións que recibeu na súa ausencia
+        digest: Enviar despois de un período longo de inactividade con un resumo das mencións que recibeu na súa ausencia
         display_name:
           one: <span class="name-counter">1</span> caracter restante
           other: <span class="name-counter">%{count}</span> caracteres restantes
@@ -14,16 +14,13 @@ gl:
           one: <span class="note-counter">1</span> caracter restante
           other: <span class="note-counter">%{count}</span> caracteres restantes
         setting_noindex: Afecta ao seu perfil público e páxinas de estado
-        setting_theme: Afecta ao aspecto de Mastodon en calquer dispositivo cando
-          está conectada.
+        setting_theme: Afecta ao aspecto de Mastodon en calquer dispositivo cando está conectada.
       imports:
         data: Ficheiro CSV exportado desde outra instancia Mastodon
       sessions:
-        otp: Introduza o código de Doble-Factor desde o seu teléfono ou utilice un
-          dos seus códigos de recuperación.
+        otp: Introduza o código de Doble-Factor desde o seu teléfono ou utilice un dos seus códigos de recuperación.
       user:
-        filtered_languages: Os idiomas marcados filtraranse das liñas temporais públicas
-          para vostede
+        filtered_languages: Os idiomas marcados filtraranse das liñas temporais públicas para vostede
     labels:
       defaults:
         avatar: Avatar
@@ -62,14 +59,13 @@ gl:
         must_be_following_dm: Bloquea as mensaxes directas de personas que non segue
       notification_emails:
         digest: Enviar correos con resumos
-        favourite: Enviar un correo cando alguén marca como favorita unha das súas
-          publicacións
+        favourite: Enviar un correo cando alguén marca como favorita unha das súas publicacións
         follow: Enviar un correo cando alguén a segue
         follow_request: Enviar un correo cando alguén solicita seguila
         mention: Enviar un correo cando alguén a menciona
         reblog: Enviar un correo cando alguén promociona a súa mensaxe
     'no': Non
     required:
-      mark: '*'
+      mark: "*"
       text: requerido
     'yes': Si
diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml
index 2e5f96957..361a5ada6 100644
--- a/config/locales/simple_form.ja.yml
+++ b/config/locales/simple_form.ja.yml
@@ -41,6 +41,7 @@ ja:
         setting_default_privacy: 投稿の公開範囲
         setting_default_sensitive: メディアを常に閲覧注意としてマークする
         setting_delete_modal: トゥートを削除する前に確認ダイアログを表示する
+        setting_favourite_modal: お気に入りをする前に確認ダイアログを表示する
         setting_noindex: 検索エンジンによるインデックスを拒否する
         setting_reduce_motion: アニメーションの動きを減らす
         setting_system_font_ui: システムのデフォルトフォントを使う
diff --git a/config/locales/simple_form.pl.yml b/config/locales/simple_form.pl.yml
index 507e46469..a97239669 100644
--- a/config/locales/simple_form.pl.yml
+++ b/config/locales/simple_form.pl.yml
@@ -18,7 +18,7 @@ pl:
           one: Pozostał <span class="name-counter">1</span> znak.
           other: Pozostało <span class="name-counter">%{count}</span> znaków
         setting_noindex: Wpływa na widoczność strony profilu i Twoich wpisów
-        setting_theme: Zmienia wygląd Mastodona po zalogowaniu z dowolnego urządzenia.
+        setting_skin: Zmienia wygląd używanej odmiany Mastodona
       imports:
         data: Plik CSV wyeksportowany z innej instancji Mastodona
       sessions:
@@ -49,10 +49,11 @@ pl:
         setting_default_privacy: Widoczność wpisów
         setting_default_sensitive: Zawsze oznaczaj zawartość multimedialną jako wrażliwą
         setting_delete_modal: Pytaj o potwierdzenie przed usunięciem wpisu
+        setting_favourite_modal: Pytaj o potwierdzenie przed dodaniem do ulubionych
         setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych
         setting_reduce_motion: Ogranicz ruch w animacjach
+        setting_skin: Motyw
         setting_system_font_ui: Używaj domyślnej czcionki systemu
-        setting_theme: Motyw strony
         setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia
         severity: Priorytet
         type: Typ importu
diff --git a/config/locales/simple_form.pt.yml b/config/locales/simple_form.pt.yml
index a5afd02d3..9970247ab 100644
--- a/config/locales/simple_form.pt.yml
+++ b/config/locales/simple_form.pt.yml
@@ -11,7 +11,7 @@ pt:
       imports:
         data: Ficheiro CSV exportado de outra instância do Mastodon
       sessions:
-        otp: Insere o código o código de autenticação de dois fatores do teu telefone ou utiliza um código de recuperação de acesso.
+        otp: Insere o código de autenticação em dois passos do teu telefone ou utiliza um código de recuperação de acesso.
     labels:
       defaults:
         avatar: Imagem de Perfil
@@ -26,10 +26,11 @@ pt:
         locked: Tornar conta privada
         new_password: Nova palavra-passe
         note: Biografia
-        otp_attempt: Código de autenticação de dois fatores
+        otp_attempt: Código de autenticação em dois passos
         password: Palavra-passe
         setting_boost_modal: Pedir confirmação antes de partilhar um post
         setting_default_privacy: Privacidade padrão de posts
+        setting_default_sensitive: Marcar sempre media como sensível
         setting_reduce_motion: Reduzir movimento em animações
         severity: Severity
         type: Import type
@@ -41,6 +42,7 @@ pt:
         digest: Enviar um email da actividade nesta instância
         favourite: Enviar email quando alguém adiciona um post teu aos favoritos
         follow: Enviar email quando alguém te segue
+        follow_request: Enviar um email quando alguém solicitar ser o teu seguidor
         mention: Enviar email quando alguém te menciona
         reblog: Enviar email quando alguém partilhar um post teu
     'no': Não
diff --git a/config/locales/simple_form.zh-CN.yml b/config/locales/simple_form.zh-CN.yml
index 1f2fa173d..4eb993e52 100644
--- a/config/locales/simple_form.zh-CN.yml
+++ b/config/locales/simple_form.zh-CN.yml
@@ -4,7 +4,7 @@ zh-CN:
     hints:
       defaults:
         avatar: 文件大小限制 2MB,只支持 PNG、GIF 或 JPG 格式。图片分辨率将会压缩至 120×120px
-        digest: 在你长时间未登录的情况下,我们会向你发送一份含有提及你的嘟文的摘要邮件
+        digest: 仅在你长时间未登录,且收到了私信时发送
         display_name: 还能输入 <span class="name-counter">%{count}</span> 个字符
         header: 文件大小限制 2MB,只支持 PNG、GIF 或 JPG 格式。图片分辨率将会压缩至 700×335px
         locked: 你需要手动审核所有关注请求
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index e1909ac71..14382331b 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -263,12 +263,18 @@ zh-CN:
       unresolved: 未处理
       view: 查看
     settings:
+      activity_api_enabled:
+        desc_html: 本站用户发布的嘟文数,以及本站的活跃用户数和一周内新用户数
+        title: 公开用户活跃度的统计数据
       bootstrap_timeline_accounts:
         desc_html: 用半角逗号分隔多个用户名。只能添加来自本站且未开启保护的帐户。如果留空,则默认关注本站所有的管理员。
         title: 新用户默认关注
       contact_information:
         email: 用于联系的公开电子邮件地址
         username: 用于联系的公开用户名
+      peers_api_enabled:
+        desc_html: 截至目前本实例在网络中已发现的域名
+        title: 公开已知实例的列表
       registrations:
         closed_message:
           desc_html: 本站关闭注册期间的提示信息。可以使用 HTML 标签
@@ -330,10 +336,13 @@ zh-CN:
       body: "%{reporter} 举报了用户 %{target}。"
       subject: 来自 %{instance} 的用户举报(#%{id})
   application_mailer:
+    notification_preferences: 更改电子邮件首选项
     salutation: "%{name}:"
     settings: 使用此链接更改你的电子邮件首选项:%{link}
     signature: 这是一封来自 %{instance} 的 Mastodon 电子邮件通知。
     view: 点此链接查看详情:
+    view_profile: 查看个人资料页
+    view_status: 查看嘟文
   applications:
     created: 应用创建成功
     destroyed: 应用删除成功
@@ -470,27 +479,36 @@ zh-CN:
     title: 运营
   notification_mailer:
     digest:
-      body: 自从你最后一次(时间是%{since})登录 %{instance} 以来,你错过了这些嘟嘟滴滴:
+      action: 查看所有通知
+      body: 以下是自%{since}你最后一次登录以来错过的消息的摘要
       mention: "%{name} 在嘟文中提到了你:"
       new_followers_summary:
-        one: 有个人关注了你!耶!
-        other: 有 %{count} 个人关注了你!好棒!
+        one: 而且,你不在的时候,有一个人关注了你!耶!
+        other: 而且,你不在的时候,有 %{count} 个人关注了你!好棒!
       subject: "自从你最后一次登录以来,你错过了 %{count} 条新通知 \U0001F418"
+      title: 在你不在的这段时间……
     favourite:
       body: 你的嘟文被 %{name} 收藏了:
       subject: "%{name} 收藏了你的嘟文"
+      title: 新的收藏
     follow:
       body: "%{name} 关注了你!"
       subject: "%{name} 关注了你"
+      title: 新的关注者
     follow_request:
+      action: 处理关注请求
       body: "%{name} 向你发送了关注请求!"
       subject: 来自 %{name} 的关注请求
+      title: 新的关注请求
     mention:
+      action: 回复
       body: "%{name} 在嘟文中提到了你:"
       subject: "%{name} 提到了你"
+      title: 新的提及
     reblog:
       body: 你的嘟文被 %{name} 转嘟了:
       subject: "%{name} 转嘟了你的嘟文"
+      title: 新的转嘟
   number:
     human:
       decimal_units:
diff --git a/config/navigation.rb b/config/navigation.rb
index 9c7a7d2ec..b08b1769d 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -7,6 +7,7 @@ SimpleNavigation::Configuration.run do |navigation|
     primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings|
       settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url, highlights_on: %r{/settings/profile|/settings/migration}
       settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
+      settings.item :keyword_mutes, safe_join([fa_icon('volume-off fw'), t('settings.keyword_mutes')]), settings_keyword_mutes_url
       settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url
       settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}
       settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication}
@@ -16,6 +17,12 @@ SimpleNavigation::Configuration.run do |navigation|
       settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url
     end
 
+    primary.item :flavours, safe_join([fa_icon('paint-brush fw'), t('settings.flavours')]), settings_flavours_url do |flavours|
+      Themes.instance.flavours.each do |flavour|
+        flavours.item flavour.to_sym, safe_join([fa_icon('star fw'), t("flavours.#{flavour}.name", default: flavour)]), settings_flavour_url(flavour)
+      end
+    end
+
     primary.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' }
 
     primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development|
diff --git a/config/routes.rb b/config/routes.rb
index 21ad0a93f..f45684519 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -54,8 +54,7 @@ Rails.application.routes.draw do
 
     resources :followers, only: [:index], controller: :follower_accounts
     resources :following, only: [:index], controller: :following_accounts
-    resources :follows, only: [:show], module: :activitypub
-    resource :follow, only: [:create], controller: :account_follow, as: :follows
+    resource :follow, only: [:create], controller: :account_follow
     resource :unfollow, only: [:create], controller: :account_unfollow
     resource :outbox, only: [:show], module: :activitypub
     resource :inbox, only: [:create], module: :activitypub
@@ -71,6 +70,13 @@ Rails.application.routes.draw do
 
   namespace :settings do
     resource :profile, only: [:show, :update]
+
+    resources :keyword_mutes do
+      collection do
+        delete :destroy_all
+      end
+    end
+
     resource :preferences, only: [:show, :update]
     resource :notifications, only: [:show, :update]
     resource :import, only: [:show, :create]
@@ -96,6 +102,8 @@ Rails.application.routes.draw do
       end
     end
 
+    resources :flavours, only: [:index, :show, :update], param: :flavour
+
     resource :delete, only: [:show, :destroy]
     resource :migration, only: [:show, :update]
 
@@ -218,6 +226,7 @@ Rails.application.routes.draw do
       end
 
       namespace :timelines do
+        resource :direct, only: :show, controller: :direct
         resource :home, only: :show, controller: :home
         resource :public, only: :show, controller: :public
         resources :tag, only: :show
@@ -232,7 +241,11 @@ Rails.application.routes.draw do
       resources :follows,    only: [:create]
       resources :media,      only: [:create, :update]
       resources :blocks,     only: [:index]
-      resources :mutes,      only: [:index]
+      resources :mutes,      only: [:index] do
+        collection do
+          get 'details'
+        end
+      end
       resources :favourites, only: [:index]
       resources :reports,    only: [:index, :create]
 
@@ -256,10 +269,11 @@ Rails.application.routes.draw do
         end
       end
 
-      resources :notifications, only: [:index, :show] do
+      resources :notifications, only: [:index, :show, :destroy] do
         collection do
           post :clear
           post :dismiss
+          delete :destroy_multiple
         end
       end
 
diff --git a/config/settings.yml b/config/settings.yml
index 4a2519464..507b7c066 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -7,7 +7,7 @@
 # For more information, see docs/Running-Mastodon/Administration-guide.md
 #
 defaults: &defaults
-  site_title: Mastodon
+  site_title: 'dev.glitch.social'
   site_description: ''
   site_extended_description: ''
   site_terms: ''
@@ -16,18 +16,20 @@ defaults: &defaults
   open_registrations: true
   closed_registrations_message: ''
   open_deletion: true
+  timeline_preview: false
   min_invite_role: 'admin'
-  timeline_preview: true
   show_staff_badge: true
   default_sensitive: false
   unfollow_modal: false
   boost_modal: false
+  favourite_modal: false
   delete_modal: true
   auto_play_gif: false
   reduce_motion: false
   system_font_ui: false
   noindex: false
-  theme: 'default'
+  flavour: 'glitch'
+  skin: 'default'
   notification_emails:
     follow: false
     reblog: false
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 4c35dcd43..bfe29b8f8 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -27,3 +27,6 @@
   ip_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
     class: Scheduler::IpCleanupScheduler
+  email_scheduler:
+    cron: '0 10 * * 2'
+    class: Scheduler::EmailScheduler
diff --git a/config/themes.yml b/config/themes.yml
deleted file mode 100644
index bbf8d0f6c..000000000
--- a/config/themes.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-default: styles/application.scss
-win95: styles/win95.scss
diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js
index 822329490..a38d9d11d 100644
--- a/config/webpack/configuration.js
+++ b/config/webpack/configuration.js
@@ -1,16 +1,62 @@
 // Common configuration for webpacker loaded from config/webpacker.yml
 
-const { join, resolve } = require('path');
+const { basename, dirname, extname, join, resolve } = require('path');
 const { env } = require('process');
 const { safeLoad } = require('js-yaml');
-const { readFileSync } = require('fs');
+const { lstatSync, readFileSync } = require('fs');
+const glob = require('glob');
 
 const configPath = resolve('config', 'webpacker.yml');
 const loadersDir = join(__dirname, 'loaders');
 const settings = safeLoad(readFileSync(configPath), 'utf8')[env.NODE_ENV];
+const flavourFiles = glob.sync('app/javascript/flavours/*/theme.yml');
+const skinFiles = glob.sync('app/javascript/skins/*/*');
+const flavours = {};
 
-const themePath = resolve('config', 'themes.yml');
-const themes = safeLoad(readFileSync(themePath), 'utf8');
+const core = function () {
+  const coreFile = resolve('app', 'javascript', 'core', 'theme.yml');
+  const data = safeLoad(readFileSync(coreFile), 'utf8');
+  if (!data.pack_directory) {
+    data.pack_directory = dirname(coreFile);
+  }
+  return data.pack ? data : {};
+}();
+
+for (let i = 0; i < flavourFiles.length; i++) {
+  const flavourFile = flavourFiles[i];
+  const data = safeLoad(readFileSync(flavourFile), 'utf8');
+  data.name = basename(dirname(flavourFile));
+  data.skin = {};
+  if (!data.pack_directory) {
+    data.pack_directory = dirname(flavourFile);
+  }
+  if (data.locales) {
+    data.locales = join(dirname(flavourFile), data.locales);
+  }
+  if (data.pack && typeof data.pack === 'object') {
+    flavours[data.name] = data;
+  }
+}
+
+for (let i = 0; i < skinFiles.length; i++) {
+  const skinFile = skinFiles[i];
+  let skin = basename(skinFile);
+  const name = basename(dirname(skinFile));
+  if (!flavours[name]) {
+    continue;
+  }
+  const data = flavours[name].skin;
+  if (lstatSync(skinFile).isDirectory()) {
+    data[skin] = {};
+    const skinPacks = glob.sync(join(skinFile, '*.{css,scss}'));
+    for (let j = 0; j < skinPacks.length; j++) {
+      const pack = skinPacks[j];
+      data[skin][basename(pack, extname(pack))] = pack;
+    }
+  } else if ((skin = skin.match(/^(.*)\.s?css$/i))) {
+    data[skin[1]] = { common: skinFile };
+  }
+}
 
 function removeOuterSlashes(string) {
   return string.replace(/^\/*/, '').replace(/\/*$/, '');
@@ -32,7 +78,8 @@ const output = {
 
 module.exports = {
   settings,
-  themes,
+  core,
+  flavours,
   env,
   loadersDir,
   output,
diff --git a/config/webpack/generateLocalePacks.js b/config/webpack/generateLocalePacks.js
index b71cf2ade..09fba4a18 100644
--- a/config/webpack/generateLocalePacks.js
+++ b/config/webpack/generateLocalePacks.js
@@ -1,52 +1,66 @@
+// A message from upstream:
+// ========================
 // To avoid adding a lot of boilerplate, locale packs are
 // automatically generated here. These are written into the tmp/
 // directory and then used to generate locale_en.js, locale_fr.js, etc.
 
-const fs = require('fs');
-const path = require('path');
+// Glitch note:
+// ============
+// This code has been entirely rewritten to support glitch flavours.
+// However, the underlying process is exactly the same.
+
+const { existsSync, readdirSync, writeFileSync } = require('fs');
+const { join, resolve } = require('path');
 const rimraf = require('rimraf');
 const mkdirp = require('mkdirp');
+const { flavours } = require('./configuration.js');
+
+module.exports = Object.keys(flavours).reduce(function (map, entry) {
+  const flavour = flavours[entry];
+  if (!flavour.locales) {
+    return map;
+  }
+  const locales = readdirSync(flavour.locales).filter(
+    filename => /\.js(?:on)?$/.test(filename) && !/defaultMessages|whitelist|index/.test(filename)
+  );
+  const outPath = resolve('tmp', 'locales', entry);
 
-const localesJsonPath = path.join(__dirname, '../../app/javascript/mastodon/locales');
-const locales = fs.readdirSync(localesJsonPath).filter(filename => {
-  return /\.json$/.test(filename) &&
-    !/defaultMessages/.test(filename) &&
-    !/whitelist/.test(filename);
-}).map(filename => filename.replace(/\.json$/, ''));
-
-const outPath = path.join(__dirname, '../../tmp/packs');
-
-rimraf.sync(outPath);
-mkdirp.sync(outPath);
-
-const outPaths = [];
-
-locales.forEach(locale => {
-  const localePath = path.join(outPath, `locale_${locale}.js`);
-  const baseLocale = locale.split('-')[0]; // e.g. 'zh-TW' -> 'zh'
-  const localeDataPath = [
-    // first try react-intl
-    `../../node_modules/react-intl/locale-data/${baseLocale}.js`,
-    // then check locales/locale-data
-    `../../app/javascript/mastodon/locales/locale-data/${baseLocale}.js`,
-    // fall back to English (this is what react-intl does anyway)
-    '../../node_modules/react-intl/locale-data/en.js',
-  ].filter(filename => fs.existsSync(path.join(outPath, filename)))
-    .map(filename => filename.replace(/..\/..\/node_modules\//, ''))[0];
-
-  const localeContent = `//
-// locale_${locale}.js
+  rimraf.sync(outPath);
+  mkdirp.sync(outPath);
+
+  locales.forEach(function (locale) {
+    const localeName = locale.replace(/\.js(?:on)?$/, '');
+    const localePath = join(outPath, `${localeName}.js`);
+    const baseLocale = localeName.split('-')[0]; // e.g. 'zh-TW' -> 'zh'
+    const localeDataPath = [
+      // first try react-intl
+      `node_modules/react-intl/locale-data/${baseLocale}.js`,
+      // then check locales/locale-data
+      `app/javascript/locales/locale-data/${baseLocale}.js`,
+      // fall back to English (this is what react-intl does anyway)
+      'node_modules/react-intl/locale-data/en.js',
+    ].filter(
+      filename => existsSync(filename)
+    ).map(
+      filename => filename.replace(/(?:node_modules|app\/javascript)\//, '')
+    )[0];
+    const localeContent = `//
+// locales/${entry}/${localeName}.js
 // automatically generated by generateLocalePacks.js
 //
-import messages from '../../app/javascript/mastodon/locales/${locale}.json';
-import localeData from ${JSON.stringify(localeDataPath)};
-import { setLocale } from '../../app/javascript/mastodon/locales';
-setLocale({messages, localeData});
-`;
-  fs.writeFileSync(localePath, localeContent, 'utf8');
-  outPaths.push(localePath);
-});
 
-module.exports = outPaths;
+import messages from '../../../${flavour.locales}/${locale.replace(/\.js$/, '')}';
+import localeData from '${localeDataPath}';
+import { setLocale } from 'locales';
 
+setLocale({
+  localeData,
+  messages,
+});
+`;
+    writeFileSync(localePath, localeContent, 'utf8');
+    map[`locales/${entry}/${localeName}`] = localePath;
+  });
 
+  return map;
+}, {});
diff --git a/config/webpack/loaders/babel.js b/config/webpack/loaders/babel.js
index e17d2fa70..770c89aa7 100644
--- a/config/webpack/loaders/babel.js
+++ b/config/webpack/loaders/babel.js
@@ -7,7 +7,8 @@ module.exports = {
   exclude: /node_modules/,
   loader: 'babel-loader',
   options: {
-    forceEnv: env,
+    forceEnv: process.env.NODE_ENV || 'development',
+    sourceRoot: 'app/javascript',
     cacheDirectory: env === 'development' ? false : resolve(__dirname, '..', '..', '..', 'tmp', 'cache', 'babel-loader'),
   },
 };
diff --git a/config/webpack/loaders/sass.js b/config/webpack/loaders/sass.js
index 88d94c684..96ad7abe8 100644
--- a/config/webpack/loaders/sass.js
+++ b/config/webpack/loaders/sass.js
@@ -9,7 +9,7 @@ module.exports = {
       { loader: 'css-loader', options: { minimize: env.NODE_ENV === 'production' } },
       { loader: 'postcss-loader', options: { sourceMap: true } },
       'resolve-url-loader',
-      'sass-loader',
+      { loader: 'sass-loader', options: { includePaths: ['app/javascript'] } },
     ],
   }),
 };
diff --git a/config/webpack/shared.js b/config/webpack/shared.js
index 50fa48175..35b9bbd1c 100644
--- a/config/webpack/shared.js
+++ b/config/webpack/shared.js
@@ -1,35 +1,55 @@
 // Note: You must restart bin/webpack-dev-server for changes to take effect
 
 const webpack = require('webpack');
-const { basename, dirname, join, relative, resolve, sep } = require('path');
+const { join, resolve } = require('path');
 const { sync } = require('glob');
 const ExtractTextPlugin = require('extract-text-webpack-plugin');
 const ManifestPlugin = require('webpack-manifest-plugin');
-const extname = require('path-complete-extname');
-const { env, settings, themes, output, loadersDir } = require('./configuration.js');
-const localePackPaths = require('./generateLocalePacks');
+const { env, settings, core, flavours, output, loadersDir } = require('./configuration.js');
+const localePacks = require('./generateLocalePacks');
 
-const extensionGlob = `**/*{${settings.extensions.join(',')}}*`;
-const entryPath = join(settings.source_path, settings.source_entry_path);
-const packPaths = sync(join(entryPath, extensionGlob));
+function reducePacks (data, into = {}) {
+  if (!data.pack) {
+    return into;
+  }
+  Object.keys(data.pack).reduce((map, entry) => {
+    const pack = data.pack[entry];
+    if (!pack) {
+      return map;
+    }
+    const packFile = typeof pack === 'string' ? pack : pack.filename;
+    if (packFile) {
+      map[data.name ? `flavours/${data.name}/${entry}` : `core/${entry}`] = resolve(data.pack_directory, packFile);
+    }
+    return map;
+  }, into);
+  if (data.name) {
+    Object.keys(data.skin).reduce((map, entry) => {
+      const skin = data.skin[entry];
+      const skinName = entry;
+      if (!skin) {
+        return map;
+      }
+      Object.keys(skin).reduce((map, entry) => {
+        const packFile = skin[entry];
+        if (!packFile) {
+          return map;
+        }
+        map[`skins/${data.name}/${skinName}/${entry}`] = resolve(packFile);
+        return map;
+      }, into);
+      return map;
+    }, into);
+  }
+  return into;
+}
 
 module.exports = {
   entry: Object.assign(
-    packPaths.reduce((map, entry) => {
-      const localMap = map;
-      const namespace = relative(join(entryPath), dirname(entry));
-      localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry);
-      return localMap;
-    }, {}),
-    localePackPaths.reduce((map, entry) => {
-      const localMap = map;
-      localMap[basename(entry, extname(entry, extname(entry)))] = resolve(entry);
-      return localMap;
-    }, {}),
-    Object.keys(themes).reduce((themePaths, name) => {
-      themePaths[name] = resolve(join(settings.source_path, themes[name]));
-      return themePaths;
-    }, {})
+    { locales: resolve('app', 'javascript', 'locales') },
+    localePacks,
+    reducePacks(core),
+    Object.keys(flavours).reduce((map, entry) => reducePacks(flavours[entry], map), {})
   ),
 
   output: {
@@ -52,25 +72,17 @@ module.exports = {
         resource.request = resource.request.replace(/^history/, 'history/es');
       }
     ),
-    new ExtractTextPlugin(env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css'),
+    new ExtractTextPlugin({
+      filename: env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css',
+      allChunks: true,
+    }),
     new ManifestPlugin({
       publicPath: output.publicPath,
       writeToFileEmit: true,
     }),
     new webpack.optimize.CommonsChunkPlugin({
-      name: 'common',
-      minChunks: (module, count) => {
-        const reactIntlPathRegexp = new RegExp(`node_modules\\${sep}react-intl`);
-
-        if (module.resource && reactIntlPathRegexp.test(module.resource)) {
-          // skip react-intl because it's useless to put in the common chunk,
-          // e.g. because "shared" modules between zh-TW and zh-CN will never
-          // be loaded together
-          return false;
-        }
-
-        return count >= 2;
-      },
+      name: 'locales',
+      minChunks: Infinity, // It doesn't make sense to use common chunks with multiple frontend support.
     }),
   ],
 
diff --git a/config/webpacker.yml b/config/webpacker.yml
index 8d8470651..50d95813a 100644
--- a/config/webpacker.yml
+++ b/config/webpacker.yml
@@ -2,7 +2,6 @@
 
 default: &default
   source_path: app/javascript
-  source_entry_path: packs
   public_output_path: packs
   cache_path: tmp/cache/webpacker
 
@@ -13,17 +12,6 @@ default: &default
   # Reload manifest.json on all requests so we reload latest compiled packs
   cache_manifest: false
 
-  extensions:
-    - .js
-    - .sass
-    - .scss
-    - .css
-    - .png
-    - .svg
-    - .gif
-    - .jpeg
-    - .jpg
-
 development:
   <<: *default
   compile: true
diff --git a/db/migrate/20170716191202_add_hide_notifications_to_mute.rb b/db/migrate/20170716191202_add_hide_notifications_to_mute.rb
index 0410938c9..de7d2a4a2 100644
--- a/db/migrate/20170716191202_add_hide_notifications_to_mute.rb
+++ b/db/migrate/20170716191202_add_hide_notifications_to_mute.rb
@@ -1,15 +1,5 @@
-require Rails.root.join('lib', 'mastodon', 'migration_helpers')
-
 class AddHideNotificationsToMute < ActiveRecord::Migration[5.1]
-  include Mastodon::MigrationHelpers
-
-  disable_ddl_transaction!
-
-  def up
-    add_column_with_default :mutes, :hide_notifications, :boolean, default: true, allow_null: false
-  end
-  
-  def down
-    remove_column :mutes, :hide_notifications
+  def change
+    add_column :mutes, :hide_notifications, :boolean, default: false, null: false
   end
 end
diff --git a/db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb b/db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb
new file mode 100644
index 000000000..8e6cac455
--- /dev/null
+++ b/db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb
@@ -0,0 +1,8 @@
+class DefaultExistingMutesToHidingNotifications < ActiveRecord::Migration[5.1]
+  def up
+    change_column_default :mutes, :hide_notifications, from: false, to: true
+
+    # Unfortunately if this is applied sometime after the one to add the table we lose some data, so this is irreversible.
+    Mute.update_all(hide_notifications: true)
+  end
+end
diff --git a/db/migrate/20171009222537_create_keyword_mutes.rb b/db/migrate/20171009222537_create_keyword_mutes.rb
new file mode 100644
index 000000000..ec0c756fb
--- /dev/null
+++ b/db/migrate/20171009222537_create_keyword_mutes.rb
@@ -0,0 +1,12 @@
+class CreateKeywordMutes < ActiveRecord::Migration[5.1]
+  def change
+    create_table :keyword_mutes do |t|
+      t.references :account, null: false
+      t.string :keyword, null: false
+      t.boolean :whole_word, null: false, default: true
+      t.timestamps
+    end
+
+    add_foreign_key :keyword_mutes, :accounts, on_delete: :cascade
+  end
+end
diff --git a/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb
new file mode 100644
index 000000000..269bb49d6
--- /dev/null
+++ b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb
@@ -0,0 +1,7 @@
+class MoveKeywordMutesIntoGlitchNamespace < ActiveRecord::Migration[5.1]
+  def change
+    safety_assured do
+      rename_table :keyword_mutes, :glitch_keyword_mutes
+    end
+  end
+end
diff --git a/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb b/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb
new file mode 100644
index 000000000..af1e29d6a
--- /dev/null
+++ b/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddLocalOnlyFlagToStatuses < ActiveRecord::Migration[5.1]
+  def change
+    add_column :statuses, :local_only, :boolean
+  end
+end
diff --git a/db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb b/db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb
new file mode 100644
index 000000000..401fc5e62
--- /dev/null
+++ b/db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb
@@ -0,0 +1,10 @@
+class AddIndexOnStatusesForApiV1AccountsAccountIdStatuses < ActiveRecord::Migration[5.1]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured do
+      add_index :statuses, [:account_id, :id, :visibility, :updated_at], order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20180106
+    end
+    remove_index :statuses, name: :index_statuses_on_account_id_id
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 04d957c63..11ca12235 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20171226094803) do
+ActiveRecord::Schema.define(version: 20180106000232) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -173,6 +173,15 @@ ActiveRecord::Schema.define(version: 20171226094803) do
     t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
   end
 
+  create_table "glitch_keyword_mutes", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.string "keyword", null: false
+    t.boolean "whole_word", default: true, null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id"
+  end
+
   create_table "imports", force: :cascade do |t|
     t.integer "type", null: false
     t.boolean "approved", default: false, null: false
@@ -245,9 +254,9 @@ ActiveRecord::Schema.define(version: 20171226094803) do
   create_table "mutes", force: :cascade do |t|
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
+    t.boolean "hide_notifications", default: true, null: false
     t.bigint "account_id", null: false
     t.bigint "target_account_id", null: false
-    t.boolean "hide_notifications", default: true, null: false
     t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true
   end
 
@@ -409,7 +418,8 @@ ActiveRecord::Schema.define(version: 20171226094803) do
     t.bigint "account_id", null: false
     t.bigint "application_id"
     t.bigint "in_reply_to_account_id"
-    t.index ["account_id", "id"], name: "index_statuses_on_account_id_id"
+    t.boolean "local_only"
+    t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc }
     t.index ["conversation_id"], name: "index_statuses_on_conversation_id"
     t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
     t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
@@ -525,6 +535,7 @@ ActiveRecord::Schema.define(version: 20171226094803) do
   add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
   add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
   add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
+  add_foreign_key "glitch_keyword_mutes", "accounts", on_delete: :cascade
   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
   add_foreign_key "invites", "users", on_delete: :cascade
   add_foreign_key "list_accounts", "accounts", on_delete: :cascade
diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh
index e92959c8e..1af5dde64 100644
--- a/docker_entrypoint.sh
+++ b/docker_entrypoint.sh
@@ -11,4 +11,4 @@ echo "Updating permissions..."
 find /mastodon -path /mastodon/public/system -prune -o -not -user mastodon -not -group mastodon -print0 | xargs -0 chown -f mastodon:mastodon
 
 echo "Executing process..."
-exec su-exec mastodon:mastodon /sbin/tini -- "$@"
+LD_PRELOAD=/lib/stack-fix.so exec su-exec mastodon:mastodon /sbin/tini -- "$@"
diff --git a/jest.config.js b/jest.config.js
index 50bde57e6..dc61b9a9d 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -9,6 +9,7 @@ module.exports = {
     '<rootDir>/log/',
     '<rootDir>/public/',
     '<rootDir>/tmp/',
+    '<rootDir>/app/javascript/themes/',
   ],
   setupFiles: [
     'raf/polyfill',
diff --git a/lib/mastodon/premailer_webpack_strategy.rb b/lib/mastodon/premailer_webpack_strategy.rb
new file mode 100644
index 000000000..4ea3ce80c
--- /dev/null
+++ b/lib/mastodon/premailer_webpack_strategy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module PremailerWebpackStrategy
+  def load(url)
+    if Webpacker.dev_server.running?
+      url = File.join("#{Webpacker.dev_server.protocol}://#{Webpacker.dev_server.host_with_port}", url)
+      HTTP.get(url).to_s
+    else
+      url = url[1..-1] if url.start_with?('/')
+      File.read(Rails.root.join('public', url))
+    end
+  end
+
+  module_function :load
+end
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index bd23ab1d1..5936b5fcf 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -13,7 +13,7 @@ module Mastodon
     end
 
     def patch
-      2
+      3
     end
 
     def pre
diff --git a/lib/paperclip/audio_transcoder.rb b/lib/paperclip/audio_transcoder.rb
new file mode 100644
index 000000000..631ccb0be
--- /dev/null
+++ b/lib/paperclip/audio_transcoder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Paperclip
+  class AudioTranscoder < Paperclip::Processor
+    def make
+      meta = ::Av.cli.identify(@file.path)
+      # {:length=>"0:00:02.14", :duration=>2.14, :audio_encode=>"mp3", :audio_bitrate=>"44100 Hz", :audio_channels=>"mono"}
+      if meta[:duration] > 60.0
+        raise Mastodon::ValidationError, "Audio uploads must be less than 60 seconds in length."
+      end
+      
+      final_file = Paperclip::Transcoder.make(file, options, attachment)
+      
+      attachment.instance.file_file_name    = 'media.mp4'
+      attachment.instance.file_content_type = 'video/mp4'
+      attachment.instance.type              = MediaAttachment.types[:video]
+
+      final_file
+    end
+  end
+end
diff --git a/lib/tasks/glitchsoc.rake b/lib/tasks/glitchsoc.rake
new file mode 100644
index 000000000..79e864648
--- /dev/null
+++ b/lib/tasks/glitchsoc.rake
@@ -0,0 +1,8 @@
+namespace :glitchsoc do
+  desc 'Backfill local-only flag on statuses table'
+  task backfill_local_only: :environment do
+    Status.local.where(local_only: nil).find_each do |st|
+      ActiveRecord::Base.logger.silence { st.update_attribute(:local_only, st.marked_local_only?) }
+    end
+  end
+end
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 33969d470..38dbed982 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -171,11 +171,10 @@ namespace :mastodon do
   end
 
   namespace :emails do
-    desc 'Send out digest e-mails'
+    desc 'Send out digest e-mails (deprecated)'
     task digest: :environment do
-      User.confirmed.joins(:account).where(accounts: { silenced: false, suspended: false }).where('current_sign_in_at < ?', 20.days.ago).find_each do |user|
-        DigestMailerWorker.perform_async(user.id)
-      end
+      # No-op
+      # This task is now executed via sidekiq-scheduler
     end
   end
 
diff --git a/package.json b/package.json
index 4c5dceabb..277e5bb63 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
     "manage:translations": "node ./config/webpack/translationRunner.js",
     "start": "node ./streaming/index.js",
     "test": "npm run test:lint && npm run test:jest",
-    "test:lint": "eslint -c .eslintrc.yml --ext=js app/javascript/ config/webpack/ spec/javascript/ streaming/",
+    "test:lint": "eslint -c .eslintrc.yml --ext=js app/javascript/ config/webpack/ streaming/",
     "test:jest": "cross-env NODE_ENV=test jest --coverage",
     "postinstall": "npm rebuild node-sass"
   },
@@ -22,6 +22,7 @@
   "private": true,
   "dependencies": {
     "array-includes": "^3.0.3",
+    "atrament": "^0.2.3",
     "autoprefixer": "^7.1.6",
     "axios": "~0.16.2",
     "babel-core": "^6.25.0",
@@ -120,13 +121,13 @@
     "websocket.js": "^0.1.12"
   },
   "devDependencies": {
-    "babel-eslint": "^7.2.3",
+    "babel-eslint": "^8.2.1",
     "enzyme": "^3.2.0",
     "enzyme-adapter-react-16": "^1.1.0",
-    "eslint": "^3.19.0",
+    "eslint": "^4.15.0",
     "eslint-plugin-import": "^2.8.0",
-    "eslint-plugin-jsx-a11y": "^4.0.0",
-    "eslint-plugin-react": "^6.10.3",
+    "eslint-plugin-jsx-a11y": "^5.1.1",
+    "eslint-plugin-react": "^7.5.1",
     "jest": "^21.2.1",
     "raf": "^3.4.0",
     "react-intl-translations-manager": "^5.0.0",
diff --git a/public/500.html b/public/500.html
index 45a907808..e69de29bb 120000..100644
--- a/public/500.html
+++ b/public/500.html
@@ -1 +0,0 @@
-assets/500.html
\ No newline at end of file
diff --git a/public/background-cybre.png b/public/background-cybre.png
new file mode 100644
index 000000000..151fd5584
--- /dev/null
+++ b/public/background-cybre.png
Binary files differdiff --git a/public/clock.js b/public/clock.js
new file mode 100644
index 000000000..ffb9beae8
--- /dev/null
+++ b/public/clock.js
@@ -0,0 +1,54 @@
+document.addEventListener("DOMContentLoaded", function(event) { 
+  updateClock();
+  setInterval(updateClock, 1000);
+});
+
+function getNextOpen(now) {
+    var days = [[0, 14], [4, 18], [8, 22], [12], [2, 16], [6, 20], [10]]
+    var nowday = now.getUTCDay();
+    var nour = now.getUTCHours();
+
+    var open_hour = -1;
+    var d = 0;
+
+    while (open_hour == -1) {
+        var times = days[(nowday + d) % 7];
+        for (var i = 0; i < times.length; ++i) {
+            var time = times[i];
+            if (time == nour) {
+                return "refresh";
+            } else if (time > nour || d > 0) {
+                open_hour = time;
+                break;
+            }
+        }
+        if (open_hour == -1) {
+            d += 1;
+            nour = -1;
+        }
+    }
+
+    var open = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + d));
+    var ts = open.setUTCHours(open_hour);
+    return open;
+}
+
+function updateClock() {
+    var clock = document.querySelector(".closed-registrations-message .clock");
+    var now = new Date();
+    var open = getNextOpen(now);
+
+    if (open == "refresh") {
+        location.reload();
+        return;
+    }
+
+    var until = open - now;
+    var ms = until % 1000;
+    var s =  Math.floor((until / 1000)) % 60;
+    var m =  Math.floor((until / 1000 / 60)) % 60;
+    var h =  Math.floor((until / 1000 / 60 / 60));
+    if (m < 10) m = "0" + m;
+    if (s < 10) s = "0" + s;
+    clock.innerHTML = h + ":" + m + ":" + s;
+}
diff --git a/public/logo-cybre-glitch.gif b/public/logo-cybre-glitch.gif
new file mode 100644
index 000000000..abe9b2a9a
--- /dev/null
+++ b/public/logo-cybre-glitch.gif
Binary files differdiff --git a/public/riot-glitch.png b/public/riot-glitch.png
new file mode 100644
index 000000000..1c97ce5f1
--- /dev/null
+++ b/public/riot-glitch.png
Binary files differdiff --git a/spec/controllers/activitypub/follows_controller_spec.rb b/spec/controllers/activitypub/follows_controller_spec.rb
deleted file mode 100644
index 6026cd353..000000000
--- a/spec/controllers/activitypub/follows_controller_spec.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe ActivityPub::FollowsController, type: :controller do
-  let(:follow_request) { Fabricate(:follow_request, account: account) }
-
-  render_views
-
-  context 'with local account' do
-    let(:account) { Fabricate(:account, domain: nil) }
-
-    it 'returns follow request' do
-      signed_request = Request.new(:get, account_follow_url(account, follow_request))
-      signed_request.on_behalf_of(follow_request.target_account)
-      request.headers.merge! signed_request.headers
-
-      get :show, params: { id: follow_request, account_username: account.username }
-
-      expect(body_as_json[:id]).to eq ActivityPub::TagManager.instance.uri_for(follow_request)
-      expect(response).to have_http_status :success
-    end
-
-    it 'returns http 404 without signature' do
-      get :show, params: { id: follow_request, account_username: account.username }
-      expect(response).to have_http_status 404
-    end
-  end
-
-  context 'with remote account' do
-    let(:account) { Fabricate(:account, domain: Faker::Internet.domain_name) }
-
-    it 'returns http 404' do
-      signed_request = Request.new(:get, account_follow_url(account, follow_request))
-      signed_request.on_behalf_of(follow_request.target_account)
-      request.headers.merge! signed_request.headers
-
-      get :show, params: { id: follow_request, account_username: account.username }
-
-      expect(response).to have_http_status 404
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
index 461b8b34b..247420d08 100644
--- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
@@ -51,7 +51,9 @@ describe Api::V1::Accounts::CredentialsController do
 
       describe 'with invalid data' do
         before do
-          patch :update, params: { note: 'This is too long. ' * 10 }
+          note = 'This is too long. '
+          note = note + 'a' * (Account::MAX_NOTE_LENGTH - note.length + 1)
+          patch :update, params: { note: note }
         end
 
         it 'returns http unprocessable entity' do
diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb
index 97d6c2773..7387b9d2d 100644
--- a/spec/controllers/api/v1/mutes_controller_spec.rb
+++ b/spec/controllers/api/v1/mutes_controller_spec.rb
@@ -18,4 +18,24 @@ RSpec.describe Api::V1::MutesController, type: :controller do
       expect(response).to have_http_status(:success)
     end
   end
+
+  describe 'GET #details' do
+    before do
+      get :details, params: { limit: 1 }
+    end
+
+    let(:mutes) { JSON.parse(response.body) }
+
+    it 'returns http success' do
+      expect(response).to have_http_status(:success)
+    end
+
+    it 'returns one mute' do
+      expect(mutes.size).to be(1)
+    end
+
+    it 'returns whether the mute hides notifications' do
+      expect(mutes.first["hide_notifications"]).to be(false)
+    end 
+  end
 end
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 6a54a78c5..51c0e3c70 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -47,22 +47,18 @@ describe ApplicationController, type: :controller do
     include_examples 'respond_with_error', 422
   end
 
-  it "does not force ssl if LOCAL_HTTPS is not 'true'" do
+  it "does not force ssl if Rails.env.production? is not 'true'" do
     routes.draw { get 'success' => 'anonymous#success' }
-    ClimateControl.modify LOCAL_HTTPS: '' do
-      allow(Rails.env).to receive(:production?).and_return(true)
-      get 'success'
-      expect(response).to have_http_status(:success)
-    end
+    allow(Rails.env).to receive(:production?).and_return(false)
+    get 'success'
+    expect(response).to have_http_status(:success)
   end
 
-  it "forces ssl if LOCAL_HTTPS is 'true'" do
+  it "forces ssl if Rails.env.production? is 'true'" do
     routes.draw { get 'success' => 'anonymous#success' }
-    ClimateControl.modify LOCAL_HTTPS: 'true' do
-      allow(Rails.env).to receive(:production?).and_return(true)
-      get 'success'
-      expect(response).to redirect_to('https://test.host/success')
-    end
+    allow(Rails.env).to receive(:production?).and_return(true)
+    get 'success'
+    expect(response).to redirect_to('https://test.host/success')
   end
 
   describe 'helper_method :current_account' do
diff --git a/spec/controllers/settings/flavours_controller_spec.rb b/spec/controllers/settings/flavours_controller_spec.rb
new file mode 100644
index 000000000..f89bde1f9
--- /dev/null
+++ b/spec/controllers/settings/flavours_controller_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe Settings::FlavoursController, type: :controller do
+  let(:user) { Fabricate(:user) }
+
+  before do
+    sign_in user, scope: :user
+  end
+
+  describe 'PUT #update' do
+    describe 'without a user[setting_skin] parameter' do
+      it 'sets the selected flavour' do
+        put :update, params: { flavour: 'schnozzberry' }
+
+        user.reload
+
+        expect(user.setting_flavour).to eq 'schnozzberry'
+      end
+    end
+
+    describe 'with a user[setting_skin] parameter' do
+      before do
+        put :update, params: { flavour: 'schnozzberry', user: { setting_skin: 'wallpaper' } }
+
+        user.reload
+      end
+
+      it 'sets the selected flavour' do
+        expect(user.setting_flavour).to eq 'schnozzberry'
+      end
+
+      it 'sets the selected skin' do
+        expect(user.setting_skin).to eq 'wallpaper'
+      end
+    end
+  end
+end
diff --git a/spec/controllers/settings/keyword_mutes_controller_spec.rb b/spec/controllers/settings/keyword_mutes_controller_spec.rb
new file mode 100644
index 000000000..a8c37a072
--- /dev/null
+++ b/spec/controllers/settings/keyword_mutes_controller_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Settings::KeywordMutesController, type: :controller do
+
+end
diff --git a/spec/fabricators/glitch_keyword_mute_fabricator.rb b/spec/fabricators/glitch_keyword_mute_fabricator.rb
new file mode 100644
index 000000000..20d393320
--- /dev/null
+++ b/spec/fabricators/glitch_keyword_mute_fabricator.rb
@@ -0,0 +1,2 @@
+Fabricator('Glitch::KeywordMute') do
+end
diff --git a/spec/helpers/settings/keyword_mutes_helper_spec.rb b/spec/helpers/settings/keyword_mutes_helper_spec.rb
new file mode 100644
index 000000000..a19d518dd
--- /dev/null
+++ b/spec/helpers/settings/keyword_mutes_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+# Specs in this file have access to a helper object that includes
+# the Settings::KeywordMutesHelper. For example:
+#
+# describe Settings::KeywordMutesHelper do
+#   describe "string concat" do
+#     it "concats two strings with spaces" do
+#       expect(helper.concat_strings("this","that")).to eq("this that")
+#     end
+#   end
+# end
+RSpec.describe Settings::KeywordMutesHelper, type: :helper do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb
index 9f43be35d..6503c83e3 100644
--- a/spec/lib/activitypub/activity/accept_spec.rb
+++ b/spec/lib/activitypub/activity/accept_spec.rb
@@ -3,49 +3,36 @@ require 'rails_helper'
 RSpec.describe ActivityPub::Activity::Accept do
   let(:sender)    { Fabricate(:account) }
   let(:recipient) { Fabricate(:account) }
-  let!(:follow_request) { Fabricate(:follow_request, account: recipient, target_account: sender) }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Accept',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: {
+        id: 'bar',
+        type: 'Follow',
+        actor: ActivityPub::TagManager.instance.uri_for(recipient),
+        object: ActivityPub::TagManager.instance.uri_for(sender),
+      },
+    }.with_indifferent_access
+  end
 
   describe '#perform' do
     subject { described_class.new(json, sender) }
 
     before do
+      Fabricate(:follow_request, account: recipient, target_account: sender)
       subject.perform
     end
 
-    context 'with concerete object representation' do
-      let(:json) do
-        {
-          '@context': 'https://www.w3.org/ns/activitystreams',
-          id: 'foo',
-          type: 'Accept',
-          actor: ActivityPub::TagManager.instance.uri_for(sender),
-          object: {
-            type: 'Follow',
-            actor: ActivityPub::TagManager.instance.uri_for(recipient),
-            object: ActivityPub::TagManager.instance.uri_for(sender),
-          },
-        }.with_indifferent_access
-      end
-
-      it 'creates a follow relationship' do
-        expect(recipient.following?(sender)).to be true
-      end
+    it 'creates a follow relationship' do
+      expect(recipient.following?(sender)).to be true
     end
 
-    context 'with object represented by id' do
-      let(:json) do
-        {
-          '@context': 'https://www.w3.org/ns/activitystreams',
-          id: 'foo',
-          type: 'Accept',
-          actor: ActivityPub::TagManager.instance.uri_for(sender),
-          object: ActivityPub::TagManager.instance.uri_for(follow_request),
-        }.with_indifferent_access
-      end
-
-      it 'creates a follow relationship' do
-        expect(recipient.following?(sender)).to be true
-      end
+    it 'removes the follow request' do
+      expect(recipient.requested?(sender)).to be false
     end
   end
 end
diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb
index 3c3991c13..51f54a398 100644
--- a/spec/lib/activitypub/activity/create_spec.rb
+++ b/spec/lib/activitypub/activity/create_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe ActivityPub::Activity::Create do
   let(:json) do
     {
       '@context': 'https://www.w3.org/ns/activitystreams',
-      id: 'foo',
+      id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
       type: 'Create',
       actor: ActivityPub::TagManager.instance.uri_for(sender),
       object: object_json,
@@ -16,6 +16,8 @@ RSpec.describe ActivityPub::Activity::Create do
   subject { described_class.new(json, sender) }
 
   before do
+    sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender))
+
     stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
     stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png'))
   end
@@ -28,7 +30,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'standalone' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
         }
@@ -52,7 +54,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'public' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           to: 'https://www.w3.org/ns/activitystreams#Public',
@@ -70,7 +72,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'unlisted' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           cc: 'https://www.w3.org/ns/activitystreams#Public',
@@ -88,7 +90,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'private' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           to: 'http://example.com/followers',
@@ -108,7 +110,7 @@ RSpec.describe ActivityPub::Activity::Create do
 
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           to: ActivityPub::TagManager.instance.uri_for(recipient),
@@ -128,7 +130,7 @@ RSpec.describe ActivityPub::Activity::Create do
 
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status),
@@ -151,7 +153,7 @@ RSpec.describe ActivityPub::Activity::Create do
 
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           tag: [
@@ -174,7 +176,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with mentions missing href' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           tag: [
@@ -194,7 +196,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with media attachments' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           attachment: [
@@ -218,7 +220,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with media attachments missing url' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           attachment: [
@@ -239,7 +241,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with hashtags' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           tag: [
@@ -263,7 +265,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with hashtags missing name' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum',
           tag: [
@@ -284,7 +286,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with emojis' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum :tinking:',
           tag: [
@@ -310,7 +312,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with emojis missing name' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum :tinking:',
           tag: [
@@ -333,7 +335,7 @@ RSpec.describe ActivityPub::Activity::Create do
     context 'with emojis missing icon' do
       let(:object_json) do
         {
-          id: 'bar',
+          id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
           type: 'Note',
           content: 'Lorem ipsum :tinking:',
           tag: [
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index f2103a4b1..f87ef383a 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -112,6 +112,13 @@ RSpec.describe FeedManager do
         expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
       end
 
+      it 'returns true for status by followee mentioning muted account' do
+        bob.mute!(jeff)
+        bob.follow!(alice)
+        status = PostStatusService.new.call(alice, 'Hey @jeff')
+        expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
+      end
+
       it 'returns true for reblog of a personally blocked domain' do
         alice.block_domain!('example.com')
         alice.follow!(jeff)
@@ -119,6 +126,60 @@ RSpec.describe FeedManager do
         reblog = Fabricate(:status, reblog: status, account: jeff)
         expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
       end
+
+      it 'returns true for a status containing a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, text: 'This is a hot take', account: bob)
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
+
+      it 'returns true for a reply containing a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        s1 = Fabricate(:status, text: 'Something', account: alice)
+        s2 = Fabricate(:status, text: 'This is a hot take', thread: s1, account: bob)
+
+        expect(FeedManager.instance.filter?(:home, s2, alice.id)).to be true
+      end
+
+      it 'returns true for a status whose spoiler text contains a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
+
+      it 'returns true for a reblog containing a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, text: 'This is a hot take', account: bob)
+        reblog = Fabricate(:status, reblog: status, account: jeff)
+
+        expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
+      end
+
+      it 'returns true for a reblog whose spoiler text contains a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
+        reblog = Fabricate(:status, reblog: status, account: jeff)
+
+        expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
+      end
+
+      it 'returns true for a status with a tag that matches a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'jorts')
+        status = Fabricate(:status, account: bob)
+	status.tags << Fabricate(:tag, name: 'jorts')
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
+
+      it 'returns true for a status with a tag that matches an octothorpe-prefixed muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: '#jorts')
+        status = Fabricate(:status, account: bob)
+	status.tags << Fabricate(:tag, name: 'jorts')
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
     end
 
     context 'for mentions feed' do
@@ -147,6 +208,13 @@ RSpec.describe FeedManager do
         bob.follow!(alice)
         expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
       end
+
+      it 'returns true for status that contains a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take')
+        status = Fabricate(:status, text: 'This is a hot take', account: alice)
+        bob.follow!(alice)
+        expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
+      end
     end
   end
 
diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb
index a08a80d17..e31445c36 100644
--- a/spec/mailers/previews/notification_mailer_preview.rb
+++ b/spec/mailers/previews/notification_mailer_preview.rb
@@ -1,4 +1,5 @@
 # Preview all emails at http://localhost:3000/rails/mailers/notification_mailer
+
 class NotificationMailerPreview < ActionMailer::Preview
   # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention
   def mention
@@ -12,6 +13,12 @@ class NotificationMailerPreview < ActionMailer::Preview
     NotificationMailer.follow(f.target_account, Notification.find_by(activity: f))
   end
 
+  # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow_request
+  def follow_request
+    f = Follow.last
+    NotificationMailer.follow_request(f.target_account, Notification.find_by(activity: f))
+  end
+
   # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite
   def favourite
     f = Favourite.last
diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb
new file mode 100644
index 000000000..6ed0090f4
--- /dev/null
+++ b/spec/mailers/previews/user_mailer_preview.rb
@@ -0,0 +1,32 @@
+# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
+
+class UserMailerPreview < ActionMailer::Preview
+  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/confirmation_instructions
+  def confirmation_instructions
+    UserMailer.confirmation_instructions(User.first, 'spec')
+  end
+
+  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/email_changed
+  def email_changed
+    user = User.first
+    user.unconfirmed_email = 'foo@bar.com'
+    UserMailer.email_changed(user)
+  end
+
+  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/password_change
+  def password_change
+    UserMailer.password_change(User.first)
+  end
+
+  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/reconfirmation_instructions
+  def reconfirmation_instructions
+    user = User.first
+    user.unconfirmed_email = 'foo@bar.com'
+    UserMailer.confirmation_instructions(user, 'spec')
+  end
+
+  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/reset_password_instructions
+  def reset_password_instructions
+    UserMailer.reset_password_instructions(User.first, 'spec')
+  end
+end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 9c1492c90..7501c498c 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -636,8 +636,8 @@ RSpec.describe Account, type: :model do
         expect(account).to model_have_error_on_field(:display_name)
       end
 
-      it 'is invalid if the note is longer than 160 characters' do
-        account = Fabricate.build(:account, note: Faker::Lorem.characters(161))
+      it 'is invalid if the note is longer than 500 characters' do
+        account = Fabricate.build(:account, note: Faker::Lorem.characters(501))
         account.valid?
         expect(account).to model_have_error_on_field(:note)
       end
@@ -676,8 +676,8 @@ RSpec.describe Account, type: :model do
         expect(account).not_to model_have_error_on_field(:display_name)
       end
 
-      it 'is valid even if the note is longer than 160 characters' do
-        account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(161))
+      it 'is valid even if the note is longer than 500 characters' do
+        account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(501))
         account.valid?
         expect(account).not_to model_have_error_on_field(:note)
       end
diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb
index d08bdc8b9..95bf9561d 100644
--- a/spec/models/concerns/account_interactions_spec.rb
+++ b/spec/models/concerns/account_interactions_spec.rb
@@ -12,12 +12,19 @@ describe AccountInteractions do
     subject { Account.following_map(target_account_ids, account_id) }
 
     context 'account with Follow' do
-      it 'returns { target_account_id => true }' do
+      it 'returns { target_account_id => { reblogs: true } }' do
         Fabricate(:follow, account: account, target_account: target_account)
         is_expected.to eq(target_account_id => { reblogs: true })
       end
     end
 
+    context 'account with Follow but with reblogs disabled' do
+      it 'returns { target_account_id => { reblogs: false } }' do
+        Fabricate(:follow, account: account, target_account: target_account, show_reblogs: false)
+        is_expected.to eq(target_account_id => { reblogs: false })
+      end
+    end
+
     context 'account without Follow' do
       it 'returns {}' do
         is_expected.to eq({})
@@ -579,7 +586,44 @@ describe AccountInteractions do
       end
 
       it 'does mute notifications' do
-        expect(me.muting_notifications?(you)).to be true
+        expect(me.muting_notifications?(you)).to be true 
+      end
+    end
+  end
+
+  describe 'ignoring reblogs from an account' do
+    before do
+      @me = Fabricate(:account, username: 'Me')
+      @you = Fabricate(:account, username: 'You')
+    end
+
+    context 'with the reblogs option unspecified' do
+      before do
+        @me.follow!(@you)
+      end
+
+      it 'defaults to showing reblogs' do
+        expect(@me.muting_reblogs?(@you)).to be(false)
+      end
+    end
+
+    context 'with the reblogs option set to false' do
+      before do
+        @me.follow!(@you, reblogs: false)
+      end
+
+      it 'does mute reblogs' do
+        expect(@me.muting_reblogs?(@you)).to be(true)
+      end
+    end
+
+    context 'with the reblogs option set to true' do
+      before do
+        @me.follow!(@you, reblogs: true)
+      end
+
+      it 'does not mute reblogs' do
+        expect(@me.muting_reblogs?(@you)).to be(false)
       end
     end
   end
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
index 7e10631a3..7bc93a2aa 100644
--- a/spec/models/follow_request_spec.rb
+++ b/spec/models/follow_request_spec.rb
@@ -13,6 +13,13 @@ RSpec.describe FollowRequest, type: :model do
       follow_request.authorize!
     end
 
+    it 'generates a Follow' do
+      follow_request = Fabricate.create(:follow_request)
+      follow_request.authorize!
+      target = follow_request.target_account
+      expect(follow_request.account.following?(target)).to be true
+    end
+
     it 'correctly passes show_reblogs when true' do
       follow_request = Fabricate.create(:follow_request, show_reblogs: true)
       follow_request.authorize!
@@ -27,12 +34,4 @@ RSpec.describe FollowRequest, type: :model do
       expect(follow_request.account.muting_reblogs?(target)).to be true
     end
   end
-
-  describe '#object_type' do
-    let(:follow_request) { Fabricate(:follow_request) }
-
-    it 'equals to :follow' do
-      expect(follow_request.object_type).to eq :follow
-    end
-  end
 end
diff --git a/spec/models/glitch/keyword_mute_spec.rb b/spec/models/glitch/keyword_mute_spec.rb
new file mode 100644
index 000000000..0ffc7b18f
--- /dev/null
+++ b/spec/models/glitch/keyword_mute_spec.rb
@@ -0,0 +1,157 @@
+require 'rails_helper'
+
+RSpec.describe Glitch::KeywordMute, type: :model do
+  let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) }
+  let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) }
+
+  describe '.text_matcher_for' do
+    let(:matcher) { Glitch::KeywordMute.text_matcher_for(alice.id) }
+
+    describe 'with no mutes' do
+      before do
+        Glitch::KeywordMute.delete_all
+      end
+
+      it 'does not match' do
+        expect(matcher.matches?('This is a hot take')).to be_falsy
+      end
+    end
+
+    describe 'with mutes' do
+      it 'does not match keywords set by a different account' do
+        Glitch::KeywordMute.create!(account: bob, keyword: 'take')
+
+        expect(matcher.matches?('This is a hot take')).to be_falsy
+      end
+
+      it 'does not match if no keywords match the status text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
+
+        expect(matcher.matches?('This is a hot take')).to be_falsy
+      end
+
+      it 'considers word boundaries when matching' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true)
+
+        expect(matcher.matches?('bobcats')).to be_falsy
+      end
+
+      it 'matches substrings if whole_word is false' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'take', whole_word: false)
+
+        expect(matcher.matches?('This is a shiitake mushroom')).to be_truthy
+      end
+
+      it 'matches keywords at the beginning of the text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'take')
+
+        expect(matcher.matches?('Take this')).to be_truthy
+      end
+
+      it 'matches keywords at the end of the text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'take')
+
+        expect(matcher.matches?('This is a hot take')).to be_truthy
+      end
+
+      it 'matches if at least one keyword case-insensitively matches the text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
+
+        expect(matcher.matches?('This is a HOT take')).to be_truthy
+      end
+
+      it 'maintains case-insensitivity when combining keywords into a single matcher' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
+        Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
+
+        expect(matcher.matches?('This is a HOT take')).to be_truthy
+      end
+
+      it 'matches keywords surrounded by non-alphanumeric ornamentation' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
+
+        expect(matcher.matches?('(hot take)')).to be_truthy
+      end
+
+      it 'escapes metacharacters in keywords' do
+        Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)')
+
+        expect(matcher.matches?('(hot take)')).to be_truthy
+      end
+
+      it 'uses case-folding rules appropriate for more than just English' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'großeltern')
+
+        expect(matcher.matches?('besuch der grosseltern')).to be_truthy
+      end
+
+      it 'matches keywords that are composed of multiple words' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake')
+
+        expect(matcher.matches?('This is a shiitake')).to be_truthy
+        expect(matcher.matches?('This is shiitake')).to_not be_truthy
+      end
+    end
+  end
+
+  describe '.tag_matcher_for' do
+    let(:matcher) { Glitch::KeywordMute.tag_matcher_for(alice.id) }
+    let(:status) { Fabricate(:status) }
+
+    describe 'with no mutes' do
+      before do
+        Glitch::KeywordMute.delete_all
+      end
+
+      it 'does not match' do
+        status.tags << Fabricate(:tag, name: 'xyzzy')
+
+        expect(matcher.matches?(status.tags)).to be false
+      end
+    end
+
+    describe 'with mutes' do
+      it 'does not match keywords set by a different account' do
+        status.tags << Fabricate(:tag, name: 'xyzzy')
+        Glitch::KeywordMute.create!(account: bob, keyword: 'take')
+
+        expect(matcher.matches?(status.tags)).to be false
+      end
+
+      it 'matches #xyzzy when given the mute "#xyzzy"' do
+        status.tags << Fabricate(:tag, name: 'xyzzy')
+        Glitch::KeywordMute.create!(account: alice, keyword: '#xyzzy')
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'matches #thingiverse when given the non-whole-word mute "#thing"' do
+        status.tags << Fabricate(:tag, name: 'thingiverse')
+        Glitch::KeywordMute.create!(account: alice, keyword: '#thing', whole_word: false)
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'matches #hashtag when given the mute "##hashtag""' do
+        status.tags << Fabricate(:tag, name: 'hashtag')
+        Glitch::KeywordMute.create!(account: alice, keyword: '##hashtag')
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'matches #oatmeal when given the non-whole-word mute "oat"' do
+        status.tags << Fabricate(:tag, name: 'oatmeal')
+        Glitch::KeywordMute.create!(account: alice, keyword: 'oat', whole_word: false)
+
+        expect(matcher.matches?(status.tags)).to be true
+      end
+
+      it 'does not match #oatmeal when given the mute "#oat"' do
+        status.tags << Fabricate(:tag, name: 'oatmeal')
+        Glitch::KeywordMute.create!(account: alice, keyword: 'oat')
+
+        expect(matcher.matches?(status.tags)).to be false
+      end
+    end
+  end
+end
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 4b5c20871..1f5a03877 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -197,6 +197,43 @@ RSpec.describe Status, type: :model do
     end
   end
 
+  describe 'on create' do
+    let(:local_account) { Fabricate(:account, username: 'local', domain: nil) }
+    let(:remote_account) { Fabricate(:account, username: 'remote', domain: 'example.com') }
+
+    subject { Status.new }
+
+    describe 'on a status that ends with the local-only emoji' do
+      before do
+        subject.text = 'A toot ' + subject.local_only_emoji
+      end
+
+      context 'if the status originates from this instance' do
+        before do
+          subject.account = local_account
+        end
+
+        it 'is marked local-only' do
+          subject.save!
+
+          expect(subject).to be_local_only
+        end
+      end
+
+      context 'if the status is remote' do
+        before do
+          subject.account = remote_account
+        end
+
+        it 'is not marked local-only' do
+          subject.save!
+
+          expect(subject).to_not be_local_only
+        end
+      end
+    end
+  end
+
   describe '.mutes_map' do
     let(:status)  { Fabricate(:status) }
     let(:account) { Fabricate(:account) }
@@ -304,6 +341,55 @@ RSpec.describe Status, type: :model do
     end
   end
 
+  describe '.as_direct_timeline' do
+    let(:account) { Fabricate(:account) }
+    let(:followed) { Fabricate(:account) }
+    let(:not_followed) { Fabricate(:account) }
+
+    before do
+      Fabricate(:follow, account: account, target_account: followed)
+
+      @self_public_status = Fabricate(:status, account: account, visibility: :public)
+      @self_direct_status = Fabricate(:status, account: account, visibility: :direct)
+      @followed_public_status = Fabricate(:status, account: followed, visibility: :public)
+      @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct)
+      @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct)
+
+      @results = Status.as_direct_timeline(account)
+    end
+
+    it 'does not include public statuses from self' do
+      expect(@results).to_not include(@self_public_status)
+    end
+
+    it 'includes direct statuses from self' do
+      expect(@results).to include(@self_direct_status)
+    end
+
+    it 'does not include public statuses from followed' do
+      expect(@results).to_not include(@followed_public_status)
+    end
+
+    it 'includes direct statuses mentioning recipient from followed' do
+      Fabricate(:mention, account: account, status: @followed_direct_status)
+      expect(@results).to include(@followed_direct_status)
+    end
+
+    it 'does not include direct statuses not mentioning recipient from followed' do
+      expect(@results).to_not include(@followed_direct_status)
+    end
+
+    it 'includes direct statuses mentioning recipient from non-followed' do
+      Fabricate(:mention, account: account, status: @not_followed_direct_status)
+      expect(@results).to include(@not_followed_direct_status)
+    end
+
+    it 'does not include direct statuses not mentioning recipient from non-followed' do
+      expect(@results).to_not include(@not_followed_direct_status)
+    end
+
+  end
+
   describe '.as_public_timeline' do
     it 'only includes statuses with public visibility' do
       public_status = Fabricate(:status, visibility: :public)
@@ -500,6 +586,32 @@ RSpec.describe Status, type: :model do
         end
       end
     end
+
+    context 'with local-only statuses' do
+      let(:status) { Fabricate(:status, local_only: true) }
+
+      subject { Status.as_public_timeline(viewer) }
+
+      context 'without a viewer' do
+        let(:viewer) { nil }
+
+        it 'excludes local-only statuses' do
+          expect(subject).to_not include(status)
+        end
+      end
+
+      context 'with a viewer' do
+        let(:viewer) { Fabricate(:account, username: 'viewer') }
+
+        it 'includes local-only statuses' do
+          expect(subject).to include(status)
+        end
+      end
+
+      # TODO: What happens if the viewer is remote?
+      # Can the viewer be remote?
+      # What prevents the viewer from being remote?
+    end
   end
 
   describe '.as_tag_timeline' do
@@ -521,6 +633,27 @@ RSpec.describe Status, type: :model do
       results = Status.as_tag_timeline(tag)
       expect(results).to include(status)
     end
+
+    context 'on a local-only status' do
+      let(:tag) { Fabricate(:tag) }
+      let(:status) { Fabricate(:status, local_only: true, tags: [tag]) }
+
+      context 'without a viewer' do
+        let(:viewer) { nil }
+
+        it 'filters the local-only status out of the result set' do
+          expect(Status.as_tag_timeline(tag, viewer)).not_to include(status)
+        end
+      end
+
+      context 'with a viewer' do
+        let(:viewer) { Fabricate(:account, username: 'viewer', domain: nil) }
+
+        it 'keeps the local-only status in the result set' do
+          expect(Status.as_tag_timeline(tag, viewer)).to include(status)
+        end
+      end
+    end
   end
 
   describe '.permitted_for' do
diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb
index bacb8fd9e..a90e22aad 100644
--- a/spec/policies/status_policy_spec.rb
+++ b/spec/policies/status_policy_spec.rb
@@ -71,6 +71,12 @@ RSpec.describe StatusPolicy, type: :model do
 
       expect(subject).to_not permit(viewer, status)
     end
+
+    it 'denies access when local-only and the viewer is not logged in' do
+      allow(status).to receive(:local_only?) { true }
+
+      expect(subject).to_not permit(nil, status)
+    end
   end
 
   permissions :reblog? do
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 67c6b9205..4f7399505 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -46,7 +46,7 @@ RSpec.configure do |config|
   config.include ActiveSupport::Testing::TimeHelpers
 
   config.before :each, type: :feature do
-    https = Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true'
+    https = ENV['LOCAL_HTTPS'] == 'true'
     Capybara.app_host = "http#{https ? 's' : ''}://#{ENV.fetch('LOCAL_DOMAIN')}"
   end
 
diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb
index ad26abc5b..a533e8413 100644
--- a/spec/services/activitypub/fetch_remote_status_service_spec.rb
+++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb
@@ -21,6 +21,8 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do
 
   describe '#call' do
     before do
+      sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender))
+
       stub_request(:head, 'https://example.com/watch?v=12345').to_return(status: 404, body: '')
       subject.call(object[:id], prefetched_body: Oj.dump(object))
     end
@@ -48,13 +50,13 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do
             {
               type: 'Link',
               mimeType: 'application/x-bittorrent',
-              href: 'https://example.com/12345.torrent',
+              href: "https://#{valid_domain}/12345.torrent",
             },
 
             {
               type: 'Link',
               mimeType: 'text/html',
-              href: 'https://example.com/watch?v=12345',
+              href: "https://#{valid_domain}/watch?v=12345",
             },
           ],
         }
@@ -64,8 +66,8 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do
         status = sender.statuses.first
 
         expect(status).to_not be_nil
-        expect(status.url).to eq 'https://example.com/watch?v=12345'
-        expect(strip_tags(status.text)).to eq 'Nyan Cat 10 hours remix https://example.com/watch?v=12345'
+        expect(status.url).to eq "https://#{valid_domain}/watch?v=12345"
+        expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remix https://#{valid_domain}/watch?v=12345"
       end
     end
   end
diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb
index eb6210a1e..bb7601e76 100644
--- a/spec/services/notify_service_spec.rb
+++ b/spec/services/notify_service_spec.rb
@@ -48,6 +48,26 @@ RSpec.describe NotifyService do
     is_expected.to_not change(Notification, :count)
   end
 
+  describe 'reblogs' do
+    let(:status)   { Fabricate(:status, account: Fabricate(:account)) }
+    let(:activity) { Fabricate(:status, account: sender, reblog: status) }
+
+    it 'shows reblogs by default' do
+      recipient.follow!(sender)
+      is_expected.to change(Notification, :count)
+    end
+
+    it 'shows reblogs when explicitly enabled' do
+      recipient.follow!(sender, reblogs: true)
+      is_expected.to change(Notification, :count)
+    end
+
+    it 'hides reblogs when disabled' do
+      recipient.follow!(sender, reblogs: false)
+      is_expected.to_not change(Notification, :count)
+    end
+  end
+  
   context 'for direct messages' do
     let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) }
 
diff --git a/spec/services/precompute_feed_service_spec.rb b/spec/services/precompute_feed_service_spec.rb
index d1ef6c184..396a3c3fb 100644
--- a/spec/services/precompute_feed_service_spec.rb
+++ b/spec/services/precompute_feed_service_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe PrecomputeFeedService do
 
       subject.call(account)
 
-      expect(Redis.current.zscore(FeedManager.instance.key(:home, account.id), reblog.id)).to eq status.id.to_f
+      expect(Redis.current.zscore(FeedManager.instance.key(:home, account.id), reblog.id)).to be_within(0.1).of(status.id.to_f)
     end
 
     it 'does not raise an error even if it could not find any status' do
diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb
index e2d1a15ec..9355c7e3f 100644
--- a/spec/validators/status_length_validator_spec.rb
+++ b/spec/validators/status_length_validator_spec.rb
@@ -7,26 +7,31 @@ describe StatusLengthValidator do
     it 'does not add errors onto remote statuses'
     it 'does not add errors onto local reblogs'
 
-    it 'adds an error when content warning is over 500 characters' do
-      status = double(spoiler_text: 'a' * 520, text: '', errors: double(add: nil), local?: true, reblog?: false)
+    it 'adds an error when content warning is over MAX_CHARS characters' do
+      chars = StatusLengthValidator::MAX_CHARS + 1
+      status = double(spoiler_text: 'a' * chars, text: '', errors: double(add: nil), local?: true, reblog?: false)
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
 
-    it 'adds an error when text is over 500 characters' do
-      status = double(spoiler_text: '', text: 'a' * 520, errors: double(add: nil), local?: true, reblog?: false)
+    it 'adds an error when text is over MAX_CHARS characters' do
+      chars = StatusLengthValidator::MAX_CHARS + 1
+      status = double(spoiler_text: '', text: 'a' * chars, errors: double(add: nil), local?: true, reblog?: false)
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
 
-    it 'adds an error when text and content warning are over 500 characters total' do
-      status = double(spoiler_text: 'a' * 250, text: 'b' * 251, errors: double(add: nil), local?: true, reblog?: false)
+    it 'adds an error when text and content warning are over MAX_CHARS characters total' do
+      chars1 = 20
+      chars2 = StatusLengthValidator::MAX_CHARS + 1 - chars1
+      status = double(spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: double(add: nil), local?: true, reblog?: false)
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
 
     it 'counts URLs as 23 characters flat' do
-      text   = ('a' * 476) + " http://#{'b' * 30}.com/example"
+      chars = StatusLengthValidator::MAX_CHARS - 1 - 23
+      text   = ('a' * chars) + " http://#{'b' * 30}.com/example"
       status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
 
       subject.validate(status)
@@ -34,7 +39,9 @@ describe StatusLengthValidator do
     end
 
     it 'counts only the front part of remote usernames' do
-      text   = ('a' * 475) + " @alice@#{'b' * 30}.com"
+      username = '@alice'
+      chars = StatusLengthValidator::MAX_CHARS - 1 - username.length
+      text   = ('a' * 475) + " #{username}@#{'b' * 30}.com"
       status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
 
       subject.validate(status)
diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb
index 724643cbc..ca59fa9e3 100644
--- a/spec/views/about/show.html.haml_spec.rb
+++ b/spec/views/about/show.html.haml_spec.rb
@@ -3,6 +3,8 @@
 require 'rails_helper'
 
 describe 'about/show.html.haml', without_verify_partial_doubles: true do
+  let(:commit_hash) { '8925731c9869f55780644304e4420a1998e52607' }
+
   before do
     allow(view).to receive(:site_hostname).and_return('example.com')
     allow(view).to receive(:site_title).and_return('example site')
@@ -16,7 +18,9 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
                                 source_url: 'https://github.com/tootsuite/mastodon',
                                 open_registrations: false,
                                 thumbnail: nil,
-                                closed_registrations_message: 'yes')
+                                closed_registrations_message: 'yes',
+                                commit_hash: commit_hash)
+
     assign(:instance_presenter, instance_presenter)
     render
 
diff --git a/stack-fix.c b/stack-fix.c
new file mode 100644
index 000000000..09311a8f0
--- /dev/null
+++ b/stack-fix.c
@@ -0,0 +1,32 @@
+#include <dlfcn.h>
+#include <pthread.h>
+#include <stdio.h>
+
+// THIS IS TO AVOID A SIGFAULT WHEN RUNNING python3.6 manage.py runserver
+// This should be fixed at some point by Alpine and/or Python
+// Check this issue for more info
+// https://github.com/docker-library/python/issues/211
+typedef int (*func_t)(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
+
+int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg) {
+
+    pthread_attr_t local;
+    int used = 0, ret;
+
+    if (!attr) {
+        used = 1;
+        pthread_attr_init(&local);
+        attr = &local;
+    }
+    pthread_attr_setstacksize((void*)attr, 2 * 1024 * 1024); // 2 MB
+
+    func_t orig = (func_t)dlsym(RTLD_NEXT, "pthread_create");
+
+    ret = orig(thread, attr, start_routine, arg);
+
+    if (used) {
+        pthread_attr_destroy(&local);
+    }
+
+    return ret;
+}
diff --git a/streaming/index.js b/streaming/index.js
index a42a91242..d08b9cd87 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -465,6 +465,10 @@ const startWorker = (workerId) => {
     streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true);
   });
 
+  app.get('/api/v1/streaming/direct', (req, res) => {
+    streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
+  });
+
   app.get('/api/v1/streaming/hashtag', (req, res) => {
     streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
   });
@@ -516,6 +520,9 @@ const startWorker = (workerId) => {
     case 'public:local':
       streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;
+    case 'direct':
+      streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+      break;
     case 'hashtag':
       streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;
diff --git a/yarn.lock b/yarn.lock
index ed66dea21..cc888a7b9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,54 +2,53 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@7.0.0-beta.31":
-  version "7.0.0-beta.31"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0-beta.31.tgz#473d021ecc573a2cce1c07d5b509d5215f46ba35"
+"@babel/code-frame@7.0.0-beta.36":
+  version "7.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0-beta.36.tgz#2349d7ec04b3a06945ae173280ef8579b63728e4"
   dependencies:
     chalk "^2.0.0"
     esutils "^2.0.2"
     js-tokens "^3.0.0"
 
-"@babel/helper-function-name@7.0.0-beta.31":
-  version "7.0.0-beta.31"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0-beta.31.tgz#afe63ad799209989348b1109b44feb66aa245f57"
+"@babel/helper-function-name@7.0.0-beta.36":
+  version "7.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0-beta.36.tgz#366e3bc35147721b69009f803907c4d53212e88d"
   dependencies:
-    "@babel/helper-get-function-arity" "7.0.0-beta.31"
-    "@babel/template" "7.0.0-beta.31"
-    "@babel/traverse" "7.0.0-beta.31"
-    "@babel/types" "7.0.0-beta.31"
+    "@babel/helper-get-function-arity" "7.0.0-beta.36"
+    "@babel/template" "7.0.0-beta.36"
+    "@babel/types" "7.0.0-beta.36"
 
-"@babel/helper-get-function-arity@7.0.0-beta.31":
-  version "7.0.0-beta.31"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-beta.31.tgz#1176d79252741218e0aec872ada07efb2b37a493"
+"@babel/helper-get-function-arity@7.0.0-beta.36":
+  version "7.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-beta.36.tgz#f5383bac9a96b274828b10d98900e84ee43e32b8"
   dependencies:
-    "@babel/types" "7.0.0-beta.31"
+    "@babel/types" "7.0.0-beta.36"
 
-"@babel/template@7.0.0-beta.31":
-  version "7.0.0-beta.31"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.31.tgz#577bb29389f6c497c3e7d014617e7d6713f68bda"
+"@babel/template@7.0.0-beta.36":
+  version "7.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.36.tgz#02e903de5d68bd7899bce3c5b5447e59529abb00"
   dependencies:
-    "@babel/code-frame" "7.0.0-beta.31"
-    "@babel/types" "7.0.0-beta.31"
-    babylon "7.0.0-beta.31"
+    "@babel/code-frame" "7.0.0-beta.36"
+    "@babel/types" "7.0.0-beta.36"
+    babylon "7.0.0-beta.36"
     lodash "^4.2.0"
 
-"@babel/traverse@7.0.0-beta.31":
-  version "7.0.0-beta.31"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.0.0-beta.31.tgz#db399499ad74aefda014f0c10321ab255134b1df"
+"@babel/traverse@7.0.0-beta.36":
+  version "7.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.0.0-beta.36.tgz#1dc6f8750e89b6b979de5fe44aa993b1a2192261"
   dependencies:
-    "@babel/code-frame" "7.0.0-beta.31"
-    "@babel/helper-function-name" "7.0.0-beta.31"
-    "@babel/types" "7.0.0-beta.31"
-    babylon "7.0.0-beta.31"
+    "@babel/code-frame" "7.0.0-beta.36"
+    "@babel/helper-function-name" "7.0.0-beta.36"
+    "@babel/types" "7.0.0-beta.36"
+    babylon "7.0.0-beta.36"
     debug "^3.0.1"
-    globals "^10.0.0"
+    globals "^11.1.0"
     invariant "^2.2.0"
     lodash "^4.2.0"
 
-"@babel/types@7.0.0-beta.31":
-  version "7.0.0-beta.31"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0-beta.31.tgz#42c9c86784f674c173fb21882ca9643334029de4"
+"@babel/types@7.0.0-beta.36":
+  version "7.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0-beta.36.tgz#64f2004353de42adb72f9ebb4665fc35b5499d23"
   dependencies:
     esutils "^2.0.2"
     lodash "^4.2.0"
@@ -100,10 +99,14 @@ acorn@^4.0.3, acorn@^4.0.4:
   version "4.0.13"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787"
 
-acorn@^5.0.0, acorn@^5.1.1, acorn@^5.2.1:
+acorn@^5.0.0, acorn@^5.1.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.2.1.tgz#317ac7821826c22c702d66189ab8359675f135d7"
 
+acorn@^5.2.1:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822"
+
 adjust-sourcemap-loader@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-1.1.0.tgz#412d92404eb61e4113635012cba53a33d008e0e2"
@@ -116,15 +119,11 @@ adjust-sourcemap-loader@^1.1.0:
     object-path "^0.9.2"
     regex-parser "^2.2.1"
 
-ajv-keywords@^1.0.0:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c"
-
-ajv-keywords@^2.0.0:
+ajv-keywords@^2.0.0, ajv-keywords@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762"
 
-ajv@^4.7.0, ajv@^4.9.1:
+ajv@^4.9.1:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536"
   dependencies:
@@ -140,6 +139,15 @@ ajv@^5.0.0, ajv@^5.1.0, ajv@^5.1.5:
     fast-json-stable-stringify "^2.0.0"
     json-schema-traverse "^0.3.0"
 
+ajv@^5.2.3, ajv@^5.3.0:
+  version "5.5.2"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965"
+  dependencies:
+    co "^4.6.0"
+    fast-deep-equal "^1.0.0"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.3.0"
+
 align-text@^0.1.1, align-text@^0.1.3:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117"
@@ -156,10 +164,6 @@ amdefine@>=0.0.4:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
 
-ansi-escapes@^1.1.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
-
 ansi-escapes@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92"
@@ -220,9 +224,9 @@ argparse@^1.0.7:
   dependencies:
     sprintf-js "~1.0.2"
 
-aria-query@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-0.3.0.tgz#cb8a9984e2862711c83c80ade5b8f5ca0de2b467"
+aria-query@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-0.7.0.tgz#4af10a1e61573ddea0cf3b99b51c52c05b424d24"
   dependencies:
     ast-types-flow "0.0.7"
 
@@ -273,13 +277,6 @@ array-unique@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
 
-array.prototype.find@^2.0.1:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.0.4.tgz#556a5c5362c08648323ddaeb9de9d14bc1864c90"
-  dependencies:
-    define-properties "^1.1.2"
-    es-abstract "^1.7.0"
-
 arrify@^1.0.0, arrify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
@@ -358,6 +355,10 @@ atob@~1.1.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773"
 
+atrament@^0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/atrament/-/atrament-0.2.3.tgz#6ccbc0daa6d3f25e5aeaeb31befeb78e86980348"
+
 autoprefixer@^6.3.1:
   version "6.7.7"
   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014"
@@ -399,7 +400,13 @@ axios@~0.16.2:
     follow-redirects "^1.2.3"
     is-buffer "^1.1.5"
 
-babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0:
+axobject-query@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0"
+  dependencies:
+    ast-types-flow "0.0.7"
+
+babel-code-frame@^6.11.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
   dependencies:
@@ -431,23 +438,16 @@ babel-core@^6.0.0, babel-core@^6.25.0, babel-core@^6.26.0:
     slash "^1.0.0"
     source-map "^0.5.6"
 
-babel-eslint@^7.2.3:
-  version "7.2.3"
-  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-7.2.3.tgz#b2fe2d80126470f5c19442dc757253a897710827"
-  dependencies:
-    babel-code-frame "^6.22.0"
-    babel-traverse "^6.23.1"
-    babel-types "^6.23.0"
-    babylon "^6.17.0"
-
-babel-eslint@^8.0.1:
-  version "8.0.3"
-  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-8.0.3.tgz#f29ecf02336be438195325cd47c468da81ee4e98"
+babel-eslint@^8.0.1, babel-eslint@^8.2.1:
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-8.2.1.tgz#136888f3c109edc65376c23ebf494f36a3e03951"
   dependencies:
-    "@babel/code-frame" "7.0.0-beta.31"
-    "@babel/traverse" "7.0.0-beta.31"
-    "@babel/types" "7.0.0-beta.31"
-    babylon "7.0.0-beta.31"
+    "@babel/code-frame" "7.0.0-beta.36"
+    "@babel/traverse" "7.0.0-beta.36"
+    "@babel/types" "7.0.0-beta.36"
+    babylon "7.0.0-beta.36"
+    eslint-scope "~3.7.1"
+    eslint-visitor-keys "^1.0.0"
 
 babel-generator@^6.18.0, babel-generator@^6.26.0:
   version "6.26.0"
@@ -1046,7 +1046,7 @@ babel-template@^6.16.0, babel-template@^6.24.1, babel-template@^6.26.0, babel-te
     babylon "^6.18.0"
     lodash "^4.17.4"
 
-babel-traverse@^6.18.0, babel-traverse@^6.23.1, babel-traverse@^6.24.1, babel-traverse@^6.26.0:
+babel-traverse@^6.18.0, babel-traverse@^6.24.1, babel-traverse@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
   dependencies:
@@ -1068,7 +1068,7 @@ babel-types@7.0.0-beta.3:
     lodash "^4.2.0"
     to-fast-properties "^2.0.0"
 
-babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.23.0, babel-types@^6.24.1, babel-types@^6.26.0:
+babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
   dependencies:
@@ -1077,11 +1077,11 @@ babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.23.0, babel-types@^6.24
     lodash "^4.17.4"
     to-fast-properties "^1.0.3"
 
-babylon@7.0.0-beta.31:
-  version "7.0.0-beta.31"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.31.tgz#7ec10f81e0e456fd0f855ad60fa30c2ac454283f"
+babylon@7.0.0-beta.36:
+  version "7.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.36.tgz#3a3683ba6a9a1e02b0aa507c8e63435e39305b9e"
 
-babylon@^6.17.0, babylon@^6.18.0:
+babylon@^6.18.0:
   version "6.18.0"
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
 
@@ -1389,7 +1389,7 @@ chain-function@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
 
-chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
+chalk@^1.1.1, chalk@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
   dependencies:
@@ -1407,6 +1407,10 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0:
     escape-string-regexp "^1.0.5"
     supports-color "^4.0.0"
 
+chardet@^0.4.0:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
+
 cheerio@^1.0.0-rc.2:
   version "1.0.0-rc.2"
   resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
@@ -1458,11 +1462,11 @@ classnames@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
 
-cli-cursor@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
+cli-cursor@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
   dependencies:
-    restore-cursor "^1.0.1"
+    restore-cursor "^2.0.0"
 
 cli-width@^2.0.0:
   version "2.2.0"
@@ -1598,7 +1602,7 @@ concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
 
-concat-stream@^1.5.2:
+concat-stream@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7"
   dependencies:
@@ -1950,7 +1954,7 @@ date-now@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
 
-debug@2.6.9, debug@^2.1.1, debug@^2.2.0, debug@^2.6.6, debug@^2.6.8:
+debug@2.6.9, debug@^2.2.0, debug@^2.6.6, debug@^2.6.8, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   dependencies:
@@ -2102,16 +2106,16 @@ dns-txt@^2.0.2:
   dependencies:
     buffer-indexof "^1.0.0"
 
-doctrine@1.5.0, doctrine@^1.2.2:
+doctrine@1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
   dependencies:
     esutils "^2.0.2"
     isarray "^1.0.0"
 
-doctrine@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.0.2.tgz#68f96ce8efc56cc42651f1faadb4f175273b0075"
+doctrine@^2.0.0, doctrine@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
   dependencies:
     esutils "^2.0.2"
 
@@ -2381,11 +2385,11 @@ escope@^3.6.0:
     estraverse "^4.1.1"
 
 eslint-import-resolver-node@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.1.tgz#4422574cde66a9a7b099938ee4d508a199e0e3cc"
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a"
   dependencies:
-    debug "^2.6.8"
-    resolve "^1.2.0"
+    debug "^2.6.9"
+    resolve "^1.5.0"
 
 eslint-module-utils@^2.1.1:
   version "2.1.1"
@@ -2409,68 +2413,81 @@ eslint-plugin-import@^2.8.0:
     minimatch "^3.0.3"
     read-pkg-up "^2.0.0"
 
-eslint-plugin-jsx-a11y@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-4.0.0.tgz#779bb0fe7b08da564a422624911de10061e048ee"
+eslint-plugin-jsx-a11y@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-5.1.1.tgz#5c96bb5186ca14e94db1095ff59b3e2bd94069b1"
   dependencies:
-    aria-query "^0.3.0"
+    aria-query "^0.7.0"
+    array-includes "^3.0.3"
     ast-types-flow "0.0.7"
+    axobject-query "^0.1.0"
     damerau-levenshtein "^1.0.0"
     emoji-regex "^6.1.0"
-    jsx-ast-utils "^1.0.0"
-    object-assign "^4.0.1"
+    jsx-ast-utils "^1.4.0"
 
-eslint-plugin-react@^6.10.3:
-  version "6.10.3"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-6.10.3.tgz#c5435beb06774e12c7db2f6abaddcbf900cd3f78"
+eslint-plugin-react@^7.5.1:
+  version "7.5.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.5.1.tgz#52e56e8d80c810de158859ef07b880d2f56ee30b"
   dependencies:
-    array.prototype.find "^2.0.1"
-    doctrine "^1.2.2"
+    doctrine "^2.0.0"
     has "^1.0.1"
-    jsx-ast-utils "^1.3.4"
-    object.assign "^4.0.4"
+    jsx-ast-utils "^2.0.0"
+    prop-types "^15.6.0"
 
-eslint@^3.19.0:
-  version "3.19.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.19.0.tgz#c8fc6201c7f40dd08941b87c085767386a679acc"
+eslint-scope@^3.7.1, eslint-scope@~3.7.1:
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
   dependencies:
-    babel-code-frame "^6.16.0"
-    chalk "^1.1.3"
-    concat-stream "^1.5.2"
-    debug "^2.1.1"
-    doctrine "^2.0.0"
-    escope "^3.6.0"
-    espree "^3.4.0"
+    esrecurse "^4.1.0"
+    estraverse "^4.1.1"
+
+eslint-visitor-keys@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
+
+eslint@^4.15.0:
+  version "4.15.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.15.0.tgz#89ab38c12713eec3d13afac14e4a89e75ef08145"
+  dependencies:
+    ajv "^5.3.0"
+    babel-code-frame "^6.22.0"
+    chalk "^2.1.0"
+    concat-stream "^1.6.0"
+    cross-spawn "^5.1.0"
+    debug "^3.1.0"
+    doctrine "^2.0.2"
+    eslint-scope "^3.7.1"
+    eslint-visitor-keys "^1.0.0"
+    espree "^3.5.2"
     esquery "^1.0.0"
-    estraverse "^4.2.0"
     esutils "^2.0.2"
     file-entry-cache "^2.0.0"
-    glob "^7.0.3"
-    globals "^9.14.0"
-    ignore "^3.2.0"
+    functional-red-black-tree "^1.0.1"
+    glob "^7.1.2"
+    globals "^11.0.1"
+    ignore "^3.3.3"
     imurmurhash "^0.1.4"
-    inquirer "^0.12.0"
-    is-my-json-valid "^2.10.0"
+    inquirer "^3.0.6"
     is-resolvable "^1.0.0"
-    js-yaml "^3.5.1"
-    json-stable-stringify "^1.0.0"
+    js-yaml "^3.9.1"
+    json-stable-stringify-without-jsonify "^1.0.1"
     levn "^0.3.0"
-    lodash "^4.0.0"
-    mkdirp "^0.5.0"
+    lodash "^4.17.4"
+    minimatch "^3.0.2"
+    mkdirp "^0.5.1"
     natural-compare "^1.4.0"
     optionator "^0.8.2"
-    path-is-inside "^1.0.1"
-    pluralize "^1.2.1"
-    progress "^1.1.8"
-    require-uncached "^1.0.2"
-    shelljs "^0.7.5"
-    strip-bom "^3.0.0"
+    path-is-inside "^1.0.2"
+    pluralize "^7.0.0"
+    progress "^2.0.0"
+    require-uncached "^1.0.3"
+    semver "^5.3.0"
+    strip-ansi "^4.0.0"
     strip-json-comments "~2.0.1"
-    table "^3.7.8"
+    table "^4.0.1"
     text-table "~0.2.0"
-    user-home "^2.0.0"
 
-espree@^3.4.0:
+espree@^3.5.2:
   version "3.5.2"
   resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.2.tgz#756ada8b979e9dcfcdb30aad8d1a9304a905e1ca"
   dependencies:
@@ -2560,10 +2577,6 @@ execa@^0.7.0:
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
-exit-hook@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
-
 expand-brackets@^0.1.4:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
@@ -2626,6 +2639,14 @@ extend@~3.0.0, extend@~3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
 
+external-editor@^2.0.4:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.1.0.tgz#3d026a21b7f95b5726387d4200ac160d372c3b48"
+  dependencies:
+    chardet "^0.4.0"
+    iconv-lite "^0.4.17"
+    tmp "^0.0.33"
+
 extglob@^0.3.1:
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
@@ -2695,12 +2716,11 @@ fbjs@^0.8.16, fbjs@^0.8.4, fbjs@^0.8.9:
     setimmediate "^1.0.5"
     ua-parser-js "^0.7.9"
 
-figures@^1.3.5:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
+figures@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
   dependencies:
     escape-string-regexp "^1.0.5"
-    object-assign "^4.1.0"
 
 file-entry-cache@^2.0.0:
   version "2.0.0"
@@ -2902,6 +2922,10 @@ function.prototype.name@^1.0.3:
     function-bind "^1.1.0"
     is-callable "^1.1.3"
 
+functional-red-black-tree@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
+
 gauge@~2.7.3:
   version "2.7.4"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@@ -2987,11 +3011,11 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-globals@^10.0.0:
-  version "10.4.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-10.4.0.tgz#5c477388b128a9e4c5c5d01c7a2aca68c68b2da7"
+globals@^11.0.1, globals@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-11.1.0.tgz#632644457f5f0e3ae711807183700ebf2e4633e4"
 
-globals@^9.14.0, globals@^9.18.0:
+globals@^9.18.0:
   version "9.18.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
 
@@ -3283,7 +3307,7 @@ https-browserify@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
 
-iconv-lite@0.4.19, iconv-lite@~0.4.13:
+iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@~0.4.13:
   version "0.4.19"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
 
@@ -3301,7 +3325,7 @@ ieee754@^1.1.4:
   version "1.1.8"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
 
-ignore@^3.2.0:
+ignore@^3.3.3:
   version "3.3.7"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021"
 
@@ -3357,22 +3381,23 @@ ini@~1.3.0:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
 
-inquirer@^0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e"
+inquirer@^3.0.6:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9"
   dependencies:
-    ansi-escapes "^1.1.0"
-    ansi-regex "^2.0.0"
-    chalk "^1.0.0"
-    cli-cursor "^1.0.1"
+    ansi-escapes "^3.0.0"
+    chalk "^2.0.0"
+    cli-cursor "^2.1.0"
     cli-width "^2.0.0"
-    figures "^1.3.5"
+    external-editor "^2.0.4"
+    figures "^2.0.0"
     lodash "^4.3.0"
-    readline2 "^1.0.1"
-    run-async "^0.1.0"
-    rx-lite "^3.1.2"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.0"
+    mute-stream "0.0.7"
+    run-async "^2.2.0"
+    rx-lite "^4.0.8"
+    rx-lite-aggregates "^4.0.8"
+    string-width "^2.1.0"
+    strip-ansi "^4.0.0"
     through "^2.3.6"
 
 internal-ip@1.2.0:
@@ -3523,7 +3548,7 @@ is-glob@^3.1.0:
   dependencies:
     is-extglob "^2.1.0"
 
-is-my-json-valid@^2.10.0, is-my-json-valid@^2.12.4:
+is-my-json-valid@^2.12.4:
   version "2.16.1"
   resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.16.1.tgz#5a846777e2c2620d1e69104e5d3a03b1f6088f11"
   dependencies:
@@ -3584,6 +3609,10 @@ is-primitive@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
 
+is-promise@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
+
 is-property@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
@@ -3595,10 +3624,8 @@ is-regex@^1.0.4:
     has "^1.0.1"
 
 is-resolvable@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62"
-  dependencies:
-    tryit "^1.0.1"
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.1.tgz#acca1cd36dbe44b974b924321555a70ba03b1cf4"
 
 is-stream@^1.0.1, is-stream@^1.1.0:
   version "1.1.0"
@@ -3971,7 +3998,7 @@ js-tokens@^3.0.0, js-tokens@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
 
-js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.7.0, js-yaml@^3.9.0:
+js-yaml@^3.4.3, js-yaml@^3.7.0, js-yaml@^3.9.0, js-yaml@^3.9.1:
   version "3.10.0"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc"
   dependencies:
@@ -4033,7 +4060,11 @@ json-schema@0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
 
-json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1:
+json-stable-stringify-without-jsonify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+
+json-stable-stringify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af"
   dependencies:
@@ -4074,10 +4105,16 @@ jsprim@^1.2.2:
     json-schema "0.2.3"
     verror "1.10.0"
 
-jsx-ast-utils@^1.0.0, jsx-ast-utils@^1.3.4:
+jsx-ast-utils@^1.4.0:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz#3867213e8dd79bf1e8f2300c0cfc1efb182c0df1"
 
+jsx-ast-utils@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz#e801b1b39985e20fffc87b40e3748080e2dcac7f"
+  dependencies:
+    array-includes "^3.0.3"
+
 keycode@^2.1.7:
   version "2.1.9"
   resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.1.9.tgz#964a23c54e4889405b4861a5c9f0480d45141dfa"
@@ -4532,9 +4569,9 @@ multicast-dns@^6.0.1:
     dns-packet "^1.0.1"
     thunky "^0.1.0"
 
-mute-stream@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"
+mute-stream@0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
 
 nan@^2.0.0, nan@^2.3.0, nan@^2.3.2:
   version "2.8.0"
@@ -4847,9 +4884,11 @@ once@^1.3.0, once@^1.3.3, once@^1.4.0:
   dependencies:
     wrappy "1"
 
-onetime@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
+onetime@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
+  dependencies:
+    mimic-fn "^1.0.0"
 
 opener@^1.4.3:
   version "1.4.3"
@@ -4907,7 +4946,7 @@ os-locale@^2.0.0:
     lcid "^1.0.0"
     mem "^1.1.0"
 
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1:
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
 
@@ -5029,7 +5068,7 @@ path-is-absolute@^1.0.0, path-is-absolute@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
 
-path-is-inside@^1.0.1:
+path-is-inside@^1.0.1, path-is-inside@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
 
@@ -5157,9 +5196,9 @@ pkg-dir@^2.0.0:
   dependencies:
     find-up "^2.1.0"
 
-pluralize@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45"
+pluralize@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
 
 portfinder@^1.0.9:
   version "1.0.13"
@@ -5687,9 +5726,9 @@ process@^0.11.10:
   version "0.11.10"
   resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
 
-progress@^1.1.8:
-  version "1.1.8"
-  resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be"
+progress@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
 
 promise-each@^2.2.0:
   version "2.2.0"
@@ -6112,20 +6151,6 @@ readdirp@^2.0.0:
     readable-stream "^2.0.2"
     set-immediate-shim "^1.0.1"
 
-readline2@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35"
-  dependencies:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    mute-stream "0.0.5"
-
-rechoir@^0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
-  dependencies:
-    resolve "^1.1.6"
-
 redent@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
@@ -6353,7 +6378,7 @@ require-package-name@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/require-package-name/-/require-package-name-2.0.1.tgz#c11e97276b65b8e2923f75dabf5fb2ef0c3841b9"
 
-require-uncached@^1.0.2:
+require-uncached@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3"
   dependencies:
@@ -6408,18 +6433,18 @@ resolve@1.1.7:
   version "1.1.7"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
 
-resolve@^1.1.6, resolve@^1.1.7, resolve@^1.2.0, resolve@^1.3.3:
+resolve@^1.1.7, resolve@^1.3.3, resolve@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.5.0.tgz#1f09acce796c9a762579f31b2c1cc4c3cddf9f36"
   dependencies:
     path-parse "^1.0.5"
 
-restore-cursor@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
+restore-cursor@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
   dependencies:
-    exit-hook "^1.0.0"
-    onetime "^1.0.0"
+    onetime "^2.0.0"
+    signal-exit "^3.0.2"
 
 ret@~0.1.10:
   version "0.1.15"
@@ -6466,15 +6491,21 @@ rst-selector-parser@^2.2.3:
     lodash.flattendeep "^4.4.0"
     nearley "^2.7.10"
 
-run-async@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389"
+run-async@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
   dependencies:
-    once "^1.3.0"
+    is-promise "^2.1.0"
 
-rx-lite@^3.1.2:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102"
+rx-lite-aggregates@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
+  dependencies:
+    rx-lite "*"
+
+rx-lite@*, rx-lite@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
 
 safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.1"
@@ -6648,14 +6679,6 @@ shebang-regex@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
 
-shelljs@^0.7.5:
-  version "0.7.8"
-  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.8.tgz#decbcf874b0d1e5fb72e14b164a9683048e9acb3"
-  dependencies:
-    glob "^7.0.0"
-    interpret "^1.0.0"
-    rechoir "^0.6.2"
-
 shellwords@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
@@ -6668,9 +6691,11 @@ slash@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
 
-slice-ansi@0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
+slice-ansi@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"
+  dependencies:
+    is-fullwidth-code-point "^2.0.0"
 
 sntp@1.x.x:
   version "1.0.9"
@@ -6862,7 +6887,7 @@ string-width@^1.0.1, string-width@^1.0.2:
     is-fullwidth-code-point "^1.0.0"
     strip-ansi "^3.0.0"
 
-string-width@^2.0.0:
+string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
   dependencies:
@@ -6972,16 +6997,16 @@ symbol-tree@^3.2.1:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"
 
-table@^3.7.8:
-  version "3.8.3"
-  resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"
+table@^4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/table/-/table-4.0.2.tgz#a33447375391e766ad34d3486e6e2aedc84d2e36"
   dependencies:
-    ajv "^4.7.0"
-    ajv-keywords "^1.0.0"
-    chalk "^1.1.1"
-    lodash "^4.0.0"
-    slice-ansi "0.0.4"
-    string-width "^2.0.0"
+    ajv "^5.2.3"
+    ajv-keywords "^2.1.0"
+    chalk "^2.1.0"
+    lodash "^4.17.4"
+    slice-ansi "1.0.0"
+    string-width "^2.1.1"
 
 tapable@^0.2.7:
   version "0.2.8"
@@ -7062,6 +7087,12 @@ tiny-queue@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/tiny-queue/-/tiny-queue-0.2.1.tgz#25a67f2c6e253b2ca941977b5ef7442ef97a6046"
 
+tmp@^0.0.33:
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+  dependencies:
+    os-tmpdir "~1.0.2"
+
 tmpl@1.0.x:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
@@ -7102,10 +7133,6 @@ trim-right@^1.0.1:
   dependencies:
     glob "^6.0.4"
 
-tryit@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb"
-
 tty-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@@ -7229,12 +7256,6 @@ url@^0.11.0:
     punycode "1.3.2"
     querystring "0.2.0"
 
-user-home@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f"
-  dependencies:
-    os-homedir "^1.0.0"
-
 util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"