about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.production.sample250
-rw-r--r--.github/dependabot.yml45
-rw-r--r--.github/workflows/build-image.yml24
-rw-r--r--.gitmodules0
-rw-r--r--.prettierignore13
-rw-r--r--.rubocop_todo.yml2
-rw-r--r--CODE_OF_CONDUCT.md2
-rw-r--r--CONTRIBUTING.md41
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock3
-rw-r--r--README.md115
-rw-r--r--Vagrantfile2
-rw-r--r--app/controllers/accounts_controller.rb2
-rw-r--r--app/controllers/activitypub/collections_controller.rb2
-rw-r--r--app/controllers/admin/base_controller.rb5
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb3
-rw-r--r--app/controllers/admin/settings/other_controller.rb9
-rw-r--r--app/controllers/api/v1/accounts/relationships_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb13
-rw-r--r--app/controllers/api/v1/statuses_controller.rb5
-rw-r--r--app/controllers/api/v1/timelines/direct_controller.rb65
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb7
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb2
-rw-r--r--app/controllers/api/v2/search_controller.rb2
-rw-r--r--app/controllers/application_controller.rb15
-rw-r--r--app/controllers/auth/challenges_controller.rb7
-rw-r--r--app/controllers/auth/confirmations_controller.rb50
-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/auth/setup_controller.rb5
-rw-r--r--app/controllers/authorize_interactions_controller.rb5
-rw-r--r--app/controllers/concerns/captcha_concern.rb59
-rw-r--r--app/controllers/concerns/theming_concern.rb89
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb2
-rw-r--r--app/controllers/concerns/web_app_controller_concern.rb7
-rw-r--r--app/controllers/disputes/base_controller.rb5
-rw-r--r--app/controllers/filters/statuses_controller.rb5
-rw-r--r--app/controllers/filters_controller.rb5
-rw-r--r--app/controllers/follower_accounts_controller.rb12
-rw-r--r--app/controllers/invites_controller.rb5
-rw-r--r--app/controllers/media_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/relationships_controller.rb5
-rw-r--r--app/controllers/settings/base_controller.rb5
-rw-r--r--app/controllers/settings/flavours_controller.rb26
-rw-r--r--app/controllers/settings/login_activities_controller.rb6
-rw-r--r--app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb4
-rw-r--r--app/controllers/shares_controller.rb5
-rw-r--r--app/controllers/statuses_cleanup_controller.rb5
-rw-r--r--app/controllers/statuses_controller.rb1
-rw-r--r--app/helpers/accounts_helper.rb16
-rw-r--r--app/helpers/admin/settings_helper.rb3
-rw-r--r--app/helpers/application_helper.rb3
-rw-r--r--app/helpers/context_helper.rb1
-rw-r--r--app/helpers/formatting_helper.rb2
-rw-r--r--app/javascript/core/admin.js227
-rw-r--r--app/javascript/core/auth.js3
-rw-r--r--app/javascript/core/common.js6
-rw-r--r--app/javascript/core/embed.js25
-rw-r--r--app/javascript/core/mailer.js (renamed from app/javascript/packs/mailer.js)0
-rw-r--r--app/javascript/core/public.js30
-rw-r--r--app/javascript/core/settings.js79
-rw-r--r--app/javascript/core/theme.yml19
-rw-r--r--app/javascript/core/two_factor_authentication.js (renamed from app/javascript/packs/two_factor_authentication.js)1
-rw-r--r--app/javascript/flavours/glitch/actions/account_notes.js69
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js884
-rw-r--r--app/javascript/flavours/glitch/actions/alerts.js63
-rw-r--r--app/javascript/flavours/glitch/actions/announcements.js180
-rw-r--r--app/javascript/flavours/glitch/actions/app.js6
-rw-r--r--app/javascript/flavours/glitch/actions/blocks.js99
-rw-r--r--app/javascript/flavours/glitch/actions/bookmarks.js90
-rw-r--r--app/javascript/flavours/glitch/actions/boosts.js29
-rw-r--r--app/javascript/flavours/glitch/actions/bundles.js25
-rw-r--r--app/javascript/flavours/glitch/actions/columns.js54
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js820
-rw-r--r--app/javascript/flavours/glitch/actions/conversations.js112
-rw-r--r--app/javascript/flavours/glitch/actions/custom_emojis.js40
-rw-r--r--app/javascript/flavours/glitch/actions/directory.js61
-rw-r--r--app/javascript/flavours/glitch/actions/domain_blocks.js166
-rw-r--r--app/javascript/flavours/glitch/actions/dropdown_menu.js10
-rw-r--r--app/javascript/flavours/glitch/actions/emojis.js14
-rw-r--r--app/javascript/flavours/glitch/actions/favourites.js93
-rw-r--r--app/javascript/flavours/glitch/actions/featured_tags.js34
-rw-r--r--app/javascript/flavours/glitch/actions/filters.js93
-rw-r--r--app/javascript/flavours/glitch/actions/height_cache.js17
-rw-r--r--app/javascript/flavours/glitch/actions/history.js37
-rw-r--r--app/javascript/flavours/glitch/actions/identity_proofs.js31
-rw-r--r--app/javascript/flavours/glitch/actions/importer/index.js101
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js111
-rw-r--r--app/javascript/flavours/glitch/actions/interactions.js394
-rw-r--r--app/javascript/flavours/glitch/actions/languages.js12
-rw-r--r--app/javascript/flavours/glitch/actions/lists.js372
-rw-r--r--app/javascript/flavours/glitch/actions/local_settings.js77
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js150
-rw-r--r--app/javascript/flavours/glitch/actions/modal.js18
-rw-r--r--app/javascript/flavours/glitch/actions/mutes.js116
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js396
-rw-r--r--app/javascript/flavours/glitch/actions/onboarding.js14
-rw-r--r--app/javascript/flavours/glitch/actions/picture_in_picture.js45
-rw-r--r--app/javascript/flavours/glitch/actions/pin_statuses.js42
-rw-r--r--app/javascript/flavours/glitch/actions/polls.js60
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/index.js17
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/registerer.js139
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/setter.js34
-rw-r--r--app/javascript/flavours/glitch/actions/reports.js38
-rw-r--r--app/javascript/flavours/glitch/actions/search.js132
-rw-r--r--app/javascript/flavours/glitch/actions/server.js118
-rw-r--r--app/javascript/flavours/glitch/actions/settings.js34
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js350
-rw-r--r--app/javascript/flavours/glitch/actions/store.js39
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js168
-rw-r--r--app/javascript/flavours/glitch/actions/suggestions.js64
-rw-r--r--app/javascript/flavours/glitch/actions/tags.js172
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js234
-rw-r--r--app/javascript/flavours/glitch/actions/trends.js139
-rw-r--r--app/javascript/flavours/glitch/api.js75
-rw-r--r--app/javascript/flavours/glitch/base_polyfills.js42
-rw-r--r--app/javascript/flavours/glitch/blurhash.js112
-rw-r--r--app/javascript/flavours/glitch/compare_id.js11
-rw-r--r--app/javascript/flavours/glitch/components/account.jsx187
-rw-r--r--app/javascript/flavours/glitch/components/admin/Counter.jsx117
-rw-r--r--app/javascript/flavours/glitch/components/admin/Dimension.jsx93
-rw-r--r--app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx160
-rw-r--r--app/javascript/flavours/glitch/components/admin/Retention.jsx151
-rw-r--r--app/javascript/flavours/glitch/components/admin/Trends.jsx73
-rw-r--r--app/javascript/flavours/glitch/components/animated_number.jsx76
-rw-r--r--app/javascript/flavours/glitch/components/attachment_list.jsx48
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.jsx42
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx42
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_input.jsx227
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.jsx235
-rw-r--r--app/javascript/flavours/glitch/components/avatar.jsx79
-rw-r--r--app/javascript/flavours/glitch/components/avatar_composite.jsx110
-rw-r--r--app/javascript/flavours/glitch/components/avatar_overlay.jsx37
-rw-r--r--app/javascript/flavours/glitch/components/blurhash.jsx65
-rw-r--r--app/javascript/flavours/glitch/components/button.jsx52
-rw-r--r--app/javascript/flavours/glitch/components/check.jsx9
-rw-r--r--app/javascript/flavours/glitch/components/column.jsx64
-rw-r--r--app/javascript/flavours/glitch/components/column_back_button.jsx60
-rw-r--r--app/javascript/flavours/glitch/components/column_back_button_slim.jsx37
-rw-r--r--app/javascript/flavours/glitch/components/column_header.jsx221
-rw-r--r--app/javascript/flavours/glitch/components/common_counter.jsx62
-rw-r--r--app/javascript/flavours/glitch/components/dismissable_banner.jsx52
-rw-r--r--app/javascript/flavours/glitch/components/display_name.jsx102
-rw-r--r--app/javascript/flavours/glitch/components/domain.jsx43
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.jsx335
-rw-r--r--app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js26
-rw-r--r--app/javascript/flavours/glitch/components/edited_timestamp/index.jsx70
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.jsx134
-rw-r--r--app/javascript/flavours/glitch/components/gifv.jsx76
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.jsx115
-rw-r--r--app/javascript/flavours/glitch/components/icon.jsx21
-rw-r--r--app/javascript/flavours/glitch/components/icon_button.jsx177
-rw-r--r--app/javascript/flavours/glitch/components/icon_with_badge.jsx22
-rw-r--r--app/javascript/flavours/glitch/components/image.jsx33
-rw-r--r--app/javascript/flavours/glitch/components/inline_account.jsx35
-rw-r--r--app/javascript/flavours/glitch/components/intersection_observer_article.jsx131
-rw-r--r--app/javascript/flavours/glitch/components/link.jsx97
-rw-r--r--app/javascript/flavours/glitch/components/load_gap.jsx35
-rw-r--r--app/javascript/flavours/glitch/components/load_more.jsx27
-rw-r--r--app/javascript/flavours/glitch/components/load_pending.jsx22
-rw-r--r--app/javascript/flavours/glitch/components/loading_indicator.jsx32
-rw-r--r--app/javascript/flavours/glitch/components/logo.jsx10
-rw-r--r--app/javascript/flavours/glitch/components/media_attachments.jsx123
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.jsx409
-rw-r--r--app/javascript/flavours/glitch/components/missing_indicator.jsx29
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.jsx161
-rw-r--r--app/javascript/flavours/glitch/components/navigation_portal.jsx36
-rw-r--r--app/javascript/flavours/glitch/components/not_signed_in_indicator.jsx12
-rw-r--r--app/javascript/flavours/glitch/components/notification_purge_buttons.jsx60
-rw-r--r--app/javascript/flavours/glitch/components/permalink.jsx51
-rw-r--r--app/javascript/flavours/glitch/components/picture_in_picture_placeholder.jsx70
-rw-r--r--app/javascript/flavours/glitch/components/poll.jsx237
-rw-r--r--app/javascript/flavours/glitch/components/radio_button.jsx35
-rw-r--r--app/javascript/flavours/glitch/components/regeneration_indicator.jsx18
-rw-r--r--app/javascript/flavours/glitch/components/relative_timestamp.jsx200
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.jsx355
-rw-r--r--app/javascript/flavours/glitch/components/server_banner.jsx93
-rw-r--r--app/javascript/flavours/glitch/components/setting_text.jsx34
-rw-r--r--app/javascript/flavours/glitch/components/short_number.jsx117
-rw-r--r--app/javascript/flavours/glitch/components/skeleton.jsx11
-rw-r--r--app/javascript/flavours/glitch/components/spoilers.jsx52
-rw-r--r--app/javascript/flavours/glitch/components/status.jsx833
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.jsx342
-rw-r--r--app/javascript/flavours/glitch/components/status_content.jsx470
-rw-r--r--app/javascript/flavours/glitch/components/status_header.jsx71
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.jsx146
-rw-r--r--app/javascript/flavours/glitch/components/status_list.jsx131
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.jsx144
-rw-r--r--app/javascript/flavours/glitch/components/status_visibility_icon.jsx52
-rw-r--r--app/javascript/flavours/glitch/components/timeline_hint.jsx18
-rw-r--r--app/javascript/flavours/glitch/containers/account_container.jsx72
-rw-r--r--app/javascript/flavours/glitch/containers/admin_component.jsx26
-rw-r--r--app/javascript/flavours/glitch/containers/compose_container.jsx41
-rw-r--r--app/javascript/flavours/glitch/containers/domain_container.jsx33
-rw-r--r--app/javascript/flavours/glitch/containers/dropdown_menu_container.js27
-rw-r--r--app/javascript/flavours/glitch/containers/intersection_observer_article_container.js17
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.jsx102
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.jsx121
-rw-r--r--app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js49
-rw-r--r--app/javascript/flavours/glitch/containers/poll_container.js25
-rw-r--r--app/javascript/flavours/glitch/containers/scroll_container.js18
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js277
-rw-r--r--app/javascript/flavours/glitch/extra_polyfills.js2
-rw-r--r--app/javascript/flavours/glitch/features/about/index.jsx220
-rw-r--r--app/javascript/flavours/glitch/features/account/components/account_note.jsx105
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.jsx86
-rw-r--r--app/javascript/flavours/glitch/features/account/components/featured_tags.jsx54
-rw-r--r--app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx37
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.jsx406
-rw-r--r--app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx34
-rw-r--r--app/javascript/flavours/glitch/features/account/containers/account_note_container.js34
-rw-r--r--app/javascript/flavours/glitch/features/account/containers/featured_tags_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/account/navigation.jsx53
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx149
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.jsx226
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.jsx158
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.jsx37
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/moved_note.jsx51
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx173
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.jsx210
-rw-r--r--app/javascript/flavours/glitch/features/audio/index.jsx578
-rw-r--r--app/javascript/flavours/glitch/features/audio/visualizer.js136
-rw-r--r--app/javascript/flavours/glitch/features/blocks/index.jsx79
-rw-r--r--app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx108
-rw-r--r--app/javascript/flavours/glitch/features/closed_registrations_modal/index.jsx76
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx42
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js28
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/index.jsx164
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/action_bar.jsx69
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.jsx25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.jsx392
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.jsx243
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx199
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx415
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.jsx137
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx328
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx46
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.jsx323
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.jsx171
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx89
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.jsx100
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx83
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.jsx169
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.jsx142
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx38
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx61
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.jsx67
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.jsx34
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx52
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/warning.jsx26
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js144
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js12
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js83
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js36
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js34
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/navigation_container.js30
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/options_container.js53
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js52
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js23
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js32
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx75
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js10
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx68
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.jsx116
-rw-r--r--app/javascript/flavours/glitch/features/compose/util/counter.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/util/url_regex.js30
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.jsx43
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx233
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx75
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js75
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/index.jsx156
-rw-r--r--app/javascript/flavours/glitch/features/directory/components/account_card.jsx247
-rw-r--r--app/javascript/flavours/glitch/features/directory/index.jsx178
-rw-r--r--app/javascript/flavours/glitch/features/domain_blocks/index.jsx83
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji.js144
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji_compressed.js122
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji_map.json1
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji_mart_data_light.js41
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji_mart_search_light.js185
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji_picker.js7
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji_unicode_mapping_light.js35
-rw-r--r--app/javascript/flavours/glitch/features/emoji/emoji_utils.js258
-rw-r--r--app/javascript/flavours/glitch/features/emoji/unicode_to_filename.js26
-rw-r--r--app/javascript/flavours/glitch/features/emoji/unicode_to_unified_name.js21
-rw-r--r--app/javascript/flavours/glitch/features/explore/components/story.jsx51
-rw-r--r--app/javascript/flavours/glitch/features/explore/index.jsx109
-rw-r--r--app/javascript/flavours/glitch/features/explore/links.jsx71
-rw-r--r--app/javascript/flavours/glitch/features/explore/results.jsx126
-rw-r--r--app/javascript/flavours/glitch/features/explore/statuses.jsx65
-rw-r--r--app/javascript/flavours/glitch/features/explore/suggestions.jsx57
-rw-r--r--app/javascript/flavours/glitch/features/explore/tags.jsx63
-rw-r--r--app/javascript/flavours/glitch/features/favourited_statuses/index.jsx108
-rw-r--r--app/javascript/flavours/glitch/features/favourites/index.jsx103
-rw-r--r--app/javascript/flavours/glitch/features/filters/added_to_filter.jsx103
-rw-r--r--app/javascript/flavours/glitch/features/filters/select_filter.jsx192
-rw-r--r--app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx85
-rw-r--r--app/javascript/flavours/glitch/features/follow_recommendations/index.jsx117
-rw-r--r--app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx50
-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.jsx92
-rw-r--r--app/javascript/flavours/glitch/features/followed_tags/index.jsx89
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.jsx175
-rw-r--r--app/javascript/flavours/glitch/features/following/index.jsx175
-rw-r--r--app/javascript/flavours/glitch/features/generic_not_found/index.jsx11
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx450
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/trends.jsx51
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js20
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js13
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.jsx204
-rw-r--r--app/javascript/flavours/glitch/features/getting_started_misc/index.jsx70
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx134
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js32
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx241
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/components/column_settings.jsx51
-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.jsx178
-rw-r--r--app/javascript/flavours/glitch/features/interaction_modal/index.jsx162
-rw-r--r--app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx149
-rw-r--r--app/javascript/flavours/glitch/features/list_adder/components/account.jsx43
-rw-r--r--app/javascript/flavours/glitch/features/list_adder/components/list.jsx69
-rw-r--r--app/javascript/flavours/glitch/features/list_adder/index.jsx73
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/account.jsx56
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.jsx70
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/search.jsx62
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/containers/account_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/containers/search_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/index.jsx79
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.jsx224
-rw-r--r--app/javascript/flavours/glitch/features/lists/components/new_list_form.jsx78
-rw-r--r--app/javascript/flavours/glitch/features/lists/index.jsx89
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/index.jsx65
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/navigation/index.jsx93
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/navigation/item/index.jsx74
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/deprecated_item/index.jsx83
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.jsx516
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/item/index.jsx119
-rw-r--r--app/javascript/flavours/glitch/features/mutes/index.jsx84
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/admin_report.jsx112
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/admin_signup.jsx101
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/clear_column_button.jsx18
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx203
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx111
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.jsx101
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx133
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/grant_permission_button.jsx19
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.jsx234
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.jsx48
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/overlay.jsx59
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.jsx41
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/report.jsx63
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/setting_toggle.jsx36
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js13
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js73
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/notification_container.js26
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.jsx382
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx217
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx47
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/index.jsx89
-rw-r--r--app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js21
-rw-r--r--app/javascript/flavours/glitch/features/pinned_accounts_editor/index.jsx78
-rw-r--r--app/javascript/flavours/glitch/features/pinned_statuses/index.jsx65
-rw-r--r--app/javascript/flavours/glitch/features/privacy_policy/index.jsx62
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/components/column_settings.jsx43
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js28
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/index.jsx168
-rw-r--r--app/javascript/flavours/glitch/features/reblogs/index.jsx104
-rw-r--r--app/javascript/flavours/glitch/features/report/category.jsx104
-rw-r--r--app/javascript/flavours/glitch/features/report/comment.jsx84
-rw-r--r--app/javascript/flavours/glitch/features/report/components/option.jsx60
-rw-r--r--app/javascript/flavours/glitch/features/report/components/status_check_box.jsx60
-rw-r--r--app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/report/rules.jsx65
-rw-r--r--app/javascript/flavours/glitch/features/report/statuses.jsx62
-rw-r--r--app/javascript/flavours/glitch/features/report/thanks.jsx85
-rw-r--r--app/javascript/flavours/glitch/features/standalone/compose/index.jsx20
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.jsx231
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.jsx282
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.jsx339
-rw-r--r--app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js161
-rw-r--r--app/javascript/flavours/glitch/features/status/index.jsx726
-rw-r--r--app/javascript/flavours/glitch/features/subscribed_languages_modal/index.jsx125
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx91
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/audio_modal.jsx62
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/block_modal.jsx103
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.jsx139
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle.jsx107
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx163
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.jsx53
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column.jsx75
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_header.jsx38
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_link.jsx55
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_loading.jsx32
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_subheading.jsx16
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.jsx183
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx103
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/compose_panel.jsx59
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/confirmation_modal.jsx89
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx87
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.jsx92
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx615
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/drawer_loading.jsx11
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx98
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/favourite_modal.jsx102
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx134
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx420
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.jsx51
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/header.jsx88
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/image_loader.jsx171
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/image_modal.jsx60
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.jsx102
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/list_panel.jsx55
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.jsx261
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_loading.jsx20
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.jsx144
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/mute_modal.jsx140
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx105
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.jsx321
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.jsx221
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/sign_in_banner.jsx40
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/upload_area.jsx52
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/video_modal.jsx75
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/zoomable_image.jsx454
-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.js17
-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.js27
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/notifications_container.js29
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js86
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.jsx684
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/async-components.js207
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/fullscreen.js46
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js21
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js57
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/optional_motion.js5
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx101
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/reduced_motion.jsx44
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js29
-rw-r--r--app/javascript/flavours/glitch/features/video/index.jsx676
-rw-r--r--app/javascript/flavours/glitch/images/elephant_ui_disappointed.svg1
-rw-r--r--app/javascript/flavours/glitch/images/elephant_ui_working.svg1
-rw-r--r--app/javascript/flavours/glitch/images/glitch-preview.jpgbin0 -> 197277 bytes
-rw-r--r--app/javascript/flavours/glitch/images/logo_warn_glitch.svg49
-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/initial_state.js153
-rw-r--r--app/javascript/flavours/glitch/is_mobile.js55
-rw-r--r--app/javascript/flavours/glitch/load_keyboard_extensions.js16
-rw-r--r--app/javascript/flavours/glitch/load_polyfills.js41
-rw-r--r--app/javascript/flavours/glitch/locales/af.json1
-rw-r--r--app/javascript/flavours/glitch/locales/an.json1
-rw-r--r--app/javascript/flavours/glitch/locales/ar.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ast.json6
-rw-r--r--app/javascript/flavours/glitch/locales/be.json1
-rw-r--r--app/javascript/flavours/glitch/locales/bg.json6
-rw-r--r--app/javascript/flavours/glitch/locales/bn.json6
-rw-r--r--app/javascript/flavours/glitch/locales/br.json6
-rw-r--r--app/javascript/flavours/glitch/locales/bs.json1
-rw-r--r--app/javascript/flavours/glitch/locales/ca.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ckb.json6
-rw-r--r--app/javascript/flavours/glitch/locales/co.json6
-rw-r--r--app/javascript/flavours/glitch/locales/cs.json152
-rw-r--r--app/javascript/flavours/glitch/locales/cy.json6
-rw-r--r--app/javascript/flavours/glitch/locales/da.json6
-rw-r--r--app/javascript/flavours/glitch/locales/de.json200
-rw-r--r--app/javascript/flavours/glitch/locales/defaultMessages.json1064
-rw-r--r--app/javascript/flavours/glitch/locales/el.json6
-rw-r--r--app/javascript/flavours/glitch/locales/en-GB.json1
-rw-r--r--app/javascript/flavours/glitch/locales/en.json200
-rw-r--r--app/javascript/flavours/glitch/locales/eo.json68
-rw-r--r--app/javascript/flavours/glitch/locales/es-AR.json200
-rw-r--r--app/javascript/flavours/glitch/locales/es-MX.json200
-rw-r--r--app/javascript/flavours/glitch/locales/es.json200
-rw-r--r--app/javascript/flavours/glitch/locales/et.json6
-rw-r--r--app/javascript/flavours/glitch/locales/eu.json6
-rw-r--r--app/javascript/flavours/glitch/locales/fa.json6
-rw-r--r--app/javascript/flavours/glitch/locales/fi.json6
-rw-r--r--app/javascript/flavours/glitch/locales/fo.json1
-rw-r--r--app/javascript/flavours/glitch/locales/fr-QC.json199
-rw-r--r--app/javascript/flavours/glitch/locales/fr.json199
-rw-r--r--app/javascript/flavours/glitch/locales/fy.json1
-rw-r--r--app/javascript/flavours/glitch/locales/ga.json6
-rw-r--r--app/javascript/flavours/glitch/locales/gd.json6
-rw-r--r--app/javascript/flavours/glitch/locales/gl.json6
-rw-r--r--app/javascript/flavours/glitch/locales/he.json6
-rw-r--r--app/javascript/flavours/glitch/locales/hi.json18
-rw-r--r--app/javascript/flavours/glitch/locales/hr.json6
-rw-r--r--app/javascript/flavours/glitch/locales/hu.json6
-rw-r--r--app/javascript/flavours/glitch/locales/hy.json6
-rw-r--r--app/javascript/flavours/glitch/locales/id.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ig.json1
-rw-r--r--app/javascript/flavours/glitch/locales/io.json6
-rw-r--r--app/javascript/flavours/glitch/locales/is.json6
-rw-r--r--app/javascript/flavours/glitch/locales/it.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ja.json124
-rw-r--r--app/javascript/flavours/glitch/locales/ka.json6
-rw-r--r--app/javascript/flavours/glitch/locales/kab.json6
-rw-r--r--app/javascript/flavours/glitch/locales/kk.json6
-rw-r--r--app/javascript/flavours/glitch/locales/kn.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ko.json200
-rw-r--r--app/javascript/flavours/glitch/locales/ku.json6
-rw-r--r--app/javascript/flavours/glitch/locales/kw.json6
-rw-r--r--app/javascript/flavours/glitch/locales/la.json1
-rw-r--r--app/javascript/flavours/glitch/locales/lt.json6
-rw-r--r--app/javascript/flavours/glitch/locales/lv.json6
-rw-r--r--app/javascript/flavours/glitch/locales/mk.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ml.json6
-rw-r--r--app/javascript/flavours/glitch/locales/mr.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ms.json6
-rw-r--r--app/javascript/flavours/glitch/locales/my.json1
-rw-r--r--app/javascript/flavours/glitch/locales/nl.json6
-rw-r--r--app/javascript/flavours/glitch/locales/nn.json6
-rw-r--r--app/javascript/flavours/glitch/locales/no.json6
-rw-r--r--app/javascript/flavours/glitch/locales/oc.json6
-rw-r--r--app/javascript/flavours/glitch/locales/pa.json6
-rw-r--r--app/javascript/flavours/glitch/locales/pl.json197
-rw-r--r--app/javascript/flavours/glitch/locales/pt-BR.json200
-rw-r--r--app/javascript/flavours/glitch/locales/pt-PT.json25
-rw-r--r--app/javascript/flavours/glitch/locales/ro.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ru.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sa.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sc.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sco.json1
-rw-r--r--app/javascript/flavours/glitch/locales/si.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sk.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sl.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sq.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sr-Latn.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sr.json6
-rw-r--r--app/javascript/flavours/glitch/locales/sv.json6
-rw-r--r--app/javascript/flavours/glitch/locales/szl.json201
-rw-r--r--app/javascript/flavours/glitch/locales/ta.json6
-rw-r--r--app/javascript/flavours/glitch/locales/tai.json201
-rw-r--r--app/javascript/flavours/glitch/locales/te.json6
-rw-r--r--app/javascript/flavours/glitch/locales/th.json6
-rw-r--r--app/javascript/flavours/glitch/locales/tr.json6
-rw-r--r--app/javascript/flavours/glitch/locales/tt.json6
-rw-r--r--app/javascript/flavours/glitch/locales/ug.json6
-rw-r--r--app/javascript/flavours/glitch/locales/uk.json48
-rw-r--r--app/javascript/flavours/glitch/locales/ur.json6
-rw-r--r--app/javascript/flavours/glitch/locales/vi.json6
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_af.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ar.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ast.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_bg.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_bn.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_br.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ca.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ckb.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_co.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_cs.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_cy.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_da.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_de.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_el.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_en.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_eo.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_es-AR.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_es-MX.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_es.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_et.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_eu.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_fa.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_fi.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_fr.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ga.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_gd.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_gl.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_he.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_hi.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_hr.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_hu.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_hy.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_id.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_io.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_is.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_it.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ja.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ka.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_kab.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_kk.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_kn.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ko.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ku.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_kw.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_lt.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_lv.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_mk.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ml.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_mr.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ms.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_nl.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_nn.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_no.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_oc.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_pa.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_pl.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_pt-BR.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_pt-PT.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ro.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ru.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sa.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sc.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_si.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sk.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sl.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sq.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sr-Latn.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sr.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_sv.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_szl.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ta.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_tai.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_te.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_th.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_tr.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_tt.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ug.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_uk.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_ur.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_vi.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_zgh.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_zh-CN.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_zh-HK.json2
-rw-r--r--app/javascript/flavours/glitch/locales/whitelist_zh-TW.json2
-rw-r--r--app/javascript/flavours/glitch/locales/zgh.json201
-rw-r--r--app/javascript/flavours/glitch/locales/zh-CN.json200
-rw-r--r--app/javascript/flavours/glitch/locales/zh-HK.json6
-rw-r--r--app/javascript/flavours/glitch/locales/zh-TW.json6
-rw-r--r--app/javascript/flavours/glitch/main.jsx46
-rw-r--r--app/javascript/flavours/glitch/middleware/errors.js17
-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.yml40
-rw-r--r--app/javascript/flavours/glitch/packs/admin.jsx24
-rw-r--r--app/javascript/flavours/glitch/packs/common.js9
-rw-r--r--app/javascript/flavours/glitch/packs/error.js14
-rw-r--r--app/javascript/flavours/glitch/packs/home.js10
-rw-r--r--app/javascript/flavours/glitch/packs/public.jsx224
-rw-r--r--app/javascript/flavours/glitch/packs/settings.js43
-rw-r--r--app/javascript/flavours/glitch/packs/share.jsx23
-rw-r--r--app/javascript/flavours/glitch/performance.js32
-rw-r--r--app/javascript/flavours/glitch/permissions.js4
-rw-r--r--app/javascript/flavours/glitch/ready.js32
-rw-r--r--app/javascript/flavours/glitch/reducers/account_notes.js44
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts.js38
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts_counters.js38
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts_map.js20
-rw-r--r--app/javascript/flavours/glitch/reducers/alerts.js26
-rw-r--r--app/javascript/flavours/glitch/reducers/announcements.js102
-rw-r--r--app/javascript/flavours/glitch/reducers/blocks.js22
-rw-r--r--app/javascript/flavours/glitch/reducers/boosts.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js654
-rw-r--r--app/javascript/flavours/glitch/reducers/contexts.js105
-rw-r--r--app/javascript/flavours/glitch/reducers/conversations.js116
-rw-r--r--app/javascript/flavours/glitch/reducers/custom_emojis.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/domain_lists.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/dropdown_menu.js18
-rw-r--r--app/javascript/flavours/glitch/reducers/filters.js44
-rw-r--r--app/javascript/flavours/glitch/reducers/followed_tags.js42
-rw-r--r--app/javascript/flavours/glitch/reducers/height_cache.js23
-rw-r--r--app/javascript/flavours/glitch/reducers/history.js28
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js94
-rw-r--r--app/javascript/flavours/glitch/reducers/list_adder.js47
-rw-r--r--app/javascript/flavours/glitch/reducers/list_editor.js96
-rw-r--r--app/javascript/flavours/glitch/reducers/lists.js37
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js81
-rw-r--r--app/javascript/flavours/glitch/reducers/markers.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/media_attachments.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/meta.js24
-rw-r--r--app/javascript/flavours/glitch/reducers/modal.js39
-rw-r--r--app/javascript/flavours/glitch/reducers/mutes.js31
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js374
-rw-r--r--app/javascript/flavours/glitch/reducers/picture_in_picture.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js57
-rw-r--r--app/javascript/flavours/glitch/reducers/polls.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/push_notifications.js53
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js85
-rw-r--r--app/javascript/flavours/glitch/reducers/search.js67
-rw-r--r--app/javascript/flavours/glitch/reducers/server.js62
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js179
-rw-r--r--app/javascript/flavours/glitch/reducers/status_lists.js148
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js97
-rw-r--r--app/javascript/flavours/glitch/reducers/suggestions.js37
-rw-r--r--app/javascript/flavours/glitch/reducers/tags.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js232
-rw-r--r--app/javascript/flavours/glitch/reducers/trends.js46
-rw-r--r--app/javascript/flavours/glitch/reducers/user_lists.js190
-rw-r--r--app/javascript/flavours/glitch/scroll.js32
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js139
-rw-r--r--app/javascript/flavours/glitch/settings.js48
-rw-r--r--app/javascript/flavours/glitch/store/configureStore.js15
-rw-r--r--app/javascript/flavours/glitch/stream.js265
-rw-r--r--app/javascript/flavours/glitch/styles/_mixins.scss98
-rw-r--r--app/javascript/flavours/glitch/styles/about.scss56
-rw-r--r--app/javascript/flavours/glitch/styles/accessibility.scss53
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss393
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss1870
-rw-r--r--app/javascript/flavours/glitch/styles/basics.scss283
-rw-r--r--app/javascript/flavours/glitch/styles/branding.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/components/about.scss291
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss805
-rw-r--r--app/javascript/flavours/glitch/styles/components/announcements.scss233
-rw-r--r--app/javascript/flavours/glitch/styles/components/boost.scss44
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss996
-rw-r--r--app/javascript/flavours/glitch/styles/components/compose_form.scss683
-rw-r--r--app/javascript/flavours/glitch/styles/components/directory.scss68
-rw-r--r--app/javascript/flavours/glitch/styles/components/domains.scss23
-rw-r--r--app/javascript/flavours/glitch/styles/components/doodle.scss90
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss284
-rw-r--r--app/javascript/flavours/glitch/styles/components/emoji.scss101
-rw-r--r--app/javascript/flavours/glitch/styles/components/emoji_picker.scss261
-rw-r--r--app/javascript/flavours/glitch/styles/components/error_boundary.scss30
-rw-r--r--app/javascript/flavours/glitch/styles/components/explore.scss115
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss26
-rw-r--r--app/javascript/flavours/glitch/styles/components/lists.scss94
-rw-r--r--app/javascript/flavours/glitch/styles/components/local_settings.scss167
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss799
-rw-r--r--app/javascript/flavours/glitch/styles/components/misc.scss1814
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss1422
-rw-r--r--app/javascript/flavours/glitch/styles/components/privacy_policy.scss209
-rw-r--r--app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss43
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss245
-rw-r--r--app/javascript/flavours/glitch/styles/components/sensitive.scss26
-rw-r--r--app/javascript/flavours/glitch/styles/components/signed_out.scss110
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss333
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss1114
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss109
-rw-r--r--app/javascript/flavours/glitch/styles/contrast.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/contrast/diff.scss78
-rw-r--r--app/javascript/flavours/glitch/styles/contrast/variables.scss22
-rw-r--r--app/javascript/flavours/glitch/styles/dashboard.scss123
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss1119
-rw-r--r--app/javascript/flavours/glitch/styles/index.scss24
-rw-r--r--app/javascript/flavours/glitch/styles/lists.scss19
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss777
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/variables.scss44
-rw-r--r--app/javascript/flavours/glitch/styles/modal.scss37
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss298
-rw-r--r--app/javascript/flavours/glitch/styles/reset.scss95
-rw-r--r--app/javascript/flavours/glitch/styles/rich_text.scss99
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss371
-rw-r--r--app/javascript/flavours/glitch/styles/statuses.scss278
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss371
-rw-r--r--app/javascript/flavours/glitch/styles/variables.scss69
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss402
-rw-r--r--app/javascript/flavours/glitch/theme.yml48
-rw-r--r--app/javascript/flavours/glitch/utils/backend_links.js18
-rw-r--r--app/javascript/flavours/glitch/utils/base64.js10
-rw-r--r--app/javascript/flavours/glitch/utils/config.js10
-rw-r--r--app/javascript/flavours/glitch/utils/content_warning.js31
-rw-r--r--app/javascript/flavours/glitch/utils/dom_helpers.js14
-rw-r--r--app/javascript/flavours/glitch/utils/filters.js16
-rw-r--r--app/javascript/flavours/glitch/utils/hashtag.js8
-rw-r--r--app/javascript/flavours/glitch/utils/html.js5
-rw-r--r--app/javascript/flavours/glitch/utils/icons.jsx15
-rw-r--r--app/javascript/flavours/glitch/utils/idna.js10
-rw-r--r--app/javascript/flavours/glitch/utils/js_helpers.js5
-rw-r--r--app/javascript/flavours/glitch/utils/log_out.js34
-rw-r--r--app/javascript/flavours/glitch/utils/notifications.js30
-rw-r--r--app/javascript/flavours/glitch/utils/numbers.js79
-rw-r--r--app/javascript/flavours/glitch/utils/privacy_preference.js5
-rw-r--r--app/javascript/flavours/glitch/utils/react_helpers.js21
-rw-r--r--app/javascript/flavours/glitch/utils/resize_image.js189
-rw-r--r--app/javascript/flavours/glitch/utils/scrollbar.js34
-rw-r--r--app/javascript/flavours/glitch/uuid.js3
-rw-r--r--app/javascript/flavours/vanilla/names.yml41
-rw-r--r--app/javascript/flavours/vanilla/theme.yml43
-rw-r--r--app/javascript/fonts/premillenium/MSSansSerif.ttfbin0 -> 626300 bytes
-rw-r--r--app/javascript/images/alert_badge.pngbin0 -> 622 bytes
-rw-r--r--app/javascript/images/clippy_frame.pngbin0 -> 378 bytes
-rw-r--r--app/javascript/images/clippy_wave.gifbin0 -> 8897 bytes
-rw-r--r--app/javascript/images/icon_about.pngbin0 -> 497 bytes
-rw-r--r--app/javascript/images/icon_blocks.pngbin0 -> 356 bytes
-rw-r--r--app/javascript/images/icon_bookmarks.pngbin0 -> 418 bytes
-rw-r--r--app/javascript/images/icon_developers.pngbin0 -> 488 bytes
-rw-r--r--app/javascript/images/icon_direct.pngbin0 -> 390 bytes
-rw-r--r--app/javascript/images/icon_docs.pngbin0 -> 452 bytes
-rw-r--r--app/javascript/images/icon_domain_blocks.pngbin0 -> 589 bytes
-rw-r--r--app/javascript/images/icon_follow_requests.pngbin0 -> 561 bytes
-rw-r--r--app/javascript/images/icon_home.pngbin0 -> 328 bytes
-rw-r--r--app/javascript/images/icon_invite.pngbin0 -> 457 bytes
-rw-r--r--app/javascript/images/icon_keyboard_shortcuts.pngbin0 -> 384 bytes
-rw-r--r--app/javascript/images/icon_likes.pngbin0 -> 326 bytes
-rw-r--r--app/javascript/images/icon_lists.pngbin0 -> 437 bytes
-rw-r--r--app/javascript/images/icon_local.pngbin0 -> 599 bytes
-rw-r--r--app/javascript/images/icon_logout.pngbin0 -> 383 bytes
-rw-r--r--app/javascript/images/icon_mobile_apps.pngbin0 -> 650 bytes
-rw-r--r--app/javascript/images/icon_mutes.pngbin0 -> 411 bytes
-rw-r--r--app/javascript/images/icon_notifications.pngbin0 -> 282 bytes
-rw-r--r--app/javascript/images/icon_pin.pngbin0 -> 337 bytes
-rw-r--r--app/javascript/images/icon_profile_directory.pngbin0 -> 340 bytes
-rw-r--r--app/javascript/images/icon_public.pngbin0 -> 688 bytes
-rw-r--r--app/javascript/images/icon_settings.pngbin0 -> 639 bytes
-rw-r--r--app/javascript/images/icon_tos.pngbin0 -> 498 bytes
-rw-r--r--app/javascript/images/screenshot.jpgbin0 -> 239221 bytes
-rw-r--r--app/javascript/images/start.pngbin0 -> 263 bytes
-rw-r--r--app/javascript/locales/index.js9
-rw-r--r--app/javascript/mastodon/actions/compose.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.jsx5
-rw-r--r--app/javascript/mastodon/features/compose/components/poll_form.jsx4
-rw-r--r--app/javascript/mastodon/initial_state.js3
-rw-r--r--app/javascript/mastodon/locales/en.json5
-rw-r--r--app/javascript/mastodon/locales/index.js10
-rw-r--r--app/javascript/mastodon/locales/ja.json4
-rw-r--r--app/javascript/mastodon/locales/pl.json5
-rw-r--r--app/javascript/packs/admin.jsx221
-rw-r--r--app/javascript/packs/common.js2
-rw-r--r--app/javascript/packs/public.jsx103
-rw-r--r--app/javascript/skins/glitch/contrast/common.scss1
-rw-r--r--app/javascript/skins/glitch/contrast/names.yml12
-rw-r--r--app/javascript/skins/glitch/mastodon-light/common.scss1
-rw-r--r--app/javascript/skins/glitch/mastodon-light/names.yml12
-rw-r--r--app/javascript/skins/vanilla/contrast/common.scss1
-rw-r--r--app/javascript/skins/vanilla/contrast/names.yml12
-rw-r--r--app/javascript/skins/vanilla/mastodon-light/common.scss1
-rw-r--r--app/javascript/skins/vanilla/mastodon-light/names.yml12
-rw-r--r--app/javascript/skins/vanilla/win95/common.scss1
-rw-r--r--app/javascript/skins/vanilla/win95/names.yml8
-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/mastodon/admin.scss16
-rw-r--r--app/javascript/styles/mastodon/components.scss4
-rw-r--r--app/javascript/styles/win95.scss2684
-rw-r--r--app/lib/activitypub/activity/create.rb4
-rw-r--r--app/lib/activitypub/parser/status_parser.rb6
-rw-r--r--app/lib/advanced_text_formatter.rb134
-rw-r--r--app/lib/feed_manager.rb60
-rw-r--r--app/lib/html_aware_formatter.rb8
-rw-r--r--app/lib/settings/scoped_settings.rb3
-rw-r--r--app/lib/themes.rb75
-rw-r--r--app/lib/vacuum/feeds_vacuum.rb7
-rw-r--r--app/models/account.rb16
-rw-r--r--app/models/account_statuses_filter.rb2
-rw-r--r--app/models/concerns/has_user_settings.rb36
-rw-r--r--app/models/concerns/status_snapshot_concern.rb1
-rw-r--r--app/models/custom_emoji.rb7
-rw-r--r--app/models/direct_feed.rb32
-rw-r--r--app/models/form/admin_settings.rb29
-rw-r--r--app/models/media_attachment.rb4
-rw-r--r--app/models/public_feed.rb14
-rw-r--r--app/models/status.rb57
-rw-r--r--app/models/status_edit.rb1
-rw-r--r--app/models/tag_feed.rb1
-rw-r--r--app/models/trends.rb9
-rw-r--r--app/models/trends/statuses.rb2
-rw-r--r--app/models/user.rb2
-rw-r--r--app/models/user_settings.rb9
-rw-r--r--app/policies/status_policy.rb5
-rw-r--r--app/serializers/activitypub/note_serializer.rb18
-rw-r--r--app/serializers/initial_state_serializer.rb17
-rw-r--r--app/serializers/rest/account_serializer.rb4
-rw-r--r--app/serializers/rest/instance_serializer.rb1
-rw-r--r--app/serializers/rest/mute_serializer.rb15
-rw-r--r--app/serializers/rest/status_serializer.rb4
-rw-r--r--app/serializers/rest/status_source_serializer.rb2
-rw-r--r--app/serializers/rest/v1/instance_serializer.rb16
-rw-r--r--app/services/backup_service.rb5
-rw-r--r--app/services/batched_remove_status_service.rb11
-rw-r--r--app/services/fan_out_on_write_service.rb12
-rw-r--r--app/services/post_status_service.rb19
-rw-r--r--app/services/precompute_feed_service.rb1
-rw-r--r--app/services/reblog_service.rb2
-rw-r--r--app/services/remove_status_service.rb8
-rw-r--r--app/services/update_status_service.rb4
-rw-r--r--app/validators/poll_validator.rb4
-rw-r--r--app/validators/status_length_validator.rb2
-rw-r--r--app/validators/status_pin_validator.rb4
-rw-r--r--app/views/admin/accounts/index.html.haml3
-rw-r--r--app/views/admin/action_logs/index.html.haml3
-rw-r--r--app/views/admin/announcements/edit.html.haml3
-rw-r--r--app/views/admin/announcements/new.html.haml3
-rw-r--r--app/views/admin/custom_emojis/index.html.haml3
-rw-r--r--app/views/admin/custom_emojis/new.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml3
-rw-r--r--app/views/admin/disputes/appeals/index.html.haml3
-rw-r--r--app/views/admin/domain_allows/new.html.haml3
-rw-r--r--app/views/admin/domain_blocks/edit.html.haml3
-rw-r--r--app/views/admin/domain_blocks/new.html.haml3
-rw-r--r--app/views/admin/email_domain_blocks/index.html.haml3
-rw-r--r--app/views/admin/email_domain_blocks/new.html.haml3
-rw-r--r--app/views/admin/export_domain_blocks/import.html.haml3
-rw-r--r--app/views/admin/follow_recommendations/show.html.haml3
-rw-r--r--app/views/admin/instances/index.html.haml3
-rw-r--r--app/views/admin/instances/show.html.haml3
-rw-r--r--app/views/admin/ip_blocks/index.html.haml3
-rw-r--r--app/views/admin/reports/show.html.haml4
-rw-r--r--app/views/admin/settings/about/show.html.haml3
-rw-r--r--app/views/admin/settings/appearance/show.html.haml5
-rw-r--r--app/views/admin/settings/branding/show.html.haml3
-rw-r--r--app/views/admin/settings/content_retention/show.html.haml3
-rw-r--r--app/views/admin/settings/discovery/show.html.haml6
-rw-r--r--app/views/admin/settings/other/show.html.haml26
-rw-r--r--app/views/admin/settings/registrations/show.html.haml7
-rw-r--r--app/views/admin/settings/shared/_links.html.haml1
-rw-r--r--app/views/admin/statuses/index.html.haml3
-rw-r--r--app/views/admin/statuses/show.html.haml3
-rw-r--r--app/views/admin/tags/show.html.haml3
-rw-r--r--app/views/admin/trends/links/index.html.haml3
-rw-r--r--app/views/admin/trends/links/preview_card_providers/index.html.haml3
-rw-r--r--app/views/admin/trends/statuses/index.html.haml3
-rw-r--r--app/views/admin/trends/tags/index.html.haml3
-rw-r--r--app/views/auth/confirmations/captcha.html.haml14
-rw-r--r--app/views/auth/sessions/two_factor.html.haml2
-rw-r--r--app/views/filters/statuses/index.html.haml3
-rw-r--r--app/views/layouts/_theme.html.haml13
-rw-r--r--app/views/layouts/admin.html.haml1
-rwxr-xr-xapp/views/layouts/application.html.haml17
-rw-r--r--app/views/layouts/auth.html.haml3
-rw-r--r--app/views/layouts/embedded.html.haml14
-rw-r--r--app/views/layouts/error.html.haml7
-rw-r--r--app/views/layouts/mailer.html.haml2
-rw-r--r--app/views/layouts/modal.html.haml3
-rw-r--r--app/views/media/player.html.haml9
-rw-r--r--app/views/relationships/show.html.haml3
-rw-r--r--app/views/settings/flavours/show.html.haml20
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml9
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml2
-rw-r--r--app/views/settings/preferences/other/show.html.haml7
-rw-r--r--app/views/settings/profiles/show.html.haml6
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml2
-rw-r--r--app/views/shared/_web_app.html.haml1
-rw-r--r--app/views/shares/show.html.haml1
-rw-r--r--app/views/statuses/_simple_status.html.haml2
-rw-r--r--app/workers/activitypub/distribute_poll_update_worker.rb2
-rw-r--r--app/workers/feed_insert_worker.rb8
-rw-r--r--config/environments/production.rb1
-rw-r--r--config/i18n-tasks.yml16
-rw-r--r--config/initializers/0_duplicate_migrations.rb56
-rw-r--r--config/initializers/content_security_policy.rb64
-rw-r--r--config/initializers/cors.rb4
-rw-r--r--config/initializers/locale.rb9
-rw-r--r--config/initializers/simple_form.rb11
-rw-r--r--config/locales-glitch/af.yml1
-rw-r--r--config/locales-glitch/an.yml1
-rw-r--r--config/locales-glitch/ar.yml1
-rw-r--r--config/locales-glitch/ast.yml1
-rw-r--r--config/locales-glitch/be.yml1
-rw-r--r--config/locales-glitch/bg.yml1
-rw-r--r--config/locales-glitch/bn.yml1
-rw-r--r--config/locales-glitch/br.yml1
-rw-r--r--config/locales-glitch/bs.yml1
-rw-r--r--config/locales-glitch/ca.yml1
-rw-r--r--config/locales-glitch/ckb.yml1
-rw-r--r--config/locales-glitch/co.yml1
-rw-r--r--config/locales-glitch/cs.yml38
-rw-r--r--config/locales-glitch/cy.yml1
-rw-r--r--config/locales-glitch/da.yml1
-rw-r--r--config/locales-glitch/de.yml42
-rw-r--r--config/locales-glitch/el.yml1
-rw-r--r--config/locales-glitch/en-GB.yml1
-rw-r--r--config/locales-glitch/en.yml42
-rw-r--r--config/locales-glitch/eo.yml1
-rw-r--r--config/locales-glitch/es-AR.yml42
-rw-r--r--config/locales-glitch/es-MX.yml42
-rw-r--r--config/locales-glitch/es.yml42
-rw-r--r--config/locales-glitch/et.yml1
-rw-r--r--config/locales-glitch/eu.yml1
-rw-r--r--config/locales-glitch/fa.yml1
-rw-r--r--config/locales-glitch/fi.yml1
-rw-r--r--config/locales-glitch/fo.yml1
-rw-r--r--config/locales-glitch/fr-QC.yml42
-rw-r--r--config/locales-glitch/fr.yml42
-rw-r--r--config/locales-glitch/fy.yml1
-rw-r--r--config/locales-glitch/ga.yml1
-rw-r--r--config/locales-glitch/gd.yml1
-rw-r--r--config/locales-glitch/gl.yml1
-rw-r--r--config/locales-glitch/he.yml1
-rw-r--r--config/locales-glitch/hi.yml1
-rw-r--r--config/locales-glitch/hr.yml1
-rw-r--r--config/locales-glitch/hu.yml1
-rw-r--r--config/locales-glitch/hy.yml1
-rw-r--r--config/locales-glitch/id.yml1
-rw-r--r--config/locales-glitch/ig.yml1
-rw-r--r--config/locales-glitch/io.yml1
-rw-r--r--config/locales-glitch/is.yml1
-rw-r--r--config/locales-glitch/it.yml1
-rw-r--r--config/locales-glitch/ja.yml20
-rw-r--r--config/locales-glitch/ka.yml1
-rw-r--r--config/locales-glitch/kab.yml1
-rw-r--r--config/locales-glitch/kk.yml1
-rw-r--r--config/locales-glitch/kn.yml1
-rw-r--r--config/locales-glitch/ko.yml42
-rw-r--r--config/locales-glitch/ku.yml1
-rw-r--r--config/locales-glitch/kw.yml1
-rw-r--r--config/locales-glitch/la.yml1
-rw-r--r--config/locales-glitch/lt.yml1
-rw-r--r--config/locales-glitch/lv.yml1
-rw-r--r--config/locales-glitch/mk.yml1
-rw-r--r--config/locales-glitch/ml.yml1
-rw-r--r--config/locales-glitch/mr.yml1
-rw-r--r--config/locales-glitch/ms.yml1
-rw-r--r--config/locales-glitch/my.yml1
-rw-r--r--config/locales-glitch/nl.yml1
-rw-r--r--config/locales-glitch/nn.yml1
-rw-r--r--config/locales-glitch/no.yml2
-rw-r--r--config/locales-glitch/oc.yml1
-rw-r--r--config/locales-glitch/pa.yml1
-rw-r--r--config/locales-glitch/pl.yml42
-rw-r--r--config/locales-glitch/pt-BR.yml42
-rw-r--r--config/locales-glitch/pt-PT.yml42
-rw-r--r--config/locales-glitch/ro.yml1
-rw-r--r--config/locales-glitch/ru.yml1
-rw-r--r--config/locales-glitch/sa.yml1
-rw-r--r--config/locales-glitch/sc.yml1
-rw-r--r--config/locales-glitch/sco.yml1
-rw-r--r--config/locales-glitch/si.yml1
-rw-r--r--config/locales-glitch/simple_form.af.yml1
-rw-r--r--config/locales-glitch/simple_form.an.yml1
-rw-r--r--config/locales-glitch/simple_form.ar.yml1
-rw-r--r--config/locales-glitch/simple_form.ast.yml1
-rw-r--r--config/locales-glitch/simple_form.be.yml1
-rw-r--r--config/locales-glitch/simple_form.bg.yml1
-rw-r--r--config/locales-glitch/simple_form.bn.yml1
-rw-r--r--config/locales-glitch/simple_form.br.yml1
-rw-r--r--config/locales-glitch/simple_form.bs.yml1
-rw-r--r--config/locales-glitch/simple_form.ca.yml1
-rw-r--r--config/locales-glitch/simple_form.ckb.yml1
-rw-r--r--config/locales-glitch/simple_form.co.yml1
-rw-r--r--config/locales-glitch/simple_form.cs.yml24
-rw-r--r--config/locales-glitch/simple_form.cy.yml1
-rw-r--r--config/locales-glitch/simple_form.da.yml1
-rw-r--r--config/locales-glitch/simple_form.de.yml27
-rw-r--r--config/locales-glitch/simple_form.el.yml1
-rw-r--r--config/locales-glitch/simple_form.en-GB.yml1
-rw-r--r--config/locales-glitch/simple_form.en.yml27
-rw-r--r--config/locales-glitch/simple_form.eo.yml1
-rw-r--r--config/locales-glitch/simple_form.es-AR.yml27
-rw-r--r--config/locales-glitch/simple_form.es-MX.yml27
-rw-r--r--config/locales-glitch/simple_form.es.yml27
-rw-r--r--config/locales-glitch/simple_form.et.yml1
-rw-r--r--config/locales-glitch/simple_form.eu.yml1
-rw-r--r--config/locales-glitch/simple_form.fa.yml1
-rw-r--r--config/locales-glitch/simple_form.fi.yml1
-rw-r--r--config/locales-glitch/simple_form.fo.yml1
-rw-r--r--config/locales-glitch/simple_form.fr-QC.yml27
-rw-r--r--config/locales-glitch/simple_form.fr.yml27
-rw-r--r--config/locales-glitch/simple_form.fy.yml1
-rw-r--r--config/locales-glitch/simple_form.ga.yml1
-rw-r--r--config/locales-glitch/simple_form.gd.yml1
-rw-r--r--config/locales-glitch/simple_form.gl.yml1
-rw-r--r--config/locales-glitch/simple_form.he.yml1
-rw-r--r--config/locales-glitch/simple_form.hi.yml1
-rw-r--r--config/locales-glitch/simple_form.hr.yml1
-rw-r--r--config/locales-glitch/simple_form.hu.yml1
-rw-r--r--config/locales-glitch/simple_form.hy.yml1
-rw-r--r--config/locales-glitch/simple_form.id.yml1
-rw-r--r--config/locales-glitch/simple_form.ig.yml1
-rw-r--r--config/locales-glitch/simple_form.io.yml1
-rw-r--r--config/locales-glitch/simple_form.is.yml1
-rw-r--r--config/locales-glitch/simple_form.it.yml1
-rw-r--r--config/locales-glitch/simple_form.ja.yml19
-rw-r--r--config/locales-glitch/simple_form.ka.yml1
-rw-r--r--config/locales-glitch/simple_form.kab.yml1
-rw-r--r--config/locales-glitch/simple_form.kk.yml1
-rw-r--r--config/locales-glitch/simple_form.kn.yml1
-rw-r--r--config/locales-glitch/simple_form.ko.yml27
-rw-r--r--config/locales-glitch/simple_form.ku.yml1
-rw-r--r--config/locales-glitch/simple_form.kw.yml1
-rw-r--r--config/locales-glitch/simple_form.la.yml1
-rw-r--r--config/locales-glitch/simple_form.lt.yml1
-rw-r--r--config/locales-glitch/simple_form.lv.yml1
-rw-r--r--config/locales-glitch/simple_form.mk.yml1
-rw-r--r--config/locales-glitch/simple_form.ml.yml1
-rw-r--r--config/locales-glitch/simple_form.mr.yml1
-rw-r--r--config/locales-glitch/simple_form.ms.yml1
-rw-r--r--config/locales-glitch/simple_form.my.yml1
-rw-r--r--config/locales-glitch/simple_form.nl.yml1
-rw-r--r--config/locales-glitch/simple_form.nn.yml1
-rw-r--r--config/locales-glitch/simple_form.no.yml2
-rw-r--r--config/locales-glitch/simple_form.oc.yml1
-rw-r--r--config/locales-glitch/simple_form.pa.yml1
-rw-r--r--config/locales-glitch/simple_form.pl.yml27
-rw-r--r--config/locales-glitch/simple_form.pt-BR.yml27
-rw-r--r--config/locales-glitch/simple_form.pt-PT.yml1
-rw-r--r--config/locales-glitch/simple_form.ro.yml1
-rw-r--r--config/locales-glitch/simple_form.ru.yml1
-rw-r--r--config/locales-glitch/simple_form.sa.yml1
-rw-r--r--config/locales-glitch/simple_form.sc.yml1
-rw-r--r--config/locales-glitch/simple_form.sco.yml1
-rw-r--r--config/locales-glitch/simple_form.si.yml1
-rw-r--r--config/locales-glitch/simple_form.sk.yml1
-rw-r--r--config/locales-glitch/simple_form.sl.yml1
-rw-r--r--config/locales-glitch/simple_form.sq.yml1
-rw-r--r--config/locales-glitch/simple_form.sr-Latn.yml1
-rw-r--r--config/locales-glitch/simple_form.sr.yml1
-rw-r--r--config/locales-glitch/simple_form.sv.yml1
-rw-r--r--config/locales-glitch/simple_form.ta.yml1
-rw-r--r--config/locales-glitch/simple_form.te.yml1
-rw-r--r--config/locales-glitch/simple_form.th.yml1
-rw-r--r--config/locales-glitch/simple_form.tr.yml1
-rw-r--r--config/locales-glitch/simple_form.tt.yml1
-rw-r--r--config/locales-glitch/simple_form.ug.yml1
-rw-r--r--config/locales-glitch/simple_form.uk.yml1
-rw-r--r--config/locales-glitch/simple_form.ur.yml1
-rw-r--r--config/locales-glitch/simple_form.vi.yml1
-rw-r--r--config/locales-glitch/simple_form.zh-CN.yml27
-rw-r--r--config/locales-glitch/simple_form.zh-HK.yml1
-rw-r--r--config/locales-glitch/simple_form.zh-TW.yml1
-rw-r--r--config/locales-glitch/sk.yml1
-rw-r--r--config/locales-glitch/sl.yml1
-rw-r--r--config/locales-glitch/sq.yml1
-rw-r--r--config/locales-glitch/sr-Latn.yml1
-rw-r--r--config/locales-glitch/sr.yml1
-rw-r--r--config/locales-glitch/sv.yml1
-rw-r--r--config/locales-glitch/ta.yml1
-rw-r--r--config/locales-glitch/te.yml1
-rw-r--r--config/locales-glitch/th.yml1
-rw-r--r--config/locales-glitch/tr.yml1
-rw-r--r--config/locales-glitch/tt.yml1
-rw-r--r--config/locales-glitch/ug.yml1
-rw-r--r--config/locales-glitch/uk.yml1
-rw-r--r--config/locales-glitch/ur.yml1
-rw-r--r--config/locales-glitch/vi.yml1
-rw-r--r--config/locales-glitch/zh-CN.yml42
-rw-r--r--config/locales-glitch/zh-HK.yml1
-rw-r--r--config/locales-glitch/zh-TW.yml1
-rw-r--r--config/navigation.rb6
-rw-r--r--config/routes.rb9
-rw-r--r--config/settings.yml14
-rw-r--r--config/themes.yml3
-rw-r--r--config/webpack/configuration.js54
-rw-r--r--config/webpack/generateLocalePacks.js102
-rw-r--r--config/webpack/rules/babel.js1
-rw-r--r--config/webpack/rules/css.js3
-rw-r--r--config/webpack/shared.js76
-rw-r--r--config/webpack/translationRunner.js27
-rw-r--r--config/webpacker.yml1
-rw-r--r--crowdin-glitch.yml8
-rw-r--r--db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb13
-rw-r--r--db/migrate/20171009222537_create_keyword_mutes.rb14
-rw-r--r--db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb9
-rw-r--r--db/migrate/20171210213213_add_local_only_flag_to_statuses.rb7
-rw-r--r--db/migrate/20180410220657_create_bookmarks.rb22
-rw-r--r--db/migrate/20180604000556_add_apply_to_mentions_flag_to_keyword_mutes.rb19
-rw-r--r--db/migrate/20180707193142_migrate_filters.rb58
-rw-r--r--db/migrate/20180831171112_create_bookmarks.rb3
-rw-r--r--db/migrate/20190512200918_add_content_type_to_statuses.rb7
-rw-r--r--db/migrate/20220209175231_add_content_type_to_status_edits.rb7
-rw-r--r--db/migrate/20230215074424_move_glitch_user_settings.rb57
-rw-r--r--db/post_migrate/20180813160548_post_migrate_filters.rb11
-rw-r--r--db/schema.rb5
-rw-r--r--jest.config.js1
-rw-r--r--lib/mastodon/version.rb4
-rw-r--r--lib/paperclip/transcoder.rb6
-rw-r--r--lib/sanitize_ext/sanitize_config.rb83
-rw-r--r--lib/tasks/assets.rake16
-rw-r--r--lib/tasks/glitchsoc.rake12
-rw-r--r--package.json3
-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/api/v1/accounts/credentials_controller_spec.rb6
-rw-r--r--spec/controllers/api/v1/timelines/direct_controller_spec.rb17
-rw-r--r--spec/controllers/api/v1/timelines/public_controller_spec.rb4
-rw-r--r--spec/controllers/application_controller_spec.rb35
-rw-r--r--spec/controllers/settings/flavours_controller_spec.rb39
-rw-r--r--spec/lib/activitypub/activity/create_spec.rb46
-rw-r--r--spec/lib/advanced_text_formatter_spec.rb300
-rw-r--r--spec/lib/feed_manager_spec.rb7
-rw-r--r--spec/lib/sanitize_config_spec.rb25
-rw-r--r--spec/models/concerns/account_interactions_spec.rb9
-rw-r--r--spec/models/follow_request_spec.rb7
-rw-r--r--spec/models/public_feed_spec.rb62
-rw-r--r--spec/models/status_spec.rb87
-rw-r--r--spec/models/tag_feed_spec.rb14
-rw-r--r--spec/policies/status_policy_spec.rb12
-rw-r--r--spec/presenters/instance_presenter_spec.rb4
-rw-r--r--spec/validators/status_length_validator_spec.rb23
-rw-r--r--streaming/index.js52
-rw-r--r--stylelint.config.js2
-rw-r--r--yarn.lock15
1194 files changed, 78597 insertions, 947 deletions
diff --git a/.env.production.sample b/.env.production.sample
index 0bf01bdc3..7bcce0f7e 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -16,11 +16,37 @@
 # ----------
 LOCAL_DOMAIN=example.com
 
+# Use this only if you need to run mastodon on a different domain than the one used for federation.
+# You can read more about this option on https://docs.joinmastodon.org/admin/config/#web-domain
+# DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING.
+# WEB_DOMAIN=mastodon.example.com
+
+# Use this if you want to have several aliases handler@example1.com
+# handler@example2.com etc. for the same user. LOCAL_DOMAIN should not
+# be added. Comma separated values
+# ALTERNATE_DOMAINS=example1.com,example2.com
+
+# Use HTTP proxy for outgoing request (optional)
+# http_proxy=http://gateway.local:8118
+# Access control for hidden service.
+# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true
+
+# Authorized fetch mode (optional)
+# Require remote servers to authentify when fetching toots, see
+# https://docs.joinmastodon.org/admin/config/#authorized_fetch
+# AUTHORIZED_FETCH=true
+
+# Limited federation mode (optional)
+# Only allow federation with specific domains, see
+# https://docs.joinmastodon.org/admin/config/#whitelist_mode
+# LIMITED_FEDERATION_MODE=true
+
 # Redis
 # -----
 REDIS_HOST=localhost
 REDIS_PORT=6379
 
+
 # PostgreSQL
 # ----------
 DB_HOST=/var/run/postgresql
@@ -29,29 +55,52 @@ DB_NAME=mastodon_production
 DB_PASS=
 DB_PORT=5432
 
+
 # Elasticsearch (optional)
 # ------------------------
-ES_ENABLED=true
-ES_HOST=localhost
-ES_PORT=9200
+#ES_ENABLED=true
+#ES_HOST=localhost
+#ES_PORT=9200
 # Authentication for ES (optional)
-ES_USER=elastic
-ES_PASS=password
+#ES_USER=elastic
+#ES_PASS=password
+
 
 # Secrets
 # -------
-# Make sure to use `rake secret` to generate secrets
+# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose)
 # -------
 SECRET_KEY_BASE=
 OTP_SECRET=
 
+
 # Web Push
 # --------
-# Generate with `rake mastodon:webpush:generate_vapid_key`
+# Generate with `rake mastodon:webpush:generate_vapid_key` (first is the private key, second is the public one)
+# You should only generate this once per instance. If you later decide to change it, all push subscription will
+# be invalidated, requiring the users to access the website again to resubscribe.
 # --------
 VAPID_PRIVATE_KEY=
 VAPID_PUBLIC_KEY=
 
+
+# Registrations
+# -------------
+
+# Single user mode will disable registrations and redirect frontpage to the first profile
+# SINGLE_USER_MODE=true
+
+# Prevent registrations with following e-mail domains
+# EMAIL_DOMAIN_DENYLIST=example1.com|example2.de|etc
+
+# Only allow registrations with the following e-mail domains
+# EMAIL_DOMAIN_ALLOWLIST=example1.com|example2.de|etc
+
+#TODO move this
+# Optionally change default language
+# DEFAULT_LOCALE=de
+
+
 # Sending mail
 # ------------
 SMTP_SERVER=
@@ -60,13 +109,190 @@ SMTP_LOGIN=
 SMTP_PASSWORD=
 SMTP_FROM_ADDRESS=notifications@example.com
 
+
 # File storage (optional)
 # -----------------------
-S3_ENABLED=true
-S3_BUCKET=files.example.com
-AWS_ACCESS_KEY_ID=
-AWS_SECRET_ACCESS_KEY=
-S3_ALIAS_HOST=files.example.com
+# The attachment host must allow cross origin request from WEB_DOMAIN or
+# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the
+# following header field:
+# Access-Control-Allow-Origin: https://192.168.1.123:9000/
+# -----------------------
+#S3_ENABLED=true
+#S3_BUCKET=files.example.com
+#AWS_ACCESS_KEY_ID=
+#AWS_SECRET_ACCESS_KEY=
+#S3_ALIAS_HOST=files.example.com
+
+# Swift (optional)
+# The attachment host must allow cross origin request - see the description
+# above.
+# SWIFT_ENABLED=true
+# SWIFT_USERNAME=
+# For Keystone V3, the value for SWIFT_TENANT should be the project name
+# SWIFT_TENANT=
+# SWIFT_PASSWORD=
+# Some OpenStack V3 providers require PROJECT_ID (optional)
+# SWIFT_PROJECT_ID=
+# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid
+# issues with token rate-limiting during high load.
+# SWIFT_AUTH_URL=
+# SWIFT_CONTAINER=
+# SWIFT_OBJECT_URL=
+# SWIFT_REGION=
+# Defaults to 'default'
+# SWIFT_DOMAIN_NAME=
+# Defaults to 60 seconds. Set to 0 to disable
+# SWIFT_CACHE_TTL=
+
+# Optional asset host for multi-server setups
+# The asset host must allow cross origin request from WEB_DOMAIN or LOCAL_DOMAIN
+# if WEB_DOMAIN is not set. For example, the server may have the
+# following header field:
+# Access-Control-Allow-Origin: https://example.com/
+# CDN_HOST=https://assets.example.com
+
+# Optional list of hosts that are allowed to serve media for your instance
+# This is useful if you include external media in your custom CSS or about page,
+# or if your data storage provider makes use of redirects to other domains.
+# EXTRA_DATA_HOSTS=https://data.example1.com|https://data.example2.com
+
+# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare)
+# S3_ALIAS_HOST=
+
+# Streaming API integration
+# STREAMING_API_BASE_URL=
+
+
+# External authentication (optional)
+# ----------------------------------
+# LDAP authentication (optional)
+# LDAP_ENABLED=true
+# LDAP_HOST=localhost
+# LDAP_PORT=389
+# LDAP_METHOD=simple_tls
+# LDAP_BASE=
+# LDAP_BIND_DN=
+# LDAP_PASSWORD=
+# LDAP_UID=cn
+# LDAP_MAIL=mail
+# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email}))
+# LDAP_UID_CONVERSION_ENABLED=true
+# LDAP_UID_CONVERSION_SEARCH=., -
+# LDAP_UID_CONVERSION_REPLACE=_
+
+# PAM authentication (optional)
+# PAM authentication uses for the email generation the "email" pam variable
+# and optional as fallback PAM_DEFAULT_SUFFIX
+# The pam environment variable "email" is provided by:
+# https://github.com/devkral/pam_email_extractor
+# PAM_ENABLED=true
+# Fallback email domain for email address generation (LOCAL_DOMAIN by default)
+# PAM_EMAIL_DOMAIN=example.com
+# Name of the pam service (pam "auth" section is evaluated)
+# PAM_DEFAULT_SERVICE=rpam
+# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default)
+# PAM_CONTROLLED_SERVICE=rpam
+
+# Global OAuth settings (optional) :
+# If you have only one strategy, you may want to enable this
+# OAUTH_REDIRECT_AT_SIGN_IN=true
+
+# Optional CAS authentication (cf. omniauth-cas) :
+# CAS_ENABLED=true
+# CAS_URL=https://sso.myserver.com/
+# CAS_HOST=sso.myserver.com/
+# CAS_PORT=443
+# CAS_SSL=true
+# CAS_VALIDATE_URL=
+# CAS_CALLBACK_URL=
+# CAS_LOGOUT_URL=
+# CAS_LOGIN_URL=
+# CAS_UID_FIELD='user'
+# CAS_CA_PATH=
+# CAS_DISABLE_SSL_VERIFICATION=false
+# CAS_UID_KEY='user'
+# CAS_NAME_KEY='name'
+# CAS_EMAIL_KEY='email'
+# CAS_NICKNAME_KEY='nickname'
+# CAS_FIRST_NAME_KEY='firstname'
+# CAS_LAST_NAME_KEY='lastname'
+# CAS_LOCATION_KEY='location'
+# CAS_IMAGE_KEY='image'
+# CAS_PHONE_KEY='phone'
+
+# Optional SAML authentication (cf. omniauth-saml)
+# SAML_ENABLED=true
+# SAML_ACS_URL=http://localhost:3000/auth/auth/saml/callback
+# SAML_ISSUER=https://example.com
+# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO
+# SAML_IDP_CERT=
+# SAML_IDP_CERT_FINGERPRINT=
+# SAML_NAME_IDENTIFIER_FORMAT=
+# SAML_CERT=
+# SAML_PRIVATE_KEY=
+# SAML_SECURITY_WANT_ASSERTION_SIGNED=true
+# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true
+# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
+# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1"
+# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
+# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.16.840.1.113730.3.1.241"
+# SAML_ATTRIBUTES_STATEMENTS_FIRST_NAME="urn:oid:2.5.4.42"
+# SAML_ATTRIBUTES_STATEMENTS_LAST_NAME="urn:oid:2.5.4.4"
+# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
+# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
+# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=
+
+
+# Custom settings
+# ---------------
+# Various ways to customize Mastodon's behavior
+# ---------------
+
+# Maximum allowed character count
+MAX_TOOT_CHARS=500
+
+# Maximum number of pinned posts
+MAX_PINNED_TOOTS=5
+
+# Maximum allowed bio characters
+MAX_BIO_CHARS=500
+
+# Maximim number of profile fields allowed
+MAX_PROFILE_FIELDS=4
+
+# Maximum allowed display name characters
+MAX_DISPLAY_NAME_CHARS=30
+
+# Maximum allowed poll options
+MAX_POLL_OPTIONS=5
+
+# Maximum allowed poll option characters
+MAX_POLL_OPTION_CHARS=100
+
+# Maximum image and video/audio upload sizes
+# Units are in bytes
+# 1048576 bytes equals 1 megabyte
+# MAX_IMAGE_SIZE=8388608
+# MAX_VIDEO_SIZE=41943040
+
+# Maximum search results to display
+# Only relevant when elasticsearch is installed
+# MAX_SEARCH_RESULTS=20
+
+# Maximum hashtags to display
+# Customize the number of hashtags shown in 'Explore'
+# MAX_TRENDING_TAGS=10
+
+# Maximum custom emoji file sizes
+# If undefined or smaller than MAX_EMOJI_SIZE, the value
+# of MAX_EMOJI_SIZE will be used for MAX_REMOTE_EMOJI_SIZE
+# Units are in bytes
+# MAX_EMOJI_SIZE=262144
+# MAX_REMOTE_EMOJI_SIZE=262144
+
+# Optional hCaptcha support
+# HCAPTCHA_SECRET_KEY=
+# HCAPTCHA_SITE_KEY=
 
 # IP and session retention
 # -----------------------
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index 74d64620e..000000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-# To get started with Dependabot version updates, you'll need to specify which
-# package ecosystems to update and where the package manifests are located.
-# Please see the documentation for all configuration options:
-# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
-
-version: 2
-updates:
-  - package-ecosystem: npm
-    directory: '/'
-    schedule:
-      interval: weekly
-    open-pull-requests-limit: 99
-    allow:
-      - dependency-type: direct
-
-  - package-ecosystem: bundler
-    directory: '/'
-    schedule:
-      interval: weekly
-    open-pull-requests-limit: 99
-    allow:
-      - dependency-type: direct
-
-  - package-ecosystem: github-actions
-    directory: '/'
-    schedule:
-      interval: weekly
-    open-pull-requests-limit: 99
-    allow:
-      - dependency-type: direct
-
-  - package-ecosystem: docker
-    directory: '/'
-    schedule:
-      interval: weekly
-    open-pull-requests-limit: 99
-    ignore:
-      - dependency-name: 'moritzheiber/ruby-jemalloc'
-        update-types:
-          # only suggest patch releases for ruby and needs to sync with .ruby-version
-          - 'version-update:semver-minor'
-      - dependency-name: 'node'
-        update-types:
-          # only node minor releases allowed unless .nvmrc major is changed
-          - 'version-update:semver-major'
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index c0a4976b1..97a363d1e 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -4,8 +4,6 @@ on:
   push:
     branches:
       - 'main'
-    tags:
-      - '*'
   pull_request:
     paths:
       - .github/workflows/build-image.yml
@@ -28,34 +26,22 @@ jobs:
       - uses: docker/setup-qemu-action@v2
       - uses: docker/setup-buildx-action@v2
 
-      - name: Log in to Docker Hub
-        uses: docker/login-action@v2
-        with:
-          username: ${{ secrets.DOCKERHUB_USERNAME }}
-          password: ${{ secrets.DOCKERHUB_TOKEN }}
-        if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
-
       - name: Log in to the Github Container registry
         uses: docker/login-action@v2
         with:
           registry: ghcr.io
           username: ${{ github.actor }}
           password: ${{ secrets.GITHUB_TOKEN }}
-        if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
+        if: github.event_name != 'pull_request'
 
       - uses: docker/metadata-action@v4
         id: meta
         with:
-          images: |
-            tootsuite/mastodon
-            ghcr.io/mastodon/mastodon
-          flavor: |
-            latest=auto
+          images: ghcr.io/${{ github.repository_owner }}/mastodon
           tags: |
+            type=raw,value=latest,enable={{is_default_branch}}
             type=edge,branch=main
-            type=pep440,pattern={{raw}}
-            type=pep440,pattern=v{{major}}.{{minor}}
-            type=ref,event=pr
+            type=sha,prefix=,format=long
 
       - uses: docker/build-push-action@v4
         with:
@@ -63,7 +49,7 @@ jobs:
           platforms: linux/amd64,linux/arm64
           provenance: false
           builder: ${{ steps.buildx.outputs.name }}
-          push: ${{ github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request' }}
+          push: ${{ github.event_name != 'pull_request' }}
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}
           cache-from: type=gha
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/.gitmodules
diff --git a/.prettierignore b/.prettierignore
index 9bdf76911..36ba57bfb 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -78,3 +78,16 @@ app/javascript/styles/mastodon/reset.scss
 
 # Ignore the generated AUTHORS.md
 AUTHORS.md
+
+# Ignore glitch-soc emoji map file
+/app/javascript/flavours/glitch/features/emoji/emoji_map.json
+
+# Ignore glitch-soc locale files
+/app/javascript/flavours/glitch/locales
+/config/locales-glitch
+
+# Ignore glitch-soc vendored CSS reset
+app/javascript/flavours/glitch/styles/reset.scss
+
+# Ignore win95 theme
+app/javascript/styles/win95.scss
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 2e4801a55..dc7e21dc5 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -473,6 +473,7 @@ RSpec/ContextWording:
     - 'spec/lib/activitypub/activity/create_spec.rb'
     - 'spec/lib/activitypub/activity/follow_spec.rb'
     - 'spec/lib/activitypub/activity/reject_spec.rb'
+    - 'spec/lib/advanced_text_formatter_spec.rb'
     - 'spec/lib/emoji_formatter_spec.rb'
     - 'spec/lib/entity_cache_spec.rb'
     - 'spec/lib/feed_manager_spec.rb'
@@ -1321,6 +1322,7 @@ Rails/FilePath:
     - 'app/models/setting.rb'
     - 'app/validators/reaction_validator.rb'
     - 'config/environments/test.rb'
+    - 'config/initializers/locale.rb'
     - 'db/migrate/20170716191202_add_hide_notifications_to_mute.rb'
     - 'db/migrate/20171005171936_add_disabled_to_custom_emojis.rb'
     - 'db/migrate/20171028221157_add_reblogs_to_follows.rb'
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 97ed96772..2ee2e538b 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 glitch-abuse@sitedethib.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 c1a5fef79..a232915b6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,3 +1,42 @@
+# 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!)
+
+## Translations
+
+You can submit glitch-soc-specific translations via [Crowdin](https://crowdin.com/project/glitch-soc). They are periodically merged into the codebase.
+
+[![Crowdin](https://badges.crowdin.net/glitch-soc/localized.svg)](https://crowdin.com/project/glitch-soc)
+
+## 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 `mastodon/mastodon`, reproduced below.
+
+<blockquote>
+
 # Contributing
 
 Thank you for considering contributing to Mastodon 🐘
@@ -44,3 +83,5 @@ It is not always possible to phrase every change in such a manner, but it is des
 ## Documentation
 
 The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation).
+
+</blockquote>
diff --git a/Gemfile b/Gemfile
index 9d4f69dae..d175d7412 100644
--- a/Gemfile
+++ b/Gemfile
@@ -156,6 +156,8 @@ end
 gem 'concurrent-ruby', require: false
 gem 'connection_pool', require: false
 gem 'xorcist', '~> 1.1'
+
+gem 'hcaptcha', '~> 7.1'
 gem 'cocoon', '~> 1.2'
 
 gem 'net-http', '~> 0.3.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index 27d7a2207..5b05b79d6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -312,6 +312,8 @@ GEM
       sysexits (~> 1.1)
     hashdiff (1.0.1)
     hashie (5.0.0)
+    hcaptcha (7.1.0)
+      json
     highline (2.0.3)
     hiredis (0.6.3)
     hkdf (0.3.0)
@@ -805,6 +807,7 @@ DEPENDENCIES
   fuubar (~> 2.5)
   haml-rails (~> 2.0)
   haml_lint
+  hcaptcha (~> 7.1)
   hiredis (~> 0.6)
   htmlentities (~> 4.3)
   http (~> 5.1)
diff --git a/README.md b/README.md
index fd55c0073..f878752fe 100644
--- a/README.md
+++ b/README.md
@@ -1,111 +1,14 @@
-<h1><picture>
-  <source media="(prefers-color-scheme: dark)" srcset="./lib/assets/wordmark.dark.png?raw=true">
-  <source media="(prefers-color-scheme: light)" srcset="./lib/assets/wordmark.light.png?raw=true">
-  <img alt="Mastodon" src="./lib/assets/wordmark.light.png?raw=true" height="34">
-</picture></h1>
+# Mastodon Glitch Edition
 
-[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases]
-[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml)
-[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
+> Now with automated deploys!
 
-[releases]: https://github.com/mastodon/mastodon/releases
-[crowdin]: https://crowdin.com/project/mastodon
+[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci]
+[![Code Climate](https://img.shields.io/codeclimate/maintainability/glitch-soc/mastodon.svg)][code_climate]
 
-Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
+[circleci]: https://circleci.com/gh/glitch-soc/mastodon
+[code_climate]: https://codeclimate.com/github/glitch-soc/mastodon
 
-Click below to **learn more** in a video:
+So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. can you dig it?
 
-[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][youtube_demo]
-
-[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE
-
-## Navigation
-
-- [Project homepage 🐘](https://joinmastodon.org)
-- [Support the development via Patreon][patreon]
-- [View sponsors](https://joinmastodon.org/sponsors)
-- [Blog](https://blog.joinmastodon.org)
-- [Documentation](https://docs.joinmastodon.org)
-- [Roadmap](https://joinmastodon.org/roadmap)
-- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
-- [Browse Mastodon servers](https://joinmastodon.org/communities)
-- [Browse Mastodon apps](https://joinmastodon.org/apps)
-
-[patreon]: https://www.patreon.com/mastodon
-
-## Features
-
-<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
-
-### No vendor lock-in: Fully interoperable with any conforming platform
-
-It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
-
-### Real-time, chronological timeline updates
-
-Updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
-
-### 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 loop continuously!
-
-### Safety and moderation tools
-
-Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
-
-### OAuth2 and a straightforward REST API
-
-Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
-
-## Deployment
-
-### Tech stack:
-
-- **Ruby on Rails** powers the REST API and other web pages
-- **React.js** and Redux are used for the dynamic parts of the interface
-- **Node.js** powers the streaming API
-
-### Requirements:
-
-- **PostgreSQL** 9.5+
-- **Redis** 4+
-- **Ruby** 2.7+
-- **Node.js** 14+
-
-The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
-
-A **Vagrant** configuration is included for development purposes. To use it, complete following steps:
-
-- Install Vagrant and Virtualbox
-- Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater`
-- Run `vagrant up`
-- Run `vagrant ssh -c "cd /vagrant && foreman start"`
-- Open `http://mastodon.local` in your browser
-
-### Getting Started with GitHub Codespaces
-
-To get started, create a codespace for this repository by clicking this 👇
-
-[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=52281283)
-
-A codespace will open in a web-based version of Visual Studio Code. The [dev container](.devcontainer/devcontainer.json) is fully configured with software needed for this project.
-
-**Note**: Dev containers is an open spec which is supported by [GitHub Codespaces](https://github.com/codespaces) and [other tools](https://containers.dev/supporting).
-
-## Contributing
-
-Mastodon is **free, open-source software** licensed under **AGPLv3**.
-
-You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
-
-**IRC channel**: #mastodon on irc.libera.chat
-
-## License
-
-Copyright (C) 2016-2022 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md))
-
-This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
+- 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 880cc1849..043bab3e9 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -102,7 +102,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"]
     # Increase the number of CPUs. Uncomment and adjust to
     # increase performance
     # vb.customize ["modifyvm", :id, "--cpus", "3"]
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 56229fd05..4d03a04b7 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -47,7 +47,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/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
index d94a285ea..23d874071 100644
--- a/app/controllers/activitypub/collections_controller.rb
+++ b/app/controllers/activitypub/collections_controller.rb
@@ -20,7 +20,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
   def set_items
     case params[:id]
     when 'featured'
-      @items = for_signed_account { cache_collection(@account.pinned_statuses, Status) }
+      @items = for_signed_account { cache_collection(@account.pinned_statuses.not_local_only, Status) }
       @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
     when 'tags'
       @items = for_signed_account { @account.featured_tags }
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 5b7a7ec11..c645ce12b 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -7,6 +7,7 @@ module Admin
 
     layout 'admin'
 
+    before_action :set_pack
     before_action :set_body_classes
     after_action :verify_authorized
 
@@ -16,6 +17,10 @@ module Admin
       @body_classes = 'admin'
     end
 
+    def set_pack
+      use_pack 'admin'
+    end
+
     def set_user
       @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
     end
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index 00d069cdf..431dc1524 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -37,6 +37,9 @@ module Admin
       flash[:alert] = I18n.t('admin.custom_emojis.no_emoji_selected')
     rescue Mastodon::NotPermittedError
       flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+    rescue ActiveRecord::RecordInvalid => e
+      error_message = action_from_button == 'copy' ? 'admin.custom_emojis.batch_copy_error' : 'admin.custom_emojis.batch_error'
+      flash[:alert] = I18n.t(error_message, message: e.message)
     ensure
       redirect_to admin_custom_emojis_path(filter_params)
     end
diff --git a/app/controllers/admin/settings/other_controller.rb b/app/controllers/admin/settings/other_controller.rb
new file mode 100644
index 000000000..c7bfa3d58
--- /dev/null
+++ b/app/controllers/admin/settings/other_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Admin::Settings::OtherController < Admin::SettingsController
+  private
+
+  def after_update_redirect_path
+    admin_settings_other_path
+  end
+end
diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb
index 503f85c97..1d3992a28 100644
--- a/app/controllers/api/v1/accounts/relationships_controller.rb
+++ b/app/controllers/api/v1/accounts/relationships_controller.rb
@@ -5,7 +5,7 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
   before_action :require_user!
 
   def index
-    accounts = Account.without_suspended.where(id: account_ids).select('id')
+    accounts = Account.where(id: account_ids).select('id')
     # .where doesn't guarantee that our results are in the same order
     # we requested them, so return the "right" order to the requestor.
     @accounts = accounts.index_by(&:id).values_at(*account_ids).compact
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 8414f6b25..7a64d1300 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 class Api::V1::NotificationsController < Api::BaseController
-  before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss]
-  before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss]
+  before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss, :destroy, :destroy_multiple]
+  before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss, :destroy, :destroy_multiple]
   before_action :require_user!
   after_action :insert_pagination_headers, only: :index
 
@@ -23,11 +23,20 @@ class Api::V1::NotificationsController < Api::BaseController
     render_empty
   end
 
+  def destroy
+    dismiss
+  end
+
   def dismiss
     current_account.notifications.find(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/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index fadd1b045..8dcf6331e 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -63,6 +63,7 @@ class Api::V1::StatusesController < Api::BaseController
       scheduled_at: status_params[:scheduled_at],
       application: doorkeeper_token.application,
       poll: status_params[:poll],
+      content_type: status_params[:content_type],
       allowed_mentions: status_params[:allowed_mentions],
       idempotency: request.headers['Idempotency-Key'],
       with_rate_limit: true
@@ -90,7 +91,8 @@ class Api::V1::StatusesController < Api::BaseController
       sensitive: status_params[:sensitive],
       language: status_params[:language],
       spoiler_text: status_params[:spoiler_text],
-      poll: status_params[:poll]
+      poll: status_params[:poll],
+      content_type: status_params[:content_type]
     )
 
     render json: @status, serializer: REST::StatusSerializer
@@ -135,6 +137,7 @@ class Api::V1::StatusesController < Api::BaseController
       :visibility,
       :language,
       :scheduled_at,
+      :content_type,
       allowed_mentions: [],
       media_ids: [],
       media_attributes: [
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..6e98e9cac
--- /dev/null
+++ b/app/controllers/api/v1/timelines/direct_controller.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class Api::V1::Timelines::DirectController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, 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
+  end
+
+  def direct_timeline_statuses
+    account_direct_feed.get(
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id],
+      params[:min_id]
+    )
+  end
+
+  def account_direct_feed
+    DirectFeed.new(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/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index d253b744f..4675af921 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -37,7 +37,10 @@ class Api::V1::Timelines::PublicController < Api::BaseController
       current_account,
       local: truthy_param?(:local),
       remote: truthy_param?(:remote),
-      only_media: truthy_param?(:only_media)
+      only_media: truthy_param?(:only_media),
+      allow_local_only: truthy_param?(:allow_local_only),
+      with_replies: Setting.show_replies_in_public_timelines,
+      with_reblogs: Setting.show_reblogs_in_public_timelines
     )
   end
 
@@ -46,7 +49,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def pagination_params(core_params)
-    params.slice(:local, :remote, :limit, :only_media).permit(:local, :remote, :limit, :only_media).merge(core_params)
+    params.slice(:local, :remote, :limit, :only_media, :allow_local_only).permit(:local, :remote, :limit, :only_media, :allow_local_only).merge(core_params)
   end
 
   def next_path
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 75c3ed218..9dd9abdfe 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -5,7 +5,7 @@ class Api::V1::Trends::TagsController < Api::BaseController
 
   after_action :insert_pagination_headers
 
-  DEFAULT_TAGS_LIMIT = 10
+  DEFAULT_TAGS_LIMIT = (ENV['MAX_TRENDING_TAGS'] || 10).to_i
 
   def index
     render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id)
diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
index 4d20aeb10..b084eae42 100644
--- a/app/controllers/api/v2/search_controller.rb
+++ b/app/controllers/api/v2/search_controller.rb
@@ -3,7 +3,7 @@
 class Api::V2::SearchController < Api::BaseController
   include Authorization
 
-  RESULTS_LIMIT = 20
+  RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i
 
   before_action -> { authorize_if_got_token! :read, :'read:search' }
   before_action :validate_search_params!
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index fb01abb93..906761f6f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -10,10 +10,12 @@ class ApplicationController < ActionController::Base
   include SessionTrackingConcern
   include CacheConcern
   include DomainControlHelper
+  include ThemingConcern
 
   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?
   helper_method :use_seamless_external_login?
   helper_method :omniauth_only?
@@ -140,15 +142,12 @@ class ApplicationController < ActionController::Base
     @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
   end
 
-  def current_theme
-    return Setting.theme unless Themes.instance.names.include? current_user&.setting_theme
-
-    current_user.setting_theme
-  end
-
   def respond_with_error(code)
     respond_to do |format|
-      format.any  { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
+      format.any do
+        use_pack 'error'
+        render "errors/#{code}", layout: 'error', status: code, formats: [:html]
+      end
       format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code }
     end
   end
diff --git a/app/controllers/auth/challenges_controller.rb b/app/controllers/auth/challenges_controller.rb
index 060944240..41827b21c 100644
--- a/app/controllers/auth/challenges_controller.rb
+++ b/app/controllers/auth/challenges_controller.rb
@@ -5,6 +5,7 @@ class Auth::ChallengesController < ApplicationController
 
   layout 'auth'
 
+  before_action :set_pack
   before_action :authenticate_user!
 
   skip_before_action :require_functional!
@@ -19,4 +20,10 @@ class Auth::ChallengesController < ApplicationController
       render_challenge
     end
   end
+
+  private
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 010fd3755..620fb621d 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -1,21 +1,71 @@
 # frozen_string_literal: true
 
 class Auth::ConfirmationsController < Devise::ConfirmationsController
+  include CaptchaConcern
+
   layout 'auth'
 
   before_action :set_body_classes
+  before_action :set_pack
+  before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
   before_action :require_unconfirmed!
 
+  before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
+  before_action :require_captcha_if_needed!, only: [:show]
+
   skip_before_action :require_functional!
 
+  def show
+    old_session_values = session.to_hash
+    reset_session
+    session.update old_session_values.except('session_id')
+
+    super
+  end
+
   def new
     super
 
     resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
   end
 
+  def confirm_captcha
+    check_captcha! do |message|
+      flash.now[:alert] = message
+      render :captcha
+      return
+    end
+
+    show
+  end
+
   private
 
+  def require_captcha_if_needed!
+    render :captcha if captcha_required?
+  end
+
+  def set_confirmation_user!
+    # We need to reimplement looking up the user because
+    # Devise::ConfirmationsController#show looks up and confirms in one
+    # step.
+    confirmation_token = params[:confirmation_token]
+    return if confirmation_token.nil?
+
+    @confirmation_user = User.find_first_by_auth_conditions(confirmation_token: confirmation_token)
+  end
+
+  def captcha_user_bypass?
+    return true if @confirmation_user.nil? || @confirmation_user.confirmed?
+
+    invite = Invite.find(@confirmation_user.invite_id) if @confirmation_user.invite_id.present?
+    invite.present? && !invite.max_uses.nil?
+  end
+
+  def set_pack
+    use_pack 'auth'
+  end
+
   def require_unconfirmed!
     if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
       redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index a8ad66929..576c3e7bc 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
   before_action :set_body_classes
 
   layout 'auth'
@@ -32,4 +33,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 b55f7f309..d2f1bea93 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -8,6 +8,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   before_action :set_invite, only: [:new, :create]
   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_strikes, only: [:edit, :update]
   before_action :set_instance_presenter, only: [:new, :create, :update]
@@ -107,6 +108,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 4f59fd501..b1abb9f1d 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -7,6 +7,7 @@ class Auth::SessionsController < Devise::SessionsController
   skip_before_action :require_functional!
   skip_before_action :update_user_sign_in
 
+  prepend_before_action :set_pack
   prepend_before_action :check_suspicious!, only: [:create]
 
   include TwoFactorAuthenticationConcern
@@ -99,6 +100,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/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb
index 46c5f2958..db5a866f2 100644
--- a/app/controllers/auth/setup_controller.rb
+++ b/app/controllers/auth/setup_controller.rb
@@ -3,6 +3,7 @@
 class Auth::SetupController < ApplicationController
   layout 'auth'
 
+  before_action :set_pack
   before_action :authenticate_user!
   before_action :require_unconfirmed_or_pending!
   before_action :set_body_classes
@@ -55,4 +56,8 @@ class Auth::SetupController < ApplicationController
   def missing_email?
     truthy_param?(:missing_email)
   end
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb
index 02a6b6d06..97fe4a9ab 100644
--- a/app/controllers/authorize_interactions_controller.rb
+++ b/app/controllers/authorize_interactions_controller.rb
@@ -8,6 +8,7 @@ class AuthorizeInteractionsController < ApplicationController
   before_action :authenticate_user!
   before_action :set_body_classes
   before_action :set_resource
+  before_action :set_pack
 
   def show
     if @resource.is_a?(Account)
@@ -65,4 +66,8 @@ class AuthorizeInteractionsController < ApplicationController
   def set_body_classes
     @body_classes = 'modal-layout'
   end
+
+  def set_pack
+    use_pack 'modal'
+  end
 end
diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/captcha_concern.rb
new file mode 100644
index 000000000..538c1ffb1
--- /dev/null
+++ b/app/controllers/concerns/captcha_concern.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module CaptchaConcern
+  extend ActiveSupport::Concern
+  include Hcaptcha::Adapters::ViewMethods
+
+  included do
+    helper_method :render_captcha
+  end
+
+  def captcha_available?
+    ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
+  end
+
+  def captcha_enabled?
+    captcha_available? && Setting.captcha_enabled
+  end
+
+  def captcha_user_bypass?
+    false
+  end
+
+  def captcha_required?
+    captcha_enabled? && !captcha_user_bypass?
+  end
+
+  def check_captcha!
+    return true unless captcha_required?
+
+    if verify_hcaptcha
+      true
+    else
+      if block_given?
+        message = flash[:hcaptcha_error]
+        flash.delete(:hcaptcha_error)
+        yield message
+      end
+      false
+    end
+  end
+
+  def extend_csp_for_captcha!
+    policy = request.content_security_policy
+    return unless captcha_required? && policy.present?
+
+    %w(script_src frame_src style_src connect_src).each do |directive|
+      values = policy.send(directive)
+      values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
+      values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
+      policy.send(directive, *values)
+    end
+  end
+
+  def render_captcha
+    return unless captcha_required?
+
+    hcaptcha_tags
+  end
+end
diff --git a/app/controllers/concerns/theming_concern.rb b/app/controllers/concerns/theming_concern.rb
new file mode 100644
index 000000000..f993a81d7
--- /dev/null
+++ b/app/controllers/concerns/theming_concern.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module ThemingConcern
+  extend ActiveSupport::Concern
+
+  def use_pack(pack_name)
+    @core = resolve_pack_with_common(Themes.instance.core, pack_name)
+    @theme = resolve_pack_with_common(Themes.instance.flavour(current_flavour), pack_name, current_skin)
+  end
+
+  private
+
+  def current_flavour
+    [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) }
+  end
+
+  def current_skin
+    skins = Themes.instance.skins_for(current_flavour)
+    [current_user&.setting_skin, Setting.skin, 'default'].find { |skin| skins.include?(skin) }
+  end
+
+  def valid_pack_data?(data, pack_name)
+    data['pack'].is_a?(Hash) && data['pack'][pack_name].present?
+  end
+
+  def nil_pack(data)
+    {
+      use_common: true,
+      flavour: data['name'],
+      pack: nil,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+  end
+
+  def pack(data, pack_name, skin)
+    pack_data = {
+      use_common: true,
+      flavour: data['name'],
+      pack: pack_name,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+
+    return pack_data unless data['pack'][pack_name].is_a?(Hash)
+
+    pack_data[:use_common] = false if data['pack'][pack_name]['use_common'] == false
+    pack_data[:pack] = nil unless data['pack'][pack_name]['filename']
+
+    preloads = data['pack'][pack_name]['preload']
+    pack_data[:preload] = [preloads] if preloads.is_a?(String)
+    pack_data[:preload] = preloads if preloads.is_a?(Array)
+
+    if skin != 'default' && data['skin'][skin]
+      pack_data[:skin] = skin if data['skin'][skin].include?(pack_name)
+    elsif data['pack'][pack_name]['stylesheet']
+      pack_data[:skin] = 'default'
+    end
+
+    pack_data
+  end
+
+  def resolve_pack(data, pack_name, skin)
+    return pack(data, pack_name, skin) if valid_pack_data?(data, pack_name)
+    return if data['name'].blank?
+
+    fallbacks = []
+    if data.key?('fallback')
+      fallbacks = data['fallback'] if data['fallback'].is_a?(Array)
+      fallbacks = [data['fallback']] if data['fallback'].is_a?(String)
+    elsif data['name'] != Setting.default_settings['flavour']
+      fallbacks = [Setting.default_settings['flavour']]
+    end
+
+    fallbacks.each do |fallback|
+      return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback)
+    end
+
+    nil
+  end
+
+  def resolve_pack_with_common(data, pack_name, skin = 'default')
+    result = resolve_pack(data, pack_name, skin) || nil_pack(data)
+    result[:common] = resolve_pack(data, 'common', skin) if result.delete(:use_common)
+    result
+  end
+end
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index 94f3ce00f..b30cd354d 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -77,6 +77,8 @@ module TwoFactorAuthenticationConcern
   def prompt_for_two_factor(user)
     set_attempt_session(user)
 
+    use_pack 'auth'
+
     @body_classes     = 'lighter'
     @webauthn_enabled = user.webauthn_enabled?
     @scheme_type      = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb
index f28786f63..7ba7a57e3 100644
--- a/app/controllers/concerns/web_app_controller_concern.rb
+++ b/app/controllers/concerns/web_app_controller_concern.rb
@@ -5,6 +5,7 @@ module WebAppControllerConcern
 
   included do
     prepend_before_action :redirect_unauthenticated_to_permalinks!
+    before_action :set_pack
     before_action :set_app_body_class
   end
 
@@ -13,10 +14,14 @@ module WebAppControllerConcern
   end
 
   def redirect_unauthenticated_to_permalinks!
-    return if user_signed_in? && current_account.moved_to_account_id.nil?
+    return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
 
     redirect_path = PermalinkRedirector.new(request.path).redirect_path
 
     redirect_to(redirect_path) if redirect_path.present?
   end
+
+  def set_pack
+    use_pack 'home'
+  end
 end
diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb
index 865146b5c..7830c5524 100644
--- a/app/controllers/disputes/base_controller.rb
+++ b/app/controllers/disputes/base_controller.rb
@@ -9,9 +9,14 @@ class Disputes::BaseController < ApplicationController
 
   before_action :set_body_classes
   before_action :authenticate_user!
+  before_action :set_pack
 
   private
 
+  def set_pack
+    use_pack 'admin'
+  end
+
   def set_body_classes
     @body_classes = 'admin'
   end
diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb
index 7779c6d95..86d11fcb9 100644
--- a/app/controllers/filters/statuses_controller.rb
+++ b/app/controllers/filters/statuses_controller.rb
@@ -6,6 +6,7 @@ class Filters::StatusesController < ApplicationController
   before_action :authenticate_user!
   before_action :set_filter
   before_action :set_status_filters
+  before_action :set_pack
   before_action :set_body_classes
 
   PER_PAGE = 20
@@ -25,6 +26,10 @@ class Filters::StatusesController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'admin'
+  end
+
   def set_filter
     @filter = current_account.custom_filters.find(params[:filter_id])
   end
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
index cc5cb5d9f..2ab3b0a74 100644
--- a/app/controllers/filters_controller.rb
+++ b/app/controllers/filters_controller.rb
@@ -5,6 +5,7 @@ class FiltersController < ApplicationController
 
   before_action :authenticate_user!
   before_action :set_filter, only: [:edit, :update, :destroy]
+  before_action :set_pack
   before_action :set_body_classes
 
   def index
@@ -43,6 +44,10 @@ class FiltersController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def set_filter
     @filter = current_account.custom_filters.find(params[:id])
   end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 9ced18449..1f5ed30de 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -58,22 +58,22 @@ class FollowerAccountsController < ApplicationController
   end
 
   def collection_presenter
+    options = { type: :ordered }
+    options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count
     if page_requested?
       ActivityPub::CollectionPresenter.new(
         id: account_followers_url(@account, page: params.fetch(:page, 1)),
-        type: :ordered,
-        size: @account.followers_count,
         items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
         part_of: account_followers_url(@account),
         next: next_page_url,
-        prev: prev_page_url
+        prev: prev_page_url,
+        **options
       )
     else
       ActivityPub::CollectionPresenter.new(
         id: account_followers_url(@account),
-        type: :ordered,
-        size: @account.followers_count,
-        first: page_url(1)
+        first: page_url(1),
+        **options
       )
     end
   end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 8d92147e2..0b3c082dc 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
   before_action :set_body_classes
 
   def index
@@ -38,6 +39,10 @@ class InvitesController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def invites
     current_user.invites.order(id: :desc)
   end
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 133564ee7..37c5dcb99 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -11,6 +11,7 @@ class MediaController < ApplicationController
   before_action :verify_permitted_status!
   before_action :check_playable, only: :player
   before_action :allow_iframing, only: :player
+  before_action :set_pack, only: :player
 
   content_security_policy only: :player do |policy|
     policy.frame_ancestors(false)
@@ -48,4 +49,8 @@ class MediaController < ApplicationController
   def allow_iframing
     response.headers['X-Frame-Options'] = 'ALLOWALL'
   end
+
+  def set_pack
+    use_pack 'public'
+  end
 end
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index 5449cfb1a..d6e7d0800 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
   before_action :set_cache_headers
 
   content_security_policy do |p|
@@ -19,6 +20,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
     store_location_for(:user, request.url)
   end
 
+  def set_pack
+    use_pack 'auth'
+  end
+
   def render_success
     if skip_authorization? || (matching_token? && !truthy_param?('force_login'))
       redirect_or_render authorize_response
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index 45151cdd7..b2564a791 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
   before_action :require_not_suspended!, only: :destroy
   before_action :set_body_classes
 
@@ -27,6 +28,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
     store_location_for(:user, request.url)
   end
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def require_not_suspended!
     forbidden if current_account.suspended?
   end
diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb
index de5dc5879..52cf1e0c1 100644
--- a/app/controllers/relationships_controller.rb
+++ b/app/controllers/relationships_controller.rb
@@ -5,6 +5,7 @@ class RelationshipsController < ApplicationController
 
   before_action :authenticate_user!
   before_action :set_accounts, only: :show
+  before_action :set_pack
   before_action :set_relationships, only: :show
   before_action :set_body_classes
 
@@ -70,4 +71,8 @@ class RelationshipsController < ApplicationController
   def set_body_classes
     @body_classes = 'admin'
   end
+
+  def set_pack
+    use_pack 'admin'
+  end
 end
diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb
index 8722fd64a..bf17b918c 100644
--- a/app/controllers/settings/base_controller.rb
+++ b/app/controllers/settings/base_controller.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Settings::BaseController < ApplicationController
+  before_action :set_pack
   layout 'admin'
 
   before_action :authenticate_user!
@@ -9,6 +10,10 @@ class Settings::BaseController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def set_body_classes
     @body_classes = 'admin'
   end
diff --git a/app/controllers/settings/flavours_controller.rb b/app/controllers/settings/flavours_controller.rb
new file mode 100644
index 000000000..b179b9429
--- /dev/null
+++ b/app/controllers/settings/flavours_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Settings::FlavoursController < Settings::BaseController
+  layout 'admin'
+
+  before_action :authenticate_user!
+
+  skip_before_action :require_functional!
+
+  def index
+    redirect_to action: 'show', flavour: current_flavour
+  end
+
+  def show
+    redirect_to action: 'show', flavour: current_flavour unless Themes.instance.flavours.include?(params[:flavour]) || (params[:flavour] == current_flavour)
+
+    @listing = Themes.instance.flavours
+    @selected = params[:flavour]
+  end
+
+  def update
+    current_user.settings.update(flavour: params.require(:flavour), skin: params.dig(:user, :setting_skin))
+    current_user.save
+    redirect_to action: 'show', flavour: params[:flavour]
+  end
+end
diff --git a/app/controllers/settings/login_activities_controller.rb b/app/controllers/settings/login_activities_controller.rb
index 57fa6aef0..ee77524b1 100644
--- a/app/controllers/settings/login_activities_controller.rb
+++ b/app/controllers/settings/login_activities_controller.rb
@@ -4,4 +4,10 @@ class Settings::LoginActivitiesController < Settings::BaseController
   def index
     @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
   end
+
+  private
+
+  def set_pack
+    use_pack 'settings'
+  end
 end
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
index d1ee7dc19..5a9029a42 100644
--- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -85,6 +85,10 @@ module Settings
 
       private
 
+      def set_pack
+        use_pack 'auth'
+      end
+
       def require_otp_enabled
         unless current_user.otp_enabled?
           flash[:error] = t('webauthn_credentials.otp_required')
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
index 6546b8497..e13e7e8b6 100644
--- a/app/controllers/shares_controller.rb
+++ b/app/controllers/shares_controller.rb
@@ -4,12 +4,17 @@ class SharesController < ApplicationController
   layout 'modal'
 
   before_action :authenticate_user!
+  before_action :set_pack
   before_action :set_body_classes
 
   def show; end
 
   private
 
+  def set_pack
+    use_pack 'share'
+  end
+
   def set_body_classes
     @body_classes = 'modal-layout compose-standalone'
   end
diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb
index e912967fd..0e7bb835f 100644
--- a/app/controllers/statuses_cleanup_controller.rb
+++ b/app/controllers/statuses_cleanup_controller.rb
@@ -6,6 +6,7 @@ class StatusesCleanupController < ApplicationController
   before_action :authenticate_user!
   before_action :set_policy
   before_action :set_body_classes
+  before_action :set_pack
 
   def show; end
 
@@ -25,6 +26,10 @@ class StatusesCleanupController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def set_policy
     @policy = current_account.statuses_cleanup_policy || current_account.build_statuses_cleanup_policy(enabled: false)
   end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index d369cd8e6..15c081264 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -41,6 +41,7 @@ class StatusesController < ApplicationController
   end
 
   def embed
+    use_pack 'embed'
     return not_found if @status.hidden? || @status.reblog?
 
     expires_in 180, public: true
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index 6301919a9..b8277ee17 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -27,8 +27,12 @@ module AccountsHelper
     end
   end
 
+  def hide_followers_count?(account)
+    Setting.hide_followers_count || account.user&.settings&.[]('hide_followers_count')
+  end
+
   def account_description(account)
-    prepend_str = [
+    prepend_stats = [
       [
         number_to_human(account.statuses_count, precision: 3, strip_insignificant_zeros: true),
         I18n.t('accounts.posts', count: account.statuses_count),
@@ -38,13 +42,15 @@ module AccountsHelper
         number_to_human(account.following_count, precision: 3, strip_insignificant_zeros: true),
         I18n.t('accounts.following', count: account.following_count),
       ].join(' '),
+    ]
 
-      [
+    unless hide_followers_count?(account)
+      prepend_stats << [
         number_to_human(account.followers_count, precision: 3, strip_insignificant_zeros: true),
         I18n.t('accounts.followers', count: account.followers_count),
-      ].join(' '),
-    ].join(', ')
+      ].join(' ')
+    end
 
-    [prepend_str, account.note].join(' · ')
+    [prepend_stats.join(', '), account.note].join(' · ')
   end
 end
diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb
index a133b4e7d..552a3ee5a 100644
--- a/app/helpers/admin/settings_helper.rb
+++ b/app/helpers/admin/settings_helper.rb
@@ -1,4 +1,7 @@
 # frozen_string_literal: true
 
 module Admin::SettingsHelper
+  def captcha_available?
+    ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
+  end
 end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 9dc8bba2d..2cac2de59 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -165,7 +165,8 @@ module ApplicationHelper
 
   def body_classes
     output = (@body_classes || '').split
-    output << "theme-#{current_theme.parameterize}"
+    output << "flavour-#{current_flavour.parameterize}"
+    output << "skin-#{current_skin.parameterize}"
     output << 'system-font' if current_account&.user&.setting_system_font_ui
     output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion')
     output << 'rtl' if locale_direction == 'rtl'
diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb
index 08cfa9c6d..2f5fecaae 100644
--- a/app/helpers/context_helper.rb
+++ b/app/helpers/context_helper.rb
@@ -7,6 +7,7 @@ module ContextHelper
   }.freeze
 
   CONTEXT_EXTENSION_MAP = {
+    direct_message: { 'litepub' => 'http://litepub.social/ns#', 'directMessage' => 'litepub:directMessage' },
     manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' },
     sensitive: { 'sensitive' => 'as:sensitive' },
     hashtag: { 'Hashtag' => 'as:Hashtag' },
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
index d390b9bc9..5b2ac1a2a 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -15,7 +15,7 @@ module FormattingHelper
   module_function :extract_status_plain_text
 
   def status_content_format(status)
-    html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
+    html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type)
   end
 
   def rss_status_content_format(status)
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
new file mode 100644
index 000000000..ac1b2f95f
--- /dev/null
+++ b/app/javascript/core/admin.js
@@ -0,0 +1,227 @@
+//  This file will be loaded on admin pages, regardless of theme.
+
+import 'packs/public-path';
+import { delegate } from '@rails/ujs';
+import ready from '../mastodon/ready';
+
+const setAnnouncementEndsAttributes = (target) => {
+  const valid = target?.value && target?.validity?.valid;
+  const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at');
+  if (valid) {
+    element.classList.remove('optional');
+    element.required = true;
+    element.min = target.value;
+  } else {
+    element.classList.add('optional');
+    element.removeAttribute('required');
+    element.removeAttribute('min');
+  }
+};
+
+delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => {
+  setAnnouncementEndsAttributes(target);
+});
+
+const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
+
+const showSelectAll = () => {
+  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+  selectAllMatchingElement.classList.add('active');
+};
+
+const hideSelectAll = () => {
+  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+  const hiddenField = document.querySelector('#select_all_matching');
+  const selectedMsg = document.querySelector('.batch-table__select-all .selected');
+  const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
+
+  selectAllMatchingElement.classList.remove('active');
+  selectedMsg.classList.remove('active');
+  notSelectedMsg.classList.add('active');
+  hiddenField.value = '0';
+};
+
+delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
+  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+
+  [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => {
+    content.checked = target.checked;
+  });
+
+  if (selectAllMatchingElement) {
+    if (target.checked) {
+      showSelectAll();
+    } else {
+      hideSelectAll();
+    }
+  }
+});
+
+delegate(document, '.batch-table__select-all button', 'click', () => {
+  const hiddenField = document.querySelector('#select_all_matching');
+  const active = hiddenField.value === '1';
+  const selectedMsg = document.querySelector('.batch-table__select-all .selected');
+  const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
+
+  if (active) {
+    hiddenField.value = '0';
+    selectedMsg.classList.remove('active');
+    notSelectedMsg.classList.add('active');
+  } else {
+    hiddenField.value = '1';
+    notSelectedMsg.classList.remove('active');
+    selectedMsg.classList.add('active');
+  }
+});
+
+delegate(document, batchCheckboxClassName, 'change', () => {
+  const checkAllElement = document.querySelector('#batch_checkbox_all');
+  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
+
+  if (checkAllElement) {
+    checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+    checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+
+    if (selectAllMatchingElement) {
+      if (checkAllElement.checked) {
+        showSelectAll();
+      } else {
+        hideSelectAll();
+      }
+    }
+  }
+});
+
+delegate(document, '.media-spoiler-show-button', 'click', () => {
+  [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => {
+    element.click();
+  });
+});
+
+delegate(document, '.media-spoiler-hide-button', 'click', () => {
+  [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => {
+    element.click();
+  });
+});
+
+delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
+  target.form.submit();
+});
+
+const onDomainBlockSeverityChange = (target) => {
+  const rejectMediaDiv   = document.querySelector('.input.with_label.domain_block_reject_media');
+  const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');
+
+  if (rejectMediaDiv) {
+    rejectMediaDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
+  }
+
+  if (rejectReportsDiv) {
+    rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
+  }
+};
+
+delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
+
+const onEnableBootstrapTimelineAccountsChange = (target) => {
+  const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts');
+
+  if (bootstrapTimelineAccountsField) {
+    bootstrapTimelineAccountsField.disabled = !target.checked;
+    if (target.checked) {
+      bootstrapTimelineAccountsField.parentElement.classList.remove('disabled');
+      bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled');
+    } else {
+      bootstrapTimelineAccountsField.parentElement.classList.add('disabled');
+      bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled');
+    }
+  }
+};
+
+delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
+
+const onChangeRegistrationMode = (target) => {
+  const enabled = target.value === 'approved';
+
+  [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
+    input.disabled = !enabled;
+    if (enabled) {
+      let element = input;
+      do {
+        element.classList.remove('disabled');
+        element = element.parentElement;
+      } while (element && !element.classList.contains('fields-group'));
+    } else {
+      let element = input;
+      do {
+        element.classList.add('disabled');
+        element = element.parentElement;
+      } while (element && !element.classList.contains('fields-group'));
+    }
+  });
+};
+
+const convertUTCDateTimeToLocal = (value) => {
+  const date = new Date(value + 'Z');
+  const twoChars = (x) => (x.toString().padStart(2, '0'));
+  return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
+};
+
+const convertLocalDatetimeToUTC = (value) => {
+  const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/;
+  const match = re.exec(value);
+  const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]);
+  const fullISO8601 = date.toISOString();
+  return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
+};
+
+delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
+
+ready(() => {
+  const domainBlockSeverityInput = document.getElementById('domain_block_severity');
+  if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
+
+  const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
+  if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
+
+  const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
+  if (registrationMode) onChangeRegistrationMode(registrationMode);
+
+  const checkAllElement = document.querySelector('#batch_checkbox_all');
+  if (checkAllElement) {
+    checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+    checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+  }
+
+  document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => {
+    const domain = document.querySelector('input[type="text"]#by_domain')?.value;
+
+    if (domain) {
+      const url = new URL(event.target.href);
+      url.searchParams.set('_domain', domain);
+      e.target.href = url;
+    }
+  });
+
+  [].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => {
+    if (element.value) {
+      element.value = convertUTCDateTimeToLocal(element.value);
+    }
+    if (element.placeholder) {
+      element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
+    }
+  });
+
+  delegate(document, 'form', 'submit', ({ target }) => {
+    [].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => {
+      if (element.value && element.validity.valid) {
+        element.value = convertLocalDatetimeToUTC(element.value);
+      }
+    });
+  });
+
+  const announcementStartsAt = document.querySelector('input[type="datetime-local"]#announcement_starts_at');
+  if (announcementStartsAt) {
+    setAnnouncementEndsAttributes(announcementStartsAt);
+  }
+});
diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js
new file mode 100644
index 000000000..d1d14d99e
--- /dev/null
+++ b/app/javascript/core/auth.js
@@ -0,0 +1,3 @@
+import 'packs/public-path';
+import './settings';
+import './two_factor_authentication';
diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js
new file mode 100644
index 000000000..1cee2f603
--- /dev/null
+++ b/app/javascript/core/common.js
@@ -0,0 +1,6 @@
+//  This file will be loaded on all pages, regardless of theme.
+
+import 'packs/public-path';
+import 'font-awesome/css/font-awesome.css';
+
+require.context('../images/', true);
diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js
new file mode 100644
index 000000000..d1e8f6b10
--- /dev/null
+++ b/app/javascript/core/embed.js
@@ -0,0 +1,25 @@
+//  This file will be loaded on embed pages, regardless of theme.
+
+import 'packs/public-path';
+
+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/packs/mailer.js b/app/javascript/core/mailer.js
index a4b6d5446..a4b6d5446 100644
--- a/app/javascript/packs/mailer.js
+++ b/app/javascript/core/mailer.js
diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js
new file mode 100644
index 000000000..5c7a51f44
--- /dev/null
+++ b/app/javascript/core/public.js
@@ -0,0 +1,30 @@
+//  This file will be loaded on public pages, regardless of theme.
+
+import 'packs/public-path';
+import ready from '../mastodon/ready';
+
+const { delegate } = require('@rails/ujs');
+const { length } = require('stringz');
+
+const getProfileAvatarAnimationHandler = (swapTo) => {
+  //animate avatar gifs on the profile page when moused over
+  return ({ target }) => {
+    const swapSrc = target.getAttribute(swapTo);
+    //only change the img source if autoplay is off and the image src is actually different
+    if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
+      target.src = swapSrc;
+    }
+  };
+};
+
+delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
+
+delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
+
+delegate(document, '#account_header', 'change', ({ target }) => {
+  const header = document.querySelector('.card .card__img img');
+  const [file] = target.files || [];
+  const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
+
+  header.src = url;
+});
diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js
new file mode 100644
index 000000000..d578463a3
--- /dev/null
+++ b/app/javascript/core/settings.js
@@ -0,0 +1,79 @@
+//  This file will be loaded on settings pages, regardless of theme.
+
+import 'packs/public-path';
+import escapeTextContentForBrowser from 'escape-html';
+
+const { delegate } = require('@rails/ujs');
+
+import emojify from '../mastodon/features/emoji/emoji';
+
+delegate(document, '#account_display_name', 'input', ({ target }) => {
+  const name = document.querySelector('.card .display-name strong');
+  if (name) {
+    if (target.value) {
+      name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
+    } else {
+      name.textContent = name.textContent = target.dataset.default;
+    }
+  }
+});
+
+delegate(document, '#account_avatar', 'change', ({ target }) => {
+  const avatar = document.querySelector('.card .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 .card__img img');
+  const [file] = target.files || [];
+  const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
+
+  header.src = url;
+});
+
+delegate(document, '#account_locked', 'change', ({ target }) => {
+  const lock = document.querySelector('.card .display-name i');
+
+  if (lock) {
+    if (target.checked) {
+      delete lock.dataset.hidden;
+    } else {
+      lock.dataset.hidden = 'true';
+    }
+  }
+});
+
+delegate(document, '.input-copy input', 'click', ({ target }) => {
+  target.focus();
+  target.select();
+  target.setSelectionRange(0, target.value.length);
+});
+
+delegate(document, '.input-copy button', 'click', ({ target }) => {
+  const input = target.parentNode.querySelector('.input-copy__wrapper input');
+
+  const oldReadOnly = input.readonly;
+
+  input.readonly = false;
+  input.focus();
+  input.select();
+  input.setSelectionRange(0, input.value.length);
+
+  try {
+    if (document.execCommand('copy')) {
+      input.blur();
+      target.parentNode.classList.add('copied');
+
+      setTimeout(() => {
+        target.parentNode.classList.remove('copied');
+      }, 700);
+    }
+  } catch (err) {
+    console.error(err);
+  }
+
+  input.readonly = oldReadOnly;
+});
diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml
new file mode 100644
index 000000000..b9144e43a
--- /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: auth.js
+  common:
+    filename: common.js
+    stylesheet: true
+  embed: embed.js
+  error:
+  home:
+  mailer:
+    filename: mailer.js
+    stylesheet: true
+  modal: public.js
+  public: public.js
+  settings: settings.js
+  share:
diff --git a/app/javascript/packs/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js
index dde06be8c..f076cdf30 100644
--- a/app/javascript/packs/two_factor_authentication.js
+++ b/app/javascript/core/two_factor_authentication.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import axios from 'axios';
 import * as WebAuthnJSON from '@github/webauthn-json';
 import ready from '../mastodon/ready';
diff --git a/app/javascript/flavours/glitch/actions/account_notes.js b/app/javascript/flavours/glitch/actions/account_notes.js
new file mode 100644
index 000000000..62a6b4cbb
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/account_notes.js
@@ -0,0 +1,69 @@
+import api from '../api';
+
+export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
+export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
+export const ACCOUNT_NOTE_SUBMIT_FAIL    = 'ACCOUNT_NOTE_SUBMIT_FAIL';
+
+export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
+export const ACCOUNT_NOTE_CANCEL    = 'ACCOUNT_NOTE_CANCEL';
+
+export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
+
+export function submitAccountNote() {
+  return (dispatch, getState) => {
+    dispatch(submitAccountNoteRequest());
+
+    const id = getState().getIn(['account_notes', 'edit', 'account_id']);
+
+    api(getState).post(`/api/v1/accounts/${id}/note`, {
+      comment: getState().getIn(['account_notes', 'edit', 'comment']),
+    }).then(response => {
+      dispatch(submitAccountNoteSuccess(response.data));
+    }).catch(error => dispatch(submitAccountNoteFail(error)));
+  };
+}
+
+export function submitAccountNoteRequest() {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_REQUEST,
+  };
+}
+
+export function submitAccountNoteSuccess(relationship) {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
+    relationship,
+  };
+}
+
+export function submitAccountNoteFail(error) {
+  return {
+    type: ACCOUNT_NOTE_SUBMIT_FAIL,
+    error,
+  };
+}
+
+export function initEditAccountNote(account) {
+  return (dispatch, getState) => {
+    const comment = getState().getIn(['relationships', account.get('id'), 'note']);
+
+    dispatch({
+      type: ACCOUNT_NOTE_INIT_EDIT,
+      account,
+      comment,
+    });
+  };
+}
+
+export function cancelAccountNote() {
+  return {
+    type: ACCOUNT_NOTE_CANCEL,
+  };
+}
+
+export function changeAccountNoteComment(comment) {
+  return {
+    type: ACCOUNT_NOTE_CHANGE_COMMENT,
+    comment,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
new file mode 100644
index 000000000..6b5b2ade5
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -0,0 +1,884 @@
+import api, { getLinks } from '../api';
+import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer';
+
+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_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST';
+export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS';
+export const ACCOUNT_LOOKUP_FAIL    = 'ACCOUNT_LOOKUP_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 ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST';
+export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS';
+export const ACCOUNT_PIN_FAIL    = 'ACCOUNT_PIN_FAIL';
+
+export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST';
+export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS';
+export const ACCOUNT_UNPIN_FAIL    = 'ACCOUNT_UNPIN_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 const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
+export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS';
+export const PINNED_ACCOUNTS_FETCH_FAIL    = 'PINNED_ACCOUNTS_FETCH_FAIL';
+
+export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY  = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY';
+export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR  = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR';
+export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE';
+
+export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET';
+
+
+export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
+
+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(importFetchedAccount(response.data));
+    }).then(() => {
+      dispatch(fetchAccountSuccess());
+    }).catch(error => {
+      dispatch(fetchAccountFail(id, error));
+    });
+  };
+}
+
+export const lookupAccount = acct => (dispatch, getState) => {
+  dispatch(lookupAccountRequest(acct));
+
+  api(getState).get('/api/v1/accounts/lookup', { params: { acct } }).then(response => {
+    dispatch(fetchRelationships([response.data.id]));
+    dispatch(importFetchedAccount(response.data));
+    dispatch(lookupAccountSuccess());
+  }).catch(error => {
+    dispatch(lookupAccountFail(acct, error));
+  });
+};
+
+export const lookupAccountRequest = (acct) => ({
+  type: ACCOUNT_LOOKUP_REQUEST,
+  acct,
+});
+
+export const lookupAccountSuccess = () => ({
+  type: ACCOUNT_LOOKUP_SUCCESS,
+});
+
+export const lookupAccountFail = (acct, error) => ({
+  type: ACCOUNT_LOOKUP_FAIL,
+  acct,
+  error,
+  skipAlert: true,
+});
+
+export function fetchAccountRequest(id) {
+  return {
+    type: ACCOUNT_FETCH_REQUEST,
+    id,
+  };
+}
+
+export function fetchAccountSuccess() {
+  return {
+    type: ACCOUNT_FETCH_SUCCESS,
+  };
+}
+
+export function fetchAccountFail(id, error) {
+  return {
+    type: ACCOUNT_FETCH_FAIL,
+    id,
+    error,
+    skipAlert: true,
+  };
+}
+
+export function followAccount(id, options = { reblogs: true }) {
+  return (dispatch, getState) => {
+    const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
+    const locked = getState().getIn(['accounts', id, 'locked'], false);
+
+    dispatch(followAccountRequest(id, locked));
+
+    api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
+      dispatch(followAccountSuccess(response.data, alreadyFollowing));
+    }).catch(error => {
+      dispatch(followAccountFail(error, locked));
+    });
+  };
+}
+
+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, locked) {
+  return {
+    type: ACCOUNT_FOLLOW_REQUEST,
+    id,
+    locked,
+    skipLoading: true,
+  };
+}
+
+export function followAccountSuccess(relationship, alreadyFollowing) {
+  return {
+    type: ACCOUNT_FOLLOW_SUCCESS,
+    relationship,
+    alreadyFollowing,
+    skipLoading: true,
+  };
+}
+
+export function followAccountFail(error, locked) {
+  return {
+    type: ACCOUNT_FOLLOW_FAIL,
+    error,
+    locked,
+    skipLoading: true,
+  };
+}
+
+export function unfollowAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNFOLLOW_REQUEST,
+    id,
+    skipLoading: true,
+  };
+}
+
+export function unfollowAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_UNFOLLOW_SUCCESS,
+    relationship,
+    statuses,
+    skipLoading: true,
+  };
+}
+
+export function unfollowAccountFail(error) {
+  return {
+    type: ACCOUNT_UNFOLLOW_FAIL,
+    error,
+    skipLoading: true,
+  };
+}
+
+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, duration=0) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).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(importFetchedAccounts(response.data));
+      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,
+    skipNotFound: true,
+  };
+}
+
+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(importFetchedAccounts(response.data));
+      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(importFetchedAccounts(response.data));
+      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,
+    skipNotFound: true,
+  };
+}
+
+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(importFetchedAccounts(response.data));
+      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 state = getState();
+    const loadedRelationships = state.get('relationships');
+    const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+    const signedIn = !!state.getIn(['meta', 'me']);
+
+    if (!signedIn || 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,
+    skipNotFound: 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(importFetchedAccounts(response.data));
+      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(importFetchedAccounts(response.data));
+      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,
+  };
+}
+
+export function pinAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(pinAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
+      dispatch(pinAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(pinAccountFail(error));
+    });
+  };
+}
+
+export function unpinAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unpinAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
+      dispatch(unpinAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unpinAccountFail(error));
+    });
+  };
+}
+
+export function pinAccountRequest(id) {
+  return {
+    type: ACCOUNT_PIN_REQUEST,
+    id,
+  };
+}
+
+export function pinAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_PIN_SUCCESS,
+    relationship,
+  };
+}
+
+export function pinAccountFail(error) {
+  return {
+    type: ACCOUNT_PIN_FAIL,
+    error,
+  };
+}
+
+export function unpinAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNPIN_REQUEST,
+    id,
+  };
+}
+
+export function unpinAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNPIN_SUCCESS,
+    relationship,
+  };
+}
+
+export function unpinAccountFail(error) {
+  return {
+    type: ACCOUNT_UNPIN_FAIL,
+    error,
+  };
+}
+
+export const revealAccount = id => ({
+  type: ACCOUNT_REVEAL,
+  id,
+});
+
+export function fetchPinnedAccounts() {
+  return (dispatch, getState) => {
+    dispatch(fetchPinnedAccountsRequest());
+
+    api(getState).get('/api/v1/endorsements', { params: { limit: 0 } }).then(response => {
+      dispatch(importFetchedAccounts(response.data));
+      dispatch(fetchPinnedAccountsSuccess(response.data));
+    }).catch(err => dispatch(fetchPinnedAccountsFail(err)));
+  };
+}
+
+export function fetchPinnedAccountsRequest() {
+  return {
+    type: PINNED_ACCOUNTS_FETCH_REQUEST,
+  };
+}
+
+export function fetchPinnedAccountsSuccess(accounts, next) {
+  return {
+    type: PINNED_ACCOUNTS_FETCH_SUCCESS,
+    accounts,
+    next,
+  };
+}
+
+export function fetchPinnedAccountsFail(error) {
+  return {
+    type: PINNED_ACCOUNTS_FETCH_FAIL,
+    error,
+  };
+}
+
+export function fetchPinnedAccountsSuggestions(q) {
+  return (dispatch, getState) => {
+    const params = {
+      q,
+      resolve: false,
+      limit: 4,
+      following: true,
+    };
+
+    api(getState).get('/api/v1/accounts/search', { params }).then(response => {
+      dispatch(importFetchedAccounts(response.data));
+      dispatch(fetchPinnedAccountsSuggestionsReady(q, response.data));
+    });
+  };
+}
+
+export function fetchPinnedAccountsSuggestionsReady(query, accounts) {
+  return {
+    type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
+    query,
+    accounts,
+  };
+}
+
+export function clearPinnedAccountsSuggestions() {
+  return {
+    type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
+  };
+}
+
+export function changePinnedAccountsSuggestions(value) {
+  return {
+    type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
+    value,
+  };
+}
+
+export function resetPinnedAccountsEditor() {
+  return {
+    type: PINNED_ACCOUNTS_EDITOR_RESET,
+  };
+}
+
diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js
new file mode 100644
index 000000000..0220b0af5
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/alerts.js
@@ -0,0 +1,63 @@
+import { defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
+  unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
+  rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
+  rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
+});
+
+export const ALERT_SHOW    = 'ALERT_SHOW';
+export const ALERT_DISMISS = 'ALERT_DISMISS';
+export const ALERT_CLEAR   = 'ALERT_CLEAR';
+export const ALERT_NOOP    = 'ALERT_NOOP';
+
+export function dismissAlert(alert) {
+  return {
+    type: ALERT_DISMISS,
+    alert,
+  };
+}
+
+export function clearAlert() {
+  return {
+    type: ALERT_CLEAR,
+  };
+}
+
+export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
+  return {
+    type: ALERT_SHOW,
+    title,
+    message,
+    message_values,
+  };
+}
+
+export function showAlertForError(error, skipNotFound = false) {
+  if (error.response) {
+    const { data, status, statusText, headers } = error.response;
+
+    if (skipNotFound && (status === 404 || status === 410)) {
+      // Skip these errors as they are reflected in the UI
+      return { type: ALERT_NOOP };
+    }
+
+    if (status === 429 && headers['x-ratelimit-reset']) {
+      const reset_date = new Date(headers['x-ratelimit-reset']);
+      return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
+    }
+
+    let message = statusText;
+    let title   = `${status}`;
+
+    if (data.error) {
+      message = data.error;
+    }
+
+    return showAlert(title, message);
+  } else {
+    console.error(error);
+    return showAlert();
+  }
+}
diff --git a/app/javascript/flavours/glitch/actions/announcements.js b/app/javascript/flavours/glitch/actions/announcements.js
new file mode 100644
index 000000000..586dcfd33
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/announcements.js
@@ -0,0 +1,180 @@
+import api from '../api';
+import { normalizeAnnouncement } from './importer/normalizer';
+
+export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
+export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
+export const ANNOUNCEMENTS_FETCH_FAIL    = 'ANNOUNCEMENTS_FETCH_FAIL';
+export const ANNOUNCEMENTS_UPDATE        = 'ANNOUNCEMENTS_UPDATE';
+export const ANNOUNCEMENTS_DELETE        = 'ANNOUNCEMENTS_DELETE';
+
+export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST';
+export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS';
+export const ANNOUNCEMENTS_DISMISS_FAIL    = 'ANNOUNCEMENTS_DISMISS_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
+export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_ADD_FAIL    = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
+export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL    = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
+
+export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW';
+
+const noOp = () => {};
+
+export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
+  dispatch(fetchAnnouncementsRequest());
+
+  api(getState).get('/api/v1/announcements').then(response => {
+    dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x))));
+  }).catch(error => {
+    dispatch(fetchAnnouncementsFail(error));
+  }).finally(() => {
+    done();
+  });
+};
+
+export const fetchAnnouncementsRequest = () => ({
+  type: ANNOUNCEMENTS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchAnnouncementsSuccess = announcements => ({
+  type: ANNOUNCEMENTS_FETCH_SUCCESS,
+  announcements,
+  skipLoading: true,
+});
+
+export const fetchAnnouncementsFail= error => ({
+  type: ANNOUNCEMENTS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const updateAnnouncements = announcement => ({
+  type: ANNOUNCEMENTS_UPDATE,
+  announcement: normalizeAnnouncement(announcement),
+});
+
+export const dismissAnnouncement = announcementId => (dispatch, getState) => {
+  dispatch(dismissAnnouncementRequest(announcementId));
+
+  api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => {
+    dispatch(dismissAnnouncementSuccess(announcementId));
+  }).catch(error => {
+    dispatch(dismissAnnouncementFail(announcementId, error));
+  });
+};
+
+export const dismissAnnouncementRequest = announcementId => ({
+  type: ANNOUNCEMENTS_DISMISS_REQUEST,
+  id: announcementId,
+});
+
+export const dismissAnnouncementSuccess = announcementId => ({
+  type: ANNOUNCEMENTS_DISMISS_SUCCESS,
+  id: announcementId,
+});
+
+export const dismissAnnouncementFail = (announcementId, error) => ({
+  type: ANNOUNCEMENTS_DISMISS_FAIL,
+  id: announcementId,
+  error,
+});
+
+export const addReaction = (announcementId, name) => (dispatch, getState) => {
+  const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId);
+
+  let alreadyAdded = false;
+
+  if (announcement) {
+    const reaction = announcement.get('reactions').find(x => x.get('name') === name);
+    if (reaction && reaction.get('me')) {
+      alreadyAdded = true;
+    }
+  }
+
+  if (!alreadyAdded) {
+    dispatch(addReactionRequest(announcementId, name, alreadyAdded));
+  }
+
+  api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
+    dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
+  }).catch(err => {
+    if (!alreadyAdded) {
+      dispatch(addReactionFail(announcementId, name, err));
+    }
+  });
+};
+
+export const addReactionRequest = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const addReactionSuccess = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const addReactionFail = (announcementId, name, error) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
+  id: announcementId,
+  name,
+  error,
+  skipLoading: true,
+});
+
+export const removeReaction = (announcementId, name) => (dispatch, getState) => {
+  dispatch(removeReactionRequest(announcementId, name));
+
+  api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
+    dispatch(removeReactionSuccess(announcementId, name));
+  }).catch(err => {
+    dispatch(removeReactionFail(announcementId, name, err));
+  });
+};
+
+export const removeReactionRequest = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const removeReactionSuccess = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const removeReactionFail = (announcementId, name, error) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+  id: announcementId,
+  name,
+  error,
+  skipLoading: true,
+});
+
+export const updateReaction = reaction => ({
+  type: ANNOUNCEMENTS_REACTION_UPDATE,
+  reaction,
+});
+
+export const toggleShowAnnouncements = () => ({
+  type: ANNOUNCEMENTS_TOGGLE_SHOW,
+});
+
+export const deleteAnnouncement = id => ({
+  type: ANNOUNCEMENTS_DELETE,
+  id,
+});
diff --git a/app/javascript/flavours/glitch/actions/app.js b/app/javascript/flavours/glitch/actions/app.js
new file mode 100644
index 000000000..de2d93e29
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/app.js
@@ -0,0 +1,6 @@
+export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
+
+export const changeLayout = layout => ({
+  type: APP_LAYOUT_CHANGE,
+  layout,
+});
diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js
new file mode 100644
index 000000000..192aa3ce4
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/blocks.js
@@ -0,0 +1,99 @@
+import api, { getLinks } from '../api';
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
+import { openModal } from './modal';
+
+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 const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL';
+
+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(importFetchedAccounts(response.data));
+      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(importFetchedAccounts(response.data));
+      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,
+  };
+}
+
+export function initBlockModal(account) {
+  return dispatch => {
+    dispatch({
+      type: BLOCKS_INIT_MODAL,
+      account,
+    });
+
+    dispatch(openModal('BLOCK'));
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/bookmarks.js b/app/javascript/flavours/glitch/actions/bookmarks.js
new file mode 100644
index 000000000..3c8eec546
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/bookmarks.js
@@ -0,0 +1,90 @@
+import api, { getLinks } from '../api';
+import { importFetchedStatuses } from './importer';
+
+export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
+export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
+export const BOOKMARKED_STATUSES_FETCH_FAIL    = 'BOOKMARKED_STATUSES_FETCH_FAIL';
+
+export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST';
+export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS';
+export const BOOKMARKED_STATUSES_EXPAND_FAIL    = 'BOOKMARKED_STATUSES_EXPAND_FAIL';
+
+export function fetchBookmarkedStatuses() {
+  return (dispatch, getState) => {
+    if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
+      return;
+    }
+
+    dispatch(fetchBookmarkedStatusesRequest());
+
+    api(getState).get('/api/v1/bookmarks').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedStatuses(response.data));
+      dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchBookmarkedStatusesFail(error));
+    });
+  };
+}
+
+export function fetchBookmarkedStatusesRequest() {
+  return {
+    type: BOOKMARKED_STATUSES_FETCH_REQUEST,
+  };
+}
+
+export function fetchBookmarkedStatusesSuccess(statuses, next) {
+  return {
+    type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next,
+  };
+}
+
+export function fetchBookmarkedStatusesFail(error) {
+  return {
+    type: BOOKMARKED_STATUSES_FETCH_FAIL,
+    error,
+  };
+}
+
+export function expandBookmarkedStatuses() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null);
+
+    if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
+      return;
+    }
+
+    dispatch(expandBookmarkedStatusesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedStatuses(response.data));
+      dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandBookmarkedStatusesFail(error));
+    });
+  };
+}
+
+export function expandBookmarkedStatusesRequest() {
+  return {
+    type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
+  };
+}
+
+export function expandBookmarkedStatusesSuccess(statuses, next) {
+  return {
+    type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
+    statuses,
+    next,
+  };
+}
+
+export function expandBookmarkedStatusesFail(error) {
+  return {
+    type: BOOKMARKED_STATUSES_EXPAND_FAIL,
+    error,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/boosts.js b/app/javascript/flavours/glitch/actions/boosts.js
new file mode 100644
index 000000000..c0f0f3acc
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/boosts.js
@@ -0,0 +1,29 @@
+import { openModal } from './modal';
+
+export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL';
+export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY';
+
+export function initBoostModal(props) {
+  return (dispatch, getState) => {
+    const default_privacy = getState().getIn(['compose', 'default_privacy']);
+
+    const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy;
+
+    dispatch({
+      type: BOOSTS_INIT_MODAL,
+      privacy,
+    });
+
+    dispatch(openModal('BOOST', props));
+  };
+}
+
+
+export function changeBoostPrivacy(privacy) {
+  return dispatch => {
+    dispatch({
+      type: BOOSTS_CHANGE_PRIVACY,
+      privacy,
+    });
+  };
+}
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/columns.js b/app/javascript/flavours/glitch/actions/columns.js
new file mode 100644
index 000000000..302c3f0f9
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/columns.js
@@ -0,0 +1,54 @@
+import { saveSettings } from './settings';
+
+export const COLUMN_ADD           = 'COLUMN_ADD';
+export const COLUMN_REMOVE        = 'COLUMN_REMOVE';
+export const COLUMN_MOVE          = 'COLUMN_MOVE';
+export const COLUMN_PARAMS_CHANGE = 'COLUMN_PARAMS_CHANGE';
+
+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());
+  };
+}
+
+export function changeColumnParams(uuid, path, value) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_PARAMS_CHANGE,
+      uuid,
+      path,
+      value,
+    });
+
+    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..9c0ef83df
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -0,0 +1,820 @@
+import axios from 'axios';
+import { throttle } from 'lodash';
+import { defineMessages } from 'react-intl';
+import api from 'flavours/glitch/api';
+import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light';
+import { tagHistory } from 'flavours/glitch/settings';
+import { recoverHashtags } from 'flavours/glitch/utils/hashtag';
+import resizeImage from 'flavours/glitch/utils/resize_image';
+import { showAlert, showAlertForError } from './alerts';
+import { useEmoji } from './emojis';
+import { importFetchedAccounts, importFetchedStatus } from './importer';
+import { openModal } from './modal';
+import { updateTimeline } from './timelines';
+
+/** @type {AbortController | undefined} */
+let fetchComposeSuggestionsAccountsController;
+/** @type {AbortController | undefined} */
+let fetchComposeSuggestionsTagsController;
+
+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_DIRECT          = 'COMPOSE_DIRECT';
+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_PROCESSING = 'COMPOSE_UPLOAD_PROCESSING';
+export const COMPOSE_UPLOAD_UNDO       = 'COMPOSE_UPLOAD_UNDO';
+
+export const THUMBNAIL_UPLOAD_REQUEST  = 'THUMBNAIL_UPLOAD_REQUEST';
+export const THUMBNAIL_UPLOAD_SUCCESS  = 'THUMBNAIL_UPLOAD_SUCCESS';
+export const THUMBNAIL_UPLOAD_FAIL     = 'THUMBNAIL_UPLOAD_FAIL';
+export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
+
+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_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE';
+export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
+
+export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
+
+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_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
+export const COMPOSE_LANGUAGE_CHANGE     = 'COMPOSE_LANGUAGE_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 const COMPOSE_POLL_ADD             = 'COMPOSE_POLL_ADD';
+export const COMPOSE_POLL_REMOVE          = 'COMPOSE_POLL_REMOVE';
+export const COMPOSE_POLL_OPTION_ADD      = 'COMPOSE_POLL_OPTION_ADD';
+export const COMPOSE_POLL_OPTION_CHANGE   = 'COMPOSE_POLL_OPTION_CHANGE';
+export const COMPOSE_POLL_OPTION_REMOVE   = 'COMPOSE_POLL_OPTION_REMOVE';
+export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
+
+export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
+
+export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
+export const COMPOSE_CHANGE_MEDIA_FOCUS       = 'COMPOSE_CHANGE_MEDIA_FOCUS';
+
+export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
+
+const messages = defineMessages({
+  uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
+  uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
+});
+
+export const ensureComposeIsVisible = (getState, routerHistory) => {
+  if (!getState().getIn(['compose', 'mounted'])) {
+    routerHistory.push('/publish');
+  }
+};
+
+export function setComposeToStatus(status, text, spoiler_text, content_type) {
+  return{
+    type: COMPOSE_SET_STATUS,
+    status,
+    text,
+    spoiler_text,
+    content_type,
+  };
+}
+
+export function changeCompose(text) {
+  return {
+    type: COMPOSE_CHANGE,
+    text: text,
+  };
+}
+
+export function cycleElefriendCompose() {
+  return {
+    type: COMPOSE_CYCLE_ELEFRIEND,
+  };
+}
+
+export function replyCompose(status, routerHistory) {
+  return (dispatch, getState) => {
+    const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']);
+    dispatch({
+      type: COMPOSE_REPLY,
+      status: status,
+      prependCWRe: prependCWRe,
+    });
+
+    ensureComposeIsVisible(getState, routerHistory);
+  };
+}
+
+export function cancelReplyCompose() {
+  return {
+    type: COMPOSE_REPLY_CANCEL,
+  };
+}
+
+export function resetCompose() {
+  return {
+    type: COMPOSE_RESET,
+  };
+}
+
+export function mentionCompose(account, routerHistory) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_MENTION,
+      account: account,
+    });
+
+    ensureComposeIsVisible(getState, routerHistory);
+  };
+}
+
+export function directCompose(account, routerHistory) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_DIRECT,
+      account: account,
+    });
+
+    ensureComposeIsVisible(getState, routerHistory);
+  };
+}
+
+export function submitCompose(routerHistory) {
+  return function (dispatch, getState) {
+    let status     = getState().getIn(['compose', 'text'], '');
+    const media    = getState().getIn(['compose', 'media_attachments']);
+    const statusId = getState().getIn(['compose', 'id'], null);
+    const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
+    let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
+
+    if ((!status || !status.length) && media.size === 0) {
+      return;
+    }
+
+    if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
+      status = status + ' 👁️';
+    }
+
+    dispatch(submitComposeRequest());
+
+    // If we're editing a post with media attachments, those have not
+    // necessarily been changed on the server. Do it now in the same
+    // API call.
+    let media_attributes;
+    if (statusId !== null) {
+      media_attributes = media.map(item => {
+        let focus;
+
+        if (item.getIn(['meta', 'focus'])) {
+          focus = `${item.getIn(['meta', 'focus', 'x']).toFixed(2)},${item.getIn(['meta', 'focus', 'y']).toFixed(2)}`;
+        }
+
+        return {
+          id: item.get('id'),
+          description: item.get('description'),
+          focus,
+        };
+      });
+    }
+
+    api(getState).request({
+      url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
+      method: statusId === null ? 'post' : 'put',
+      data: {
+        status,
+        content_type: getState().getIn(['compose', 'content_type']),
+        in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+        media_ids: media.map(item => item.get('id')),
+        media_attributes,
+        sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
+        spoiler_text: spoilerText,
+        visibility: getState().getIn(['compose', 'privacy']),
+        poll: getState().getIn(['compose', 'poll'], null),
+        language: getState().getIn(['compose', 'language']),
+      },
+      headers: {
+        'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
+      },
+    }).then(function (response) {
+      if (routerHistory
+          && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new')
+          && window.history.state
+          && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) {
+        routerHistory.goBack();
+      }
+
+      dispatch(insertIntoTagHistory(response.data.tags, status));
+      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 insertIfOnline = (timelineId) => {
+        const timeline = getState().getIn(['timelines', timelineId]);
+
+        if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
+          dispatch(updateTimeline(timelineId, { ...response.data }));
+        }
+      };
+
+      if (statusId) {
+        dispatch(importFetchedStatus({ ...response.data }));
+      }
+
+      if (statusId === null) {
+        insertIfOnline('home');
+      }
+
+      if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+        insertIfOnline('community');
+        if (!response.data.local_only) {
+          insertIfOnline('public');
+        }
+      } else if (statusId === null && response.data.visibility === 'direct') {
+        insertIfOnline('direct');
+      }
+    }).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) {
+    const uploadLimit = 4;
+    const media  = getState().getIn(['compose', 'media_attachments']);
+    const pending  = getState().getIn(['compose', 'pending_media_attachments']);
+    const progress = new Array(files.length).fill(0);
+    let total = Array.from(files).reduce((a, v) => a + v.size, 0);
+
+    if (files.length + media.size + pending > uploadLimit) {
+      dispatch(showAlert(undefined, messages.uploadErrorLimit));
+      return;
+    }
+
+    if (getState().getIn(['compose', 'poll'])) {
+      dispatch(showAlert(undefined, messages.uploadErrorPoll));
+      return;
+    }
+
+    dispatch(uploadComposeRequest());
+
+    for (const [i, f] of Array.from(files).entries()) {
+      if (media.size + i > 3) break;
+
+      resizeImage(f).then(file => {
+        const data = new FormData();
+        data.append('file', file);
+        // Account for disparity in size of original image and resized data
+        total += file.size - f.size;
+
+        return api(getState).post('/api/v2/media', data, {
+          onUploadProgress: function({ loaded }){
+            progress[i] = loaded;
+            dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
+          },
+        }).then(({ status, data }) => {
+          // If server-side processing of the media attachment has not completed yet,
+          // poll the server until it is, before showing the media attachment as uploaded
+
+          if (status === 200) {
+            dispatch(uploadComposeSuccess(data, f));
+          } else if (status === 202) {
+            dispatch(uploadComposeProcessing());
+
+            let tryCount = 1;
+
+            const poll = () => {
+              api(getState).get(`/api/v1/media/${data.id}`).then(response => {
+                if (response.status === 200) {
+                  dispatch(uploadComposeSuccess(response.data, f));
+                } else if (response.status === 206) {
+                  const retryAfter = (Math.log2(tryCount) || 1) * 1000;
+                  tryCount += 1;
+                  setTimeout(() => poll(), retryAfter);
+                }
+              }).catch(error => dispatch(uploadComposeFail(error)));
+            };
+
+            poll();
+          }
+        });
+      }).catch(error => dispatch(uploadComposeFail(error)));
+    }
+  };
+}
+
+export const uploadComposeProcessing = () => ({
+  type: COMPOSE_UPLOAD_PROCESSING,
+});
+
+export const uploadThumbnail = (id, file) => (dispatch, getState) => {
+  dispatch(uploadThumbnailRequest());
+
+  const total = file.size;
+  const data = new FormData();
+
+  data.append('thumbnail', file);
+
+  api(getState).put(`/api/v1/media/${id}`, data, {
+    onUploadProgress: ({ loaded }) => {
+      dispatch(uploadThumbnailProgress(loaded, total));
+    },
+  }).then(({ data }) => {
+    dispatch(uploadThumbnailSuccess(data));
+  }).catch(error => {
+    dispatch(uploadThumbnailFail(id, error));
+  });
+};
+
+export const uploadThumbnailRequest = () => ({
+  type: THUMBNAIL_UPLOAD_REQUEST,
+  skipLoading: true,
+});
+
+export const uploadThumbnailProgress = (loaded, total) => ({
+  type: THUMBNAIL_UPLOAD_PROGRESS,
+  loaded,
+  total,
+  skipLoading: true,
+});
+
+export const uploadThumbnailSuccess = media => ({
+  type: THUMBNAIL_UPLOAD_SUCCESS,
+  media,
+  skipLoading: true,
+});
+
+export const uploadThumbnailFail = error => ({
+  type: THUMBNAIL_UPLOAD_FAIL,
+  error,
+  skipLoading: true,
+});
+
+export function initMediaEditModal(id) {
+  return dispatch => {
+    dispatch({
+      type: INIT_MEDIA_EDIT_MODAL,
+      id,
+    });
+
+    dispatch(openModal('FOCAL_POINT', { id }));
+  };
+}
+
+export function onChangeMediaDescription(description) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+    description,
+  };
+}
+
+export function onChangeMediaFocus(focusX, focusY) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_FOCUS,
+    focusX,
+    focusY,
+  };
+}
+
+export function changeUploadCompose(id, params) {
+  return (dispatch, getState) => {
+    dispatch(changeUploadComposeRequest());
+
+    let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
+
+    // Editing already-attached media is deferred to editing the post itself.
+    // For simplicity's sake, fake an API reply.
+    if (media && !media.get('unattached')) {
+      let { description, focus } = params;
+      const data = media.toJS();
+
+      if (description) {
+        data.description = description;
+      }
+
+      if (focus) {
+        focus = focus.split(',');
+        data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
+      }
+
+      dispatch(changeUploadComposeSuccess(data, true));
+    } else {
+      api(getState).put(`/api/v1/media/${id}`, params).then(response => {
+        dispatch(changeUploadComposeSuccess(response.data, false));
+      }).catch(error => {
+        dispatch(changeUploadComposeFail(id, error));
+      });
+    }
+  };
+}
+
+export function changeUploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+    skipLoading: true,
+  };
+}
+
+export function changeUploadComposeSuccess(media, attached) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+    media: media,
+    attached: attached,
+    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, file) {
+  return {
+    type: COMPOSE_UPLOAD_SUCCESS,
+    media: media,
+    file: file,
+    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() {
+  if (fetchComposeSuggestionsAccountsController) {
+    fetchComposeSuggestionsAccountsController.abort();
+  }
+  return {
+    type: COMPOSE_SUGGESTIONS_CLEAR,
+  };
+}
+
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+  if (fetchComposeSuggestionsAccountsController) {
+    fetchComposeSuggestionsAccountsController.abort();
+  }
+
+  fetchComposeSuggestionsAccountsController = new AbortController();
+
+  api(getState).get('/api/v1/accounts/search', {
+    signal: fetchComposeSuggestionsAccountsController.signal,
+
+    params: {
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(response => {
+    dispatch(importFetchedAccounts(response.data));
+    dispatch(readyComposeSuggestionsAccounts(token, response.data));
+  }).catch(error => {
+    if (!axios.isCancel(error)) {
+      dispatch(showAlertForError(error));
+    }
+  }).finally(() => {
+    fetchComposeSuggestionsAccountsController = undefined;
+  });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+  const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+  dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
+const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
+  if (fetchComposeSuggestionsTagsController) {
+    fetchComposeSuggestionsTagsController.abort();
+  }
+
+  dispatch(updateSuggestionTags(token));
+
+  fetchComposeSuggestionsTagsController = new AbortController();
+
+  api(getState).get('/api/v2/search', {
+    signal: fetchComposeSuggestionsTagsController.signal,
+
+    params: {
+      type: 'hashtags',
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(({ data }) => {
+    dispatch(readyComposeSuggestionsTags(token, data.hashtags));
+  }).catch(error => {
+    if (!axios.isCancel(error)) {
+      dispatch(showAlertForError(error));
+    }
+  }).finally(() => {
+    fetchComposeSuggestionsTagsController = undefined;
+  });
+}, 200, { leading: true, trailing: true });
+
+export function fetchComposeSuggestions(token) {
+  return (dispatch, getState) => {
+    switch (token[0]) {
+    case ':':
+      fetchComposeSuggestionsEmojis(dispatch, getState, token);
+      break;
+    case '#':
+      fetchComposeSuggestionsTags(dispatch, getState, token);
+      break;
+    default:
+      fetchComposeSuggestionsAccounts(dispatch, getState, token);
+      break;
+    }
+  };
+}
+
+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 const readyComposeSuggestionsTags = (token, tags) => ({
+  type: COMPOSE_SUGGESTIONS_READY,
+  token,
+  tags,
+});
+
+export function selectComposeSuggestion(position, token, suggestion, path) {
+  return (dispatch, getState) => {
+    let completion;
+    if (suggestion.type === 'emoji') {
+      dispatch(useEmoji(suggestion));
+      completion = suggestion.native || suggestion.colons;
+    } else if (suggestion.type === 'hashtag') {
+      completion = `#${suggestion.name}`;
+    } else if (suggestion.type === 'account') {
+      completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
+    }
+
+    // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
+    // the suggestions are dismissed and the cursor moves forward.
+    if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
+      dispatch({
+        type: COMPOSE_SUGGESTION_SELECT,
+        position,
+        token,
+        completion,
+        path,
+      });
+    } else {
+      dispatch({
+        type: COMPOSE_SUGGESTION_IGNORE,
+        position,
+        token,
+        completion,
+        path,
+      });
+    }
+  };
+}
+
+export function updateSuggestionTags(token) {
+  return {
+    type: COMPOSE_SUGGESTION_TAGS_UPDATE,
+    token,
+  };
+}
+
+export function updateTagHistory(tags) {
+  return {
+    type: COMPOSE_TAG_HISTORY_UPDATE,
+    tags,
+  };
+}
+
+export function hydrateCompose() {
+  return (dispatch, getState) => {
+    const me = getState().getIn(['meta', 'me']);
+    const history = tagHistory.get(me);
+
+    if (history !== null) {
+      dispatch(updateTagHistory(history));
+    }
+  };
+}
+
+function insertIntoTagHistory(recognizedTags, text) {
+  return (dispatch, getState) => {
+    const state = getState();
+    const oldHistory = state.getIn(['compose', 'tagHistory']);
+    const me = state.getIn(['meta', 'me']);
+    const names = recoverHashtags(recognizedTags, text);
+    const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1);
+
+    names.push(...intersectedOldHistory.toJS());
+
+    const newHistory = names.slice(0, 1000);
+
+    tagHistory.set(me, newHistory);
+    dispatch(updateTagHistory(newHistory));
+  };
+}
+
+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 const changeComposeLanguage = language => ({
+  type: COMPOSE_LANGUAGE_CHANGE,
+  language,
+});
+
+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 changeComposeContentType(value) {
+  return {
+    type: COMPOSE_CONTENT_TYPE_CHANGE,
+    value,
+  };
+}
+
+export function insertEmojiCompose(position, emoji) {
+  return {
+    type: COMPOSE_EMOJI_INSERT,
+    position,
+    emoji,
+  };
+}
+
+export function addPoll() {
+  return {
+    type: COMPOSE_POLL_ADD,
+  };
+}
+
+export function removePoll() {
+  return {
+    type: COMPOSE_POLL_REMOVE,
+  };
+}
+
+export function addPollOption(title) {
+  return {
+    type: COMPOSE_POLL_OPTION_ADD,
+    title,
+  };
+}
+
+export function changePollOption(index, title) {
+  return {
+    type: COMPOSE_POLL_OPTION_CHANGE,
+    index,
+    title,
+  };
+}
+
+export function removePollOption(index) {
+  return {
+    type: COMPOSE_POLL_OPTION_REMOVE,
+    index,
+  };
+}
+
+export function changePollSettings(expiresIn, isMultiple) {
+  return {
+    type: COMPOSE_POLL_SETTINGS_CHANGE,
+    expiresIn,
+    isMultiple,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js
new file mode 100644
index 000000000..4ef654b1f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/conversations.js
@@ -0,0 +1,112 @@
+import api, { getLinks } from '../api';
+import {
+  importFetchedAccounts,
+  importFetchedStatuses,
+  importFetchedStatus,
+} from './importer';
+
+export const CONVERSATIONS_MOUNT   = 'CONVERSATIONS_MOUNT';
+export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT';
+
+export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST';
+export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS';
+export const CONVERSATIONS_FETCH_FAIL    = 'CONVERSATIONS_FETCH_FAIL';
+export const CONVERSATIONS_UPDATE        = 'CONVERSATIONS_UPDATE';
+
+export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
+
+export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST';
+export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS';
+export const CONVERSATIONS_DELETE_FAIL    = 'CONVERSATIONS_DELETE_FAIL';
+
+export const mountConversations = () => ({
+  type: CONVERSATIONS_MOUNT,
+});
+
+export const unmountConversations = () => ({
+  type: CONVERSATIONS_UNMOUNT,
+});
+
+export const markConversationRead = conversationId => (dispatch, getState) => {
+  dispatch({
+    type: CONVERSATIONS_READ,
+    id: conversationId,
+  });
+
+  api(getState).post(`/api/v1/conversations/${conversationId}/read`);
+};
+
+export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => {
+  dispatch(expandConversationsRequest());
+
+  const params = { max_id: maxId };
+
+  if (!maxId) {
+    params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']);
+  }
+
+  const isLoadingRecent = !!params.since_id;
+
+  api(getState).get('/api/v1/conversations', { params })
+    .then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), [])));
+      dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x)));
+      dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent));
+    })
+    .catch(err => dispatch(expandConversationsFail(err)));
+};
+
+export const expandConversationsRequest = () => ({
+  type: CONVERSATIONS_FETCH_REQUEST,
+});
+
+export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({
+  type: CONVERSATIONS_FETCH_SUCCESS,
+  conversations,
+  next,
+  isLoadingRecent,
+});
+
+export const expandConversationsFail = error => ({
+  type: CONVERSATIONS_FETCH_FAIL,
+  error,
+});
+
+export const updateConversations = conversation => dispatch => {
+  dispatch(importFetchedAccounts(conversation.accounts));
+
+  if (conversation.last_status) {
+    dispatch(importFetchedStatus(conversation.last_status));
+  }
+
+  dispatch({
+    type: CONVERSATIONS_UPDATE,
+    conversation,
+  });
+};
+
+export const deleteConversation = conversationId => (dispatch, getState) => {
+  dispatch(deleteConversationRequest(conversationId));
+
+  api(getState).delete(`/api/v1/conversations/${conversationId}`)
+    .then(() => dispatch(deleteConversationSuccess(conversationId)))
+    .catch(error => dispatch(deleteConversationFail(conversationId, error)));
+};
+
+export const deleteConversationRequest = id => ({
+  type: CONVERSATIONS_DELETE_REQUEST,
+  id,
+});
+
+export const deleteConversationSuccess = id => ({
+  type: CONVERSATIONS_DELETE_SUCCESS,
+  id,
+});
+
+export const deleteConversationFail = (id, error) => ({
+  type: CONVERSATIONS_DELETE_FAIL,
+  id,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/custom_emojis.js b/app/javascript/flavours/glitch/actions/custom_emojis.js
new file mode 100644
index 000000000..9ec8156b1
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/custom_emojis.js
@@ -0,0 +1,40 @@
+import api from '../api';
+
+export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST';
+export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS';
+export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL';
+
+export function fetchCustomEmojis() {
+  return (dispatch, getState) => {
+    dispatch(fetchCustomEmojisRequest());
+
+    api(getState).get('/api/v1/custom_emojis').then(response => {
+      dispatch(fetchCustomEmojisSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchCustomEmojisFail(error));
+    });
+  };
+}
+
+export function fetchCustomEmojisRequest() {
+  return {
+    type: CUSTOM_EMOJIS_FETCH_REQUEST,
+    skipLoading: true,
+  };
+}
+
+export function fetchCustomEmojisSuccess(custom_emojis) {
+  return {
+    type: CUSTOM_EMOJIS_FETCH_SUCCESS,
+    custom_emojis,
+    skipLoading: true,
+  };
+}
+
+export function fetchCustomEmojisFail(error) {
+  return {
+    type: CUSTOM_EMOJIS_FETCH_FAIL,
+    error,
+    skipLoading: true,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js
new file mode 100644
index 000000000..4b2b6dd56
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/directory.js
@@ -0,0 +1,61 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
+
+export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
+export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
+export const DIRECTORY_FETCH_FAIL    = 'DIRECTORY_FETCH_FAIL';
+
+export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
+export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
+export const DIRECTORY_EXPAND_FAIL    = 'DIRECTORY_EXPAND_FAIL';
+
+export const fetchDirectory = params => (dispatch, getState) => {
+  dispatch(fetchDirectoryRequest());
+
+  api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(fetchDirectoryFail(error)));
+};
+
+export const fetchDirectoryRequest = () => ({
+  type: DIRECTORY_FETCH_REQUEST,
+});
+
+export const fetchDirectorySuccess = accounts => ({
+  type: DIRECTORY_FETCH_SUCCESS,
+  accounts,
+});
+
+export const fetchDirectoryFail = error => ({
+  type: DIRECTORY_FETCH_FAIL,
+  error,
+});
+
+export const expandDirectory = params => (dispatch, getState) => {
+  dispatch(expandDirectoryRequest());
+
+  const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
+
+  api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(expandDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(expandDirectoryFail(error)));
+};
+
+export const expandDirectoryRequest = () => ({
+  type: DIRECTORY_EXPAND_REQUEST,
+});
+
+export const expandDirectorySuccess = accounts => ({
+  type: DIRECTORY_EXPAND_SUCCESS,
+  accounts,
+});
+
+export const expandDirectoryFail = error => ({
+  type: DIRECTORY_EXPAND_FAIL,
+  error,
+});
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..d06de20a2
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/domain_blocks.js
@@ -0,0 +1,166 @@
+import api, { getLinks } from '../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 const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST';
+export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS';
+export const DOMAIN_BLOCKS_EXPAND_FAIL    = 'DOMAIN_BLOCKS_EXPAND_FAIL';
+
+export function blockDomain(domain) {
+  return (dispatch, getState) => {
+    dispatch(blockDomainRequest(domain));
+
+    api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
+      const at_domain = '@' + domain;
+      const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
+
+      dispatch(blockDomainSuccess(domain, accounts));
+    }).catch(err => {
+      dispatch(blockDomainFail(domain, err));
+    });
+  };
+}
+
+export function blockDomainRequest(domain) {
+  return {
+    type: DOMAIN_BLOCK_REQUEST,
+    domain,
+  };
+}
+
+export function blockDomainSuccess(domain, accounts) {
+  return {
+    type: DOMAIN_BLOCK_SUCCESS,
+    domain,
+    accounts,
+  };
+}
+
+export function blockDomainFail(domain, error) {
+  return {
+    type: DOMAIN_BLOCK_FAIL,
+    domain,
+    error,
+  };
+}
+
+export function unblockDomain(domain) {
+  return (dispatch, getState) => {
+    dispatch(unblockDomainRequest(domain));
+
+    api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
+      const at_domain = '@' + domain;
+      const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
+      dispatch(unblockDomainSuccess(domain, accounts));
+    }).catch(err => {
+      dispatch(unblockDomainFail(domain, err));
+    });
+  };
+}
+
+export function unblockDomainRequest(domain) {
+  return {
+    type: DOMAIN_UNBLOCK_REQUEST,
+    domain,
+  };
+}
+
+export function unblockDomainSuccess(domain, accounts) {
+  return {
+    type: DOMAIN_UNBLOCK_SUCCESS,
+    domain,
+    accounts,
+  };
+}
+
+export function unblockDomainFail(domain, error) {
+  return {
+    type: DOMAIN_UNBLOCK_FAIL,
+    domain,
+    error,
+  };
+}
+
+export function fetchDomainBlocks() {
+  return (dispatch, getState) => {
+    dispatch(fetchDomainBlocksRequest());
+
+    api(getState).get('/api/v1/domain_blocks').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,
+  };
+}
+
+export function expandDomainBlocks() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['domain_lists', 'blocks', 'next']);
+
+    if (!url) {
+      return;
+    }
+
+    dispatch(expandDomainBlocksRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null));
+    }).catch(err => {
+      dispatch(expandDomainBlocksFail(err));
+    });
+  };
+}
+
+export function expandDomainBlocksRequest() {
+  return {
+    type: DOMAIN_BLOCKS_EXPAND_REQUEST,
+  };
+}
+
+export function expandDomainBlocksSuccess(domains, next) {
+  return {
+    type: DOMAIN_BLOCKS_EXPAND_SUCCESS,
+    domains,
+    next,
+  };
+}
+
+export function expandDomainBlocksFail(error) {
+  return {
+    type: DOMAIN_BLOCKS_EXPAND_FAIL,
+    error,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.js b/app/javascript/flavours/glitch/actions/dropdown_menu.js
new file mode 100644
index 000000000..023151d4b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/dropdown_menu.js
@@ -0,0 +1,10 @@
+export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
+export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
+
+export function openDropdownMenu(id, keyboard, scroll_key) {
+  return { type: DROPDOWN_MENU_OPEN, id, keyboard, scroll_key };
+}
+
+export function closeDropdownMenu(id) {
+  return { type: DROPDOWN_MENU_CLOSE, id };
+}
diff --git a/app/javascript/flavours/glitch/actions/emojis.js b/app/javascript/flavours/glitch/actions/emojis.js
new file mode 100644
index 000000000..3b5d53996
--- /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..7388e0c58
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/favourites.js
@@ -0,0 +1,93 @@
+import api, { getLinks } from '../api';
+import { importFetchedStatuses } from './importer';
+
+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(importFetchedStatuses(response.data));
+      dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchFavouritedStatusesFail(error));
+    });
+  };
+}
+
+export function fetchFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_REQUEST,
+    skipLoading: true,
+  };
+}
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next,
+    skipLoading: true,
+  };
+}
+
+export function fetchFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_FAIL,
+    error,
+    skipLoading: true,
+  };
+}
+
+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(importFetchedStatuses(response.data));
+      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/featured_tags.js b/app/javascript/flavours/glitch/actions/featured_tags.js
new file mode 100644
index 000000000..18bb61539
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/featured_tags.js
@@ -0,0 +1,34 @@
+import api from '../api';
+
+export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST';
+export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS';
+export const FEATURED_TAGS_FETCH_FAIL    = 'FEATURED_TAGS_FETCH_FAIL';
+
+export const fetchFeaturedTags = (id) => (dispatch, getState) => {
+  if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) {
+    return;
+  }
+
+  dispatch(fetchFeaturedTagsRequest(id));
+
+  api(getState).get(`/api/v1/accounts/${id}/featured_tags`)
+    .then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data)))
+    .catch(err => dispatch(fetchFeaturedTagsFail(id, err)));
+};
+
+export const fetchFeaturedTagsRequest = (id) => ({
+  type: FEATURED_TAGS_FETCH_REQUEST,
+  id,
+});
+
+export const fetchFeaturedTagsSuccess = (id, tags) => ({
+  type: FEATURED_TAGS_FETCH_SUCCESS,
+  id,
+  tags,
+});
+
+export const fetchFeaturedTagsFail = (id, error) => ({
+  type: FEATURED_TAGS_FETCH_FAIL,
+  id,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/filters.js b/app/javascript/flavours/glitch/actions/filters.js
new file mode 100644
index 000000000..e9c609fc8
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/filters.js
@@ -0,0 +1,93 @@
+import api from '../api';
+import { openModal } from './modal';
+
+export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
+export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
+export const FILTERS_FETCH_FAIL    = 'FILTERS_FETCH_FAIL';
+
+export const FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST';
+export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS';
+export const FILTERS_STATUS_CREATE_FAIL    = 'FILTERS_STATUS_CREATE_FAIL';
+
+export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
+export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
+export const FILTERS_CREATE_FAIL    = 'FILTERS_CREATE_FAIL';
+
+export const initAddFilter = (status, { contextType }) => dispatch =>
+  dispatch(openModal('FILTER', {
+    statusId: status?.get('id'),
+    contextType: contextType,
+  }));
+
+export const fetchFilters = () => (dispatch, getState) => {
+  dispatch({
+    type: FILTERS_FETCH_REQUEST,
+    skipLoading: true,
+  });
+
+  api(getState)
+    .get('/api/v2/filters')
+    .then(({ data }) => dispatch({
+      type: FILTERS_FETCH_SUCCESS,
+      filters: data,
+      skipLoading: true,
+    }))
+    .catch(err => dispatch({
+      type: FILTERS_FETCH_FAIL,
+      err,
+      skipLoading: true,
+      skipAlert: true,
+    }));
+};
+
+export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => {
+  dispatch(createFilterStatusRequest());
+
+  api(getState).post(`/api/v2/filters/${params.filter_id}/statuses`, params).then(response => {
+    dispatch(createFilterStatusSuccess(response.data));
+    if (onSuccess) onSuccess();
+  }).catch(error => {
+    dispatch(createFilterStatusFail(error));
+    if (onFail) onFail();
+  });
+};
+
+export const createFilterStatusRequest = () => ({
+  type: FILTERS_STATUS_CREATE_REQUEST,
+});
+
+export const createFilterStatusSuccess = filter_status => ({
+  type: FILTERS_STATUS_CREATE_SUCCESS,
+  filter_status,
+});
+
+export const createFilterStatusFail = error => ({
+  type: FILTERS_STATUS_CREATE_FAIL,
+  error,
+});
+
+export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => {
+  dispatch(createFilterRequest());
+
+  api(getState).post('/api/v2/filters', params).then(response => {
+    dispatch(createFilterSuccess(response.data));
+    if (onSuccess) onSuccess(response.data);
+  }).catch(error => {
+    dispatch(createFilterFail(error));
+    if (onFail) onFail();
+  });
+};
+
+export const createFilterRequest = () => ({
+  type: FILTERS_CREATE_REQUEST,
+});
+
+export const createFilterSuccess = filter => ({
+  type: FILTERS_CREATE_SUCCESS,
+  filter,
+});
+
+export const createFilterFail = error => ({
+  type: FILTERS_CREATE_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..a8645410c
--- /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/history.js b/app/javascript/flavours/glitch/actions/history.js
new file mode 100644
index 000000000..c142aaf61
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/history.js
@@ -0,0 +1,37 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+
+export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST';
+export const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS';
+export const HISTORY_FETCH_FAIL    = 'HISTORY_FETCH_FAIL';
+
+export const fetchHistory = statusId => (dispatch, getState) => {
+  const loading = getState().getIn(['history', statusId, 'loading']);
+
+  if (loading) {
+    return;
+  }
+
+  dispatch(fetchHistoryRequest(statusId));
+
+  api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => {
+    dispatch(importFetchedAccounts(data.map(x => x.account)));
+    dispatch(fetchHistorySuccess(statusId, data));
+  }).catch(error => dispatch(fetchHistoryFail(error)));
+};
+
+export const fetchHistoryRequest = statusId => ({
+  type: HISTORY_FETCH_REQUEST,
+  statusId,
+});
+
+export const fetchHistorySuccess = (statusId, history) => ({
+  type: HISTORY_FETCH_SUCCESS,
+  statusId,
+  history,
+});
+
+export const fetchHistoryFail = error => ({
+  type: HISTORY_FETCH_FAIL,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/identity_proofs.js b/app/javascript/flavours/glitch/actions/identity_proofs.js
new file mode 100644
index 000000000..103983956
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/identity_proofs.js
@@ -0,0 +1,31 @@
+import api from '../api';
+
+export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST';
+export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS';
+export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL    = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL';
+
+export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => {
+  dispatch(fetchAccountIdentityProofsRequest(accountId));
+
+  api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`)
+    .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data)))
+    .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err)));
+};
+
+export const fetchAccountIdentityProofsRequest = id => ({
+  type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
+  id,
+});
+
+export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({
+  type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
+  accountId,
+  identity_proofs,
+});
+
+export const fetchAccountIdentityProofsFail = (accountId, err) => ({
+  type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
+  accountId,
+  err,
+  skipNotFound: true,
+});
diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js
new file mode 100644
index 000000000..94d133b5f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/importer/index.js
@@ -0,0 +1,101 @@
+import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer';
+
+export const ACCOUNT_IMPORT  = 'ACCOUNT_IMPORT';
+export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
+export const STATUS_IMPORT   = 'STATUS_IMPORT';
+export const STATUSES_IMPORT = 'STATUSES_IMPORT';
+export const POLLS_IMPORT    = 'POLLS_IMPORT';
+export const FILTERS_IMPORT  = 'FILTERS_IMPORT';
+
+function pushUnique(array, object) {
+  if (array.every(element => element.id !== object.id)) {
+    array.push(object);
+  }
+}
+
+export function importAccount(account) {
+  return { type: ACCOUNT_IMPORT, account };
+}
+
+export function importAccounts(accounts) {
+  return { type: ACCOUNTS_IMPORT, accounts };
+}
+
+export function importStatus(status) {
+  return { type: STATUS_IMPORT, status };
+}
+
+export function importStatuses(statuses) {
+  return { type: STATUSES_IMPORT, statuses };
+}
+
+export function importFilters(filters) {
+  return { type: FILTERS_IMPORT, filters };
+}
+
+export function importPolls(polls) {
+  return { type: POLLS_IMPORT, polls };
+}
+
+export function importFetchedAccount(account) {
+  return importFetchedAccounts([account]);
+}
+
+export function importFetchedAccounts(accounts) {
+  const normalAccounts = [];
+
+  function processAccount(account) {
+    pushUnique(normalAccounts, normalizeAccount(account));
+
+    if (account.moved) {
+      processAccount(account.moved);
+    }
+  }
+
+  accounts.forEach(processAccount);
+
+  return importAccounts(normalAccounts);
+}
+
+export function importFetchedStatus(status) {
+  return importFetchedStatuses([status]);
+}
+
+export function importFetchedStatuses(statuses) {
+  return (dispatch, getState) => {
+    const accounts = [];
+    const normalStatuses = [];
+    const polls = [];
+    const filters = [];
+
+    function processStatus(status) {
+      pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings')));
+      pushUnique(accounts, status.account);
+
+      if (status.filtered) {
+        status.filtered.forEach(result => pushUnique(filters, result.filter));
+      }
+
+      if (status.reblog && status.reblog.id) {
+        processStatus(status.reblog);
+      }
+
+      if (status.poll && status.poll.id) {
+        pushUnique(polls, normalizePoll(status.poll));
+      }
+    }
+
+    statuses.forEach(processStatus);
+
+    dispatch(importPolls(polls));
+    dispatch(importFetchedAccounts(accounts));
+    dispatch(importStatuses(normalStatuses));
+    dispatch(importFilters(filters));
+  };
+}
+
+export function importFetchedPoll(poll) {
+  return dispatch => {
+    dispatch(importPolls([normalizePoll(poll)]));
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
new file mode 100644
index 000000000..1c9f524e4
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -0,0 +1,111 @@
+import escapeTextContentForBrowser from 'escape-html';
+import emojify from 'flavours/glitch/features/emoji/emoji';
+import { unescapeHTML } from 'flavours/glitch/utils/html';
+import { autoHideCW } from 'flavours/glitch/utils/content_warning';
+
+const domParser = new DOMParser();
+
+const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
+  obj[`:${emoji.shortcode}:`] = emoji;
+  return obj;
+}, {});
+
+export function searchTextFromRawStatus (status) {
+  const spoilerText   = status.spoiler_text || '';
+  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+}
+
+export function normalizeAccount(account) {
+  account = { ...account };
+
+  const emojiMap = makeEmojiMap(account);
+  const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
+
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
+  account.note_emojified = emojify(account.note, emojiMap);
+  account.note_plain = unescapeHTML(account.note);
+
+  if (account.fields) {
+    account.fields = account.fields.map(pair => ({
+      ...pair,
+      name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
+      value_emojified: emojify(pair.value, emojiMap),
+      value_plain: unescapeHTML(pair.value),
+    }));
+  }
+
+  if (account.moved) {
+    account.moved = account.moved.id;
+  }
+
+  return account;
+}
+
+export function normalizeFilterResult(result) {
+  const normalResult = { ...result };
+
+  normalResult.filter = normalResult.filter.id;
+
+  return normalResult;
+}
+
+export function normalizeStatus(status, normalOldStatus, settings) {
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
+
+  if (status.reblog && status.reblog.id) {
+    normalStatus.reblog = status.reblog.id;
+  }
+
+  if (status.poll && status.poll.id) {
+    normalStatus.poll = status.poll.id;
+  }
+
+  if (status.filtered) {
+    normalStatus.filtered = status.filtered.map(normalizeFilterResult);
+  }
+
+  // Only calculate these values when status first encountered and
+  // when the underlying values change. Otherwise keep the ones
+  // already in the reducer
+  if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) {
+    normalStatus.search_index = normalOldStatus.get('search_index');
+    normalStatus.contentHtml = normalOldStatus.get('contentHtml');
+    normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
+    normalStatus.hidden = normalOldStatus.get('hidden');
+  } else {
+    const spoilerText   = normalStatus.spoiler_text || '';
+    const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+    const emojiMap      = makeEmojiMap(normalStatus);
+
+    normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+    normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
+    normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
+    normalStatus.hidden       = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
+  }
+
+  return normalStatus;
+}
+
+export function normalizePoll(poll) {
+  const normalPoll = { ...poll };
+  const emojiMap = makeEmojiMap(normalPoll);
+
+  normalPoll.options = poll.options.map((option, index) => ({
+    ...option,
+    voted: poll.own_votes && poll.own_votes.includes(index),
+    title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
+  }));
+
+  return normalPoll;
+}
+
+export function normalizeAnnouncement(announcement) {
+  const normalAnnouncement = { ...announcement };
+  const emojiMap = makeEmojiMap(normalAnnouncement);
+
+  normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
+
+  return normalAnnouncement;
+}
diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js
new file mode 100644
index 000000000..c7b552a65
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/interactions.js
@@ -0,0 +1,394 @@
+import api from '../api';
+import { importFetchedAccounts, importFetchedStatus } from './importer';
+
+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 const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST';
+export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS';
+export const BOOKMARK_FAIL    = 'BOOKMARKED_FAIL';
+
+export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
+export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
+export const UNBOOKMARK_FAIL    = 'UNBOOKMARKED_FAIL';
+
+export function reblog(status, visibility) {
+  return function (dispatch, getState) {
+    dispatch(reblogRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).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(importFetchedStatus(response.data.reblog));
+      dispatch(reblogSuccess(status));
+    }).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(importFetchedStatus(response.data));
+      dispatch(unreblogSuccess(status));
+    }).catch(error => {
+      dispatch(unreblogFail(status, error));
+    });
+  };
+}
+
+export function reblogRequest(status) {
+  return {
+    type: REBLOG_REQUEST,
+    status: status,
+  };
+}
+
+export function reblogSuccess(status) {
+  return {
+    type: REBLOG_SUCCESS,
+    status: status,
+  };
+}
+
+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) {
+  return {
+    type: UNREBLOG_SUCCESS,
+    status: status,
+  };
+}
+
+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(importFetchedStatus(response.data));
+      dispatch(favouriteSuccess(status));
+    }).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(importFetchedStatus(response.data));
+      dispatch(unfavouriteSuccess(status));
+    }).catch(error => {
+      dispatch(unfavouriteFail(status, error));
+    });
+  };
+}
+
+export function favouriteRequest(status) {
+  return {
+    type: FAVOURITE_REQUEST,
+    status: status,
+  };
+}
+
+export function favouriteSuccess(status) {
+  return {
+    type: FAVOURITE_SUCCESS,
+    status: status,
+  };
+}
+
+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) {
+  return {
+    type: UNFAVOURITE_SUCCESS,
+    status: status,
+  };
+}
+
+export function unfavouriteFail(status, error) {
+  return {
+    type: UNFAVOURITE_FAIL,
+    status: status,
+    error: error,
+  };
+}
+
+export function bookmark(status) {
+  return function (dispatch, getState) {
+    dispatch(bookmarkRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) {
+      dispatch(importFetchedStatus(response.data));
+      dispatch(bookmarkSuccess(status));
+    }).catch(function (error) {
+      dispatch(bookmarkFail(status, error));
+    });
+  };
+}
+
+export function unbookmark(status) {
+  return (dispatch, getState) => {
+    dispatch(unbookmarkRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
+      dispatch(importFetchedStatus(response.data));
+      dispatch(unbookmarkSuccess(status));
+    }).catch(error => {
+      dispatch(unbookmarkFail(status, error));
+    });
+  };
+}
+
+export function bookmarkRequest(status) {
+  return {
+    type: BOOKMARK_REQUEST,
+    status: status,
+  };
+}
+
+export function bookmarkSuccess(status) {
+  return {
+    type: BOOKMARK_SUCCESS,
+    status: status,
+  };
+}
+
+export function bookmarkFail(status, error) {
+  return {
+    type: BOOKMARK_FAIL,
+    status: status,
+    error: error,
+  };
+}
+
+export function unbookmarkRequest(status) {
+  return {
+    type: UNBOOKMARK_REQUEST,
+    status: status,
+  };
+}
+
+export function unbookmarkSuccess(status) {
+  return {
+    type: UNBOOKMARK_SUCCESS,
+    status: status,
+  };
+}
+
+export function unbookmarkFail(status, error) {
+  return {
+    type: UNBOOKMARK_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(importFetchedAccounts(response.data));
+      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(importFetchedAccounts(response.data));
+      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(importFetchedStatus(response.data));
+      dispatch(pinSuccess(status));
+    }).catch(error => {
+      dispatch(pinFail(status, error));
+    });
+  };
+}
+
+export function pinRequest(status) {
+  return {
+    type: PIN_REQUEST,
+    status,
+  };
+}
+
+export function pinSuccess(status) {
+  return {
+    type: PIN_SUCCESS,
+    status,
+  };
+}
+
+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(importFetchedStatus(response.data));
+      dispatch(unpinSuccess(status));
+    }).catch(error => {
+      dispatch(unpinFail(status, error));
+    });
+  };
+}
+
+export function unpinRequest(status) {
+  return {
+    type: UNPIN_REQUEST,
+    status,
+  };
+}
+
+export function unpinSuccess(status) {
+  return {
+    type: UNPIN_SUCCESS,
+    status,
+  };
+}
+
+export function unpinFail(status, error) {
+  return {
+    type: UNPIN_FAIL,
+    status,
+    error,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/languages.js b/app/javascript/flavours/glitch/actions/languages.js
new file mode 100644
index 000000000..ad186ba0c
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/languages.js
@@ -0,0 +1,12 @@
+import { saveSettings } from './settings';
+
+export const LANGUAGE_USE = 'LANGUAGE_USE';
+
+export const useLanguage = language => dispatch => {
+  dispatch({
+    type: LANGUAGE_USE,
+    language,
+  });
+
+  dispatch(saveSettings());
+};
diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js
new file mode 100644
index 000000000..5ab922436
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/lists.js
@@ -0,0 +1,372 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+import { showAlertForError } from './alerts';
+
+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 LIST_ADDER_RESET = 'LIST_ADDER_RESET';
+export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
+
+export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
+export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
+export const LIST_ADDER_LISTS_FETCH_FAIL    = 'LIST_ADDER_LISTS_FETCH_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, replies_policy) => (dispatch, getState) => {
+  dispatch(updateListRequest(id));
+
+  api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).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(importFetchedAccounts(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(importFetchedAccounts(data));
+    dispatch(fetchListSuggestionsReady(q, data));
+  }).catch(error => dispatch(showAlertForError(error)));
+};
+
+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,
+});
+
+export const resetListAdder = () => ({
+  type: LIST_ADDER_RESET,
+});
+
+export const setupListAdder = accountId => (dispatch, getState) => {
+  dispatch({
+    type: LIST_ADDER_SETUP,
+    account: getState().getIn(['accounts', accountId]),
+  });
+  dispatch(fetchLists());
+  dispatch(fetchAccountLists(accountId));
+};
+
+export const fetchAccountLists = accountId => (dispatch, getState) => {
+  dispatch(fetchAccountListsRequest(accountId));
+
+  api(getState).get(`/api/v1/accounts/${accountId}/lists`)
+    .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
+    .catch(err => dispatch(fetchAccountListsFail(accountId, err)));
+};
+
+export const fetchAccountListsRequest = id => ({
+  type:LIST_ADDER_LISTS_FETCH_REQUEST,
+  id,
+});
+
+export const fetchAccountListsSuccess = (id, lists) => ({
+  type: LIST_ADDER_LISTS_FETCH_SUCCESS,
+  id,
+  lists,
+});
+
+export const fetchAccountListsFail = (id, err) => ({
+  type: LIST_ADDER_LISTS_FETCH_FAIL,
+  id,
+  err,
+});
+
+export const addToListAdder = listId => (dispatch, getState) => {
+  dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
+};
+
+export const removeFromListAdder = listId => (dispatch, getState) => {
+  dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
+};
+
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..adf7fd2ab
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/local_settings.js
@@ -0,0 +1,77 @@
+import { expandSpoilers, disableSwiping } from 'flavours/glitch/initial_state';
+import { openModal } from './modal';
+
+export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
+export const LOCAL_SETTING_DELETE = 'LOCAL_SETTING_DELETE';
+
+export function checkDeprecatedLocalSettings() {
+  return (dispatch, getState) => {
+    const local_auto_unfold = getState().getIn(['local_settings', 'content_warnings', 'auto_unfold']);
+    const local_swipe_to_change_columns = getState().getIn(['local_settings', 'swipe_to_change_columns']);
+    let changed_settings = [];
+
+    if (local_auto_unfold !== null && local_auto_unfold !== undefined) {
+      if (local_auto_unfold === expandSpoilers) {
+        dispatch(deleteLocalSetting(['content_warnings', 'auto_unfold']));
+      } else {
+        changed_settings.push('user_setting_expand_spoilers');
+      }
+    }
+
+    if (local_swipe_to_change_columns !== null && local_swipe_to_change_columns !== undefined) {
+      if (local_swipe_to_change_columns === !disableSwiping) {
+        dispatch(deleteLocalSetting(['swipe_to_change_columns']));
+      } else {
+        changed_settings.push('user_setting_disable_swiping');
+      }
+    }
+
+    if (changed_settings.length > 0) {
+      dispatch(openModal('DEPRECATED_SETTINGS', {
+        settings: changed_settings,
+        onConfirm: () => dispatch(clearDeprecatedLocalSettings()),
+      }));
+    }
+  };
+}
+
+export function clearDeprecatedLocalSettings() {
+  return (dispatch) => {
+    dispatch(deleteLocalSetting(['content_warnings', 'auto_unfold']));
+    dispatch(deleteLocalSetting(['swipe_to_change_columns']));
+  };
+}
+
+export function changeLocalSetting(key, value) {
+  return dispatch => {
+    dispatch({
+      type: LOCAL_SETTING_CHANGE,
+      key,
+      value,
+    });
+
+    dispatch(saveLocalSettings());
+  };
+}
+
+export function deleteLocalSetting(key) {
+  return dispatch => {
+    dispatch({
+      type: LOCAL_SETTING_DELETE,
+      key,
+    });
+
+    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/markers.js b/app/javascript/flavours/glitch/actions/markers.js
new file mode 100644
index 000000000..dfd701cbb
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -0,0 +1,150 @@
+import api from '../api';
+import { debounce } from 'lodash';
+import compareId from '../compare_id';
+import { List as ImmutableList } from 'immutable';
+
+export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
+export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
+export const MARKERS_FETCH_FAIL    = 'MARKERS_FETCH_FAIL';
+export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
+
+export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
+  const accessToken = getState().getIn(['meta', 'access_token'], '');
+  const params      = _buildParams(getState());
+
+  if (Object.keys(params).length === 0 || accessToken === '') {
+    return;
+  }
+
+  // The Fetch API allows us to perform requests that will be carried out
+  // after the page closes. But that only works if the `keepalive` attribute
+  // is supported.
+  if (window.fetch && 'keepalive' in new Request('')) {
+    fetch('/api/v1/markers', {
+      keepalive: true,
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${accessToken}`,
+      },
+      body: JSON.stringify(params),
+    });
+
+    return;
+  } else if (navigator && navigator.sendBeacon) {
+    // Failing that, we can use sendBeacon, but we have to encode the data as
+    // FormData for DoorKeeper to recognize the token.
+    const formData = new FormData();
+
+    formData.append('bearer_token', accessToken);
+
+    for (const [id, value] of Object.entries(params)) {
+      formData.append(`${id}[last_read_id]`, value.last_read_id);
+    }
+
+    if (navigator.sendBeacon('/api/v1/markers', formData)) {
+      return;
+    }
+  }
+
+  // If neither Fetch nor sendBeacon worked, try to perform a synchronous
+  // request.
+  try {
+    const client = new XMLHttpRequest();
+
+    client.open('POST', '/api/v1/markers', false);
+    client.setRequestHeader('Content-Type', 'application/json');
+    client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
+    client.SUBMIT(JSON.stringify(params));
+  } catch (e) {
+    // Do not make the BeforeUnload handler error out
+  }
+};
+
+const _buildParams = (state) => {
+  const params = {};
+
+  const lastHomeId         = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null);
+  const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
+
+  if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
+    params.home = {
+      last_read_id: lastHomeId,
+    };
+  }
+
+  if (lastNotificationId && lastNotificationId !== '0' && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
+    params.notifications = {
+      last_read_id: lastNotificationId,
+    };
+  }
+
+  return params;
+};
+
+const debouncedSubmitMarkers = debounce((dispatch, getState) => {
+  const accessToken = getState().getIn(['meta', 'access_token'], '');
+  const params      = _buildParams(getState());
+
+  if (Object.keys(params).length === 0 || accessToken === '') {
+    return;
+  }
+
+  api(getState).post('/api/v1/markers', params).then(() => {
+    dispatch(submitMarkersSuccess(params));
+  }).catch(() => {});
+}, 300000, { leading: true, trailing: true });
+
+export function submitMarkersSuccess({ home, notifications }) {
+  return {
+    type: MARKERS_SUBMIT_SUCCESS,
+    home: (home || {}).last_read_id,
+    notifications: (notifications || {}).last_read_id,
+  };
+}
+
+export function submitMarkers(params = {}) {
+  const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+
+  if (params.immediate === true) {
+    debouncedSubmitMarkers.flush();
+  }
+
+  return result;
+}
+
+export const fetchMarkers = () => (dispatch, getState) => {
+  const params = { timeline: ['notifications'] };
+
+  dispatch(fetchMarkersRequest());
+
+  api(getState).get('/api/v1/markers', { params }).then(response => {
+    dispatch(fetchMarkersSuccess(response.data));
+  }).catch(error => {
+    dispatch(fetchMarkersFail(error));
+  });
+};
+
+export function fetchMarkersRequest() {
+  return {
+    type: MARKERS_FETCH_REQUEST,
+    skipLoading: true,
+  };
+}
+
+export function fetchMarkersSuccess(markers) {
+  return {
+    type: MARKERS_FETCH_SUCCESS,
+    markers,
+    skipLoading: true,
+  };
+}
+
+export function fetchMarkersFail(error) {
+  return {
+    type: MARKERS_FETCH_FAIL,
+    error,
+    skipLoading: true,
+    skipAlert: true,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js
new file mode 100644
index 000000000..ef2ae0e4c
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/modal.js
@@ -0,0 +1,18 @@
+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(type, options = { ignoreFocus: false }) {
+  return {
+    type: MODAL_CLOSE,
+    modalType: type,
+    ignoreFocus: options.ignoreFocus,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js
new file mode 100644
index 000000000..aa47d1464
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -0,0 +1,116 @@
+import api, { getLinks } from '../api';
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
+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 const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
+
+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(importFetchedAccounts(response.data));
+      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(importFetchedAccounts(response.data));
+      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 });
+  };
+}
+
+export function changeMuteDuration(duration) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_CHANGE_DURATION,
+      duration,
+    });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
new file mode 100644
index 000000000..989bc4144
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -0,0 +1,396 @@
+import api, { getLinks } from '../api';
+import IntlMessageFormat from 'intl-messageformat';
+import { fetchFollowRequests, fetchRelationships } from './accounts';
+import {
+  importFetchedAccount,
+  importFetchedAccounts,
+  importFetchedStatus,
+  importFetchedStatuses,
+} from './importer';
+import { submitMarkers } from './markers';
+import { saveSettings } from './settings';
+import { defineMessages } from 'react-intl';
+import { List as ImmutableList } from 'immutable';
+import { unescapeHTML } from 'flavours/glitch/utils/html';
+import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
+import compareId from 'flavours/glitch/compare_id';
+import { requestNotificationPermission } from 'flavours/glitch/utils/notifications';
+
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
+
+// 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_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
+
+export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
+
+export const NOTIFICATIONS_CLEAR        = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP   = 'NOTIFICATIONS_SCROLL_TOP';
+export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
+
+export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT';
+export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
+
+export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
+
+export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
+
+export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
+
+defineMessages({
+  mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+});
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+  const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
+
+  if (accountIds > 0) {
+    dispatch(fetchRelationships(accountIds));
+  }
+};
+
+export const loadPending = () => ({
+  type: NOTIFICATIONS_LOAD_PENDING,
+});
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+  return (dispatch, getState) => {
+    const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
+    const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
+    const showAlert    = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+    const playSound    = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+    let filtered = false;
+
+    if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
+      const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
+
+      if (filters.some(result => result.filter.filter_action === 'hide')) {
+        return;
+      }
+
+      filtered = filters.length > 0;
+    }
+
+    if (['follow_request'].includes(notification.type)) {
+      dispatch(fetchFollowRequests());
+    }
+
+    dispatch(submitMarkers());
+
+    if (showInColumn) {
+      dispatch(importFetchedAccount(notification.account));
+
+      if (notification.status) {
+        dispatch(importFetchedStatus(notification.status));
+      }
+
+      if (notification.report) {
+        dispatch(importFetchedAccount(notification.report.target_account));
+      }
+
+      dispatch({
+        type: NOTIFICATIONS_UPDATE,
+        notification,
+        usePendingItems: preferPendingItems,
+        meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
+      });
+
+      fetchRelatedRelationships(dispatch, [notification]);
+    } else if (playSound && !filtered) {
+      dispatch({
+        type: NOTIFICATIONS_UPDATE_NOOP,
+        meta: { sound: 'boop' },
+      });
+    }
+
+    // Desktop notifications
+    if (typeof window.Notification !== 'undefined' && showAlert && !filtered) {
+      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();
+
+
+const excludeTypesFromFilter = filter => {
+  const allTypes = ImmutableList([
+    'follow',
+    'follow_request',
+    'favourite',
+    'reblog',
+    'mention',
+    'poll',
+    'status',
+    'update',
+    'admin.sign_up',
+    'admin.report',
+  ]);
+
+  return allTypes.filterNot(item => item === filter).toJS();
+};
+
+const noOp = () => {};
+
+let expandNotificationsController = new AbortController();
+
+export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) {
+  return (dispatch, getState) => {
+    const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
+    const notifications = getState().get('notifications');
+    const isLoadingMore = !!maxId;
+
+    if (notifications.get('isLoading')) {
+      if (forceLoad) {
+        expandNotificationsController.abort();
+        expandNotificationsController = new AbortController();
+      } else {
+        done();
+        return;
+      }
+    }
+
+    const params = {
+      max_id: maxId,
+      exclude_types: activeFilter === 'all'
+        ? excludeTypesFromSettings(getState())
+        : excludeTypesFromFilter(activeFilter),
+    };
+
+    if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
+      const a = notifications.getIn(['pendingItems', 0, 'id']);
+      const b = notifications.getIn(['items', 0, 'id']);
+
+      if (a && b && compareId(a, b) > 0) {
+        params.since_id = a;
+      } else {
+        params.since_id = b || a;
+      }
+    }
+
+    const isLoadingRecent = !!params.since_id;
+
+    dispatch(expandNotificationsRequest(isLoadingMore));
+
+    api(getState).get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedAccounts(response.data.map(item => item.account)));
+      dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
+      dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
+
+      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
+      fetchRelatedRelationships(dispatch, response.data);
+      dispatch(submitMarkers());
+    }).catch(error => {
+      dispatch(expandNotificationsFail(error, isLoadingMore));
+    }).finally(() => {
+      done();
+    });
+  };
+}
+
+export function expandNotificationsRequest(isLoadingMore) {
+  return {
+    type: NOTIFICATIONS_EXPAND_REQUEST,
+    skipLoading: !isLoadingMore,
+  };
+}
+
+export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
+  return {
+    type: NOTIFICATIONS_EXPAND_SUCCESS,
+    notifications,
+    next,
+    isLoadingRecent: isLoadingRecent,
+    usePendingItems,
+    skipLoading: !isLoadingMore,
+  };
+}
+
+export function expandNotificationsFail(error, isLoadingMore) {
+  return {
+    type: NOTIFICATIONS_EXPAND_FAIL,
+    error,
+    skipLoading: !isLoadingMore,
+    skipAlert: !isLoadingMore || error.name === 'AbortError',
+  };
+}
+
+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,
+  };
+}
+
+export function mountNotifications() {
+  return {
+    type: NOTIFICATIONS_MOUNT,
+  };
+}
+
+export function unmountNotifications() {
+  return {
+    type: NOTIFICATIONS_UNMOUNT,
+  };
+}
+
+export function notificationsSetVisibility(visibility) {
+  return {
+    type: NOTIFICATIONS_SET_VISIBILITY,
+    visibility: visibility,
+  };
+}
+
+export function setFilter (filterType) {
+  return dispatch => {
+    dispatch({
+      type: NOTIFICATIONS_FILTER_SET,
+      path: ['notifications', 'quickFilter', 'active'],
+      value: filterType,
+    });
+    dispatch(expandNotifications({ forceLoad: true }));
+    dispatch(saveSettings());
+  };
+}
+
+export function markNotificationsAsRead() {
+  return {
+    type: NOTIFICATIONS_MARK_AS_READ,
+  };
+}
+
+// Browser support
+export function setupBrowserNotifications() {
+  return dispatch => {
+    dispatch(setBrowserSupport('Notification' in window));
+    if ('Notification' in window) {
+      dispatch(setBrowserPermission(Notification.permission));
+    }
+
+    if ('Notification' in window && 'permissions' in navigator) {
+      navigator.permissions.query({ name: 'notifications' }).then((status) => {
+        status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
+      }).catch(console.warn);
+    }
+  };
+}
+
+export function requestBrowserPermission(callback = noOp) {
+  return dispatch => {
+    requestNotificationPermission((permission) => {
+      dispatch(setBrowserPermission(permission));
+      callback(permission);
+    });
+  };
+}
+
+export function setBrowserSupport (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setBrowserPermission (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
+    value,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/onboarding.js b/app/javascript/flavours/glitch/actions/onboarding.js
new file mode 100644
index 000000000..5038b7eb6
--- /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/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js
new file mode 100644
index 000000000..33d8d57d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js
@@ -0,0 +1,45 @@
+// @ts-check
+
+export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
+export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
+
+/**
+ * @typedef MediaProps
+ * @property {string} src
+ * @property {boolean} muted
+ * @property {number} volume
+ * @property {number} currentTime
+ * @property {string} poster
+ * @property {string} backgroundColor
+ * @property {string} foregroundColor
+ * @property {string} accentColor
+ */
+
+/**
+ * @param {string} statusId
+ * @param {string} accountId
+ * @param {string} playerType
+ * @param {MediaProps} props
+ * @return {object}
+ */
+export const deployPictureInPicture = (statusId, accountId, playerType, props) => {
+  return (dispatch, getState) => {
+    // Do not open a player for a toot that does not exist
+    if (getState().hasIn(['statuses', statusId])) {
+      dispatch({
+        type: PICTURE_IN_PICTURE_DEPLOY,
+        statusId,
+        accountId,
+        playerType,
+        props,
+      });
+    }
+  };
+};
+
+/*
+ * @return {object}
+ */
+export const removePictureInPicture = () => ({
+  type: PICTURE_IN_PICTURE_REMOVE,
+});
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..d8c0a1373
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/pin_statuses.js
@@ -0,0 +1,42 @@
+import api from '../api';
+import { importFetchedStatuses } from './importer';
+
+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/initial_state';
+
+export function fetchPinnedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchPinnedStatusesRequest());
+
+    api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+      dispatch(importFetchedStatuses(response.data));
+      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/polls.js b/app/javascript/flavours/glitch/actions/polls.js
new file mode 100644
index 000000000..8e8b82df5
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/polls.js
@@ -0,0 +1,60 @@
+import api from '../api';
+import { importFetchedPoll } from './importer';
+
+export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
+export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
+export const POLL_VOTE_FAIL    = 'POLL_VOTE_FAIL';
+
+export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
+export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
+export const POLL_FETCH_FAIL    = 'POLL_FETCH_FAIL';
+
+export const vote = (pollId, choices) => (dispatch, getState) => {
+  dispatch(voteRequest());
+
+  api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices })
+    .then(({ data }) => {
+      dispatch(importFetchedPoll(data));
+      dispatch(voteSuccess(data));
+    })
+    .catch(err => dispatch(voteFail(err)));
+};
+
+export const fetchPoll = pollId => (dispatch, getState) => {
+  dispatch(fetchPollRequest());
+
+  api(getState).get(`/api/v1/polls/${pollId}`)
+    .then(({ data }) => {
+      dispatch(importFetchedPoll(data));
+      dispatch(fetchPollSuccess(data));
+    })
+    .catch(err => dispatch(fetchPollFail(err)));
+};
+
+export const voteRequest = () => ({
+  type: POLL_VOTE_REQUEST,
+});
+
+export const voteSuccess = poll => ({
+  type: POLL_VOTE_SUCCESS,
+  poll,
+});
+
+export const voteFail = error => ({
+  type: POLL_VOTE_FAIL,
+  error,
+});
+
+export const fetchPollRequest = () => ({
+  type: POLL_FETCH_REQUEST,
+});
+
+export const fetchPollSuccess = poll => ({
+  type: POLL_FETCH_SUCCESS,
+  poll,
+});
+
+export const fetchPollFail = error => ({
+  type: POLL_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..9dcc4bd4b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/index.js
@@ -0,0 +1,17 @@
+import { setAlerts } from './setter';
+import { saveSettings } from './registerer';
+
+export function changeAlerts(path, value) {
+  return dispatch => {
+    dispatch(setAlerts(path, value));
+    dispatch(saveSettings());
+  };
+}
+
+export {
+  CLEAR_SUBSCRIPTION,
+  SET_BROWSER_SUPPORT,
+  SET_SUBSCRIPTION,
+  SET_ALERTS,
+} from './setter';
+export { register } from './registerer';
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..bc5634233
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
@@ -0,0 +1,139 @@
+import api from '../../api';
+import { pushNotificationsSetting } from '../../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 (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);
+          }
+
+          return getRegistration()
+            .then(getPushSubscription)
+            .then(unsubscribe);
+        })
+        .catch(console.warn);
+    } 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);
+      }
+    }).catch(console.warn);
+  };
+}
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..fbe5b3791
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/reports.js
@@ -0,0 +1,38 @@
+import api from '../api';
+import { openModal } from './modal';
+
+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 initReport = (account, status) => dispatch =>
+  dispatch(openModal('REPORT', {
+    accountId: account.get('id'),
+    statusId: status?.get('id'),
+  }));
+
+export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => {
+  dispatch(submitReportRequest());
+
+  api(getState).post('/api/v1/reports', params).then(response => {
+    dispatch(submitReportSuccess(response.data));
+    if (onSuccess) onSuccess();
+  }).catch(error => {
+    dispatch(submitReportFail(error));
+    if (onFail) onFail();
+  });
+};
+
+export const submitReportRequest = () => ({
+  type: REPORT_SUBMIT_REQUEST,
+});
+
+export const submitReportSuccess = report => ({
+  type: REPORT_SUBMIT_SUCCESS,
+  report,
+});
+
+export const submitReportFail = error => ({
+  type: REPORT_SUBMIT_FAIL,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js
new file mode 100644
index 000000000..0012808e5
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/search.js
@@ -0,0 +1,132 @@
+import api from '../api';
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts, importFetchedStatuses } from './importer';
+
+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 const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
+export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
+export const SEARCH_EXPAND_FAIL    = 'SEARCH_EXPAND_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']);
+    const signedIn = !!getState().getIn(['meta', 'me']);
+
+    if (value.length === 0) {
+      dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, ''));
+      return;
+    }
+
+    dispatch(fetchSearchRequest());
+
+    api(getState).get('/api/v2/search', {
+      params: {
+        q: value,
+        resolve: signedIn,
+        limit: 10,
+      },
+    }).then(response => {
+      if (response.data.accounts) {
+        dispatch(importFetchedAccounts(response.data.accounts));
+      }
+
+      if (response.data.statuses) {
+        dispatch(importFetchedStatuses(response.data.statuses));
+      }
+
+      dispatch(fetchSearchSuccess(response.data, value));
+      dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchSearchFail(error));
+    });
+  };
+}
+
+export function fetchSearchRequest() {
+  return {
+    type: SEARCH_FETCH_REQUEST,
+  };
+}
+
+export function fetchSearchSuccess(results, searchTerm) {
+  return {
+    type: SEARCH_FETCH_SUCCESS,
+    results,
+    searchTerm,
+  };
+}
+
+export function fetchSearchFail(error) {
+  return {
+    type: SEARCH_FETCH_FAIL,
+    error,
+  };
+}
+
+export const expandSearch = type => (dispatch, getState) => {
+  const value  = getState().getIn(['search', 'value']);
+  const offset = getState().getIn(['search', 'results', type]).size;
+
+  dispatch(expandSearchRequest());
+
+  api(getState).get('/api/v2/search', {
+    params: {
+      q: value,
+      type,
+      offset,
+    },
+  }).then(({ data }) => {
+    if (data.accounts) {
+      dispatch(importFetchedAccounts(data.accounts));
+    }
+
+    if (data.statuses) {
+      dispatch(importFetchedStatuses(data.statuses));
+    }
+
+    dispatch(expandSearchSuccess(data, value, type));
+    dispatch(fetchRelationships(data.accounts.map(item => item.id)));
+  }).catch(error => {
+    dispatch(expandSearchFail(error));
+  });
+};
+
+export const expandSearchRequest = () => ({
+  type: SEARCH_EXPAND_REQUEST,
+});
+
+export const expandSearchSuccess = (results, searchTerm, searchType) => ({
+  type: SEARCH_EXPAND_SUCCESS,
+  results,
+  searchTerm,
+  searchType,
+});
+
+export const expandSearchFail = error => ({
+  type: SEARCH_EXPAND_FAIL,
+  error,
+});
+
+export const showSearch = () => ({
+  type: SEARCH_SHOW,
+});
diff --git a/app/javascript/flavours/glitch/actions/server.js b/app/javascript/flavours/glitch/actions/server.js
new file mode 100644
index 000000000..091af0f0f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/server.js
@@ -0,0 +1,118 @@
+import api from '../api';
+import { importFetchedAccount } from './importer';
+
+export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
+export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
+export const SERVER_FETCH_FAIL    = 'Server_FETCH_FAIL';
+
+export const SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST = 'SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST';
+export const SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS = 'SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS';
+export const SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL    = 'SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL';
+
+export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST';
+export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS';
+export const EXTENDED_DESCRIPTION_FAIL    = 'EXTENDED_DESCRIPTION_FAIL';
+
+export const SERVER_DOMAIN_BLOCKS_FETCH_REQUEST = 'SERVER_DOMAIN_BLOCKS_FETCH_REQUEST';
+export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS';
+export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL    = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
+
+export const fetchServer = () => (dispatch, getState) => {
+  dispatch(fetchServerRequest());
+
+  api(getState)
+    .get('/api/v2/instance').then(({ data }) => {
+      if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
+      dispatch(fetchServerSuccess(data));
+    }).catch(err => dispatch(fetchServerFail(err)));
+};
+
+const fetchServerRequest = () => ({
+  type: SERVER_FETCH_REQUEST,
+});
+
+const fetchServerSuccess = server => ({
+  type: SERVER_FETCH_SUCCESS,
+  server,
+});
+
+const fetchServerFail = error => ({
+  type: SERVER_FETCH_FAIL,
+  error,
+});
+
+export const fetchServerTranslationLanguages = () => (dispatch, getState) => {
+  dispatch(fetchServerTranslationLanguagesRequest());
+
+  api(getState)
+    .get('/api/v1/instance/translation_languages').then(({ data }) => {
+      dispatch(fetchServerTranslationLanguagesSuccess(data));
+    }).catch(err => dispatch(fetchServerTranslationLanguagesFail(err)));
+};
+
+const fetchServerTranslationLanguagesRequest = () => ({
+  type: SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST,
+});
+
+const fetchServerTranslationLanguagesSuccess = translationLanguages => ({
+  type: SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS,
+  translationLanguages,
+});
+
+const fetchServerTranslationLanguagesFail = error => ({
+  type: SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL,
+  error,
+});
+
+export const fetchExtendedDescription = () => (dispatch, getState) => {
+  dispatch(fetchExtendedDescriptionRequest());
+
+  api(getState)
+    .get('/api/v1/instance/extended_description')
+    .then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data)))
+    .catch(err => dispatch(fetchExtendedDescriptionFail(err)));
+};
+
+const fetchExtendedDescriptionRequest = () => ({
+  type: EXTENDED_DESCRIPTION_REQUEST,
+});
+
+const fetchExtendedDescriptionSuccess = description => ({
+  type: EXTENDED_DESCRIPTION_SUCCESS,
+  description,
+});
+
+const fetchExtendedDescriptionFail = error => ({
+  type: EXTENDED_DESCRIPTION_FAIL,
+  error,
+});
+
+export const fetchDomainBlocks = () => (dispatch, getState) => {
+  dispatch(fetchDomainBlocksRequest());
+
+  api(getState)
+    .get('/api/v1/instance/domain_blocks')
+    .then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data)))
+    .catch(err => {
+      if (err.response.status === 404) {
+        dispatch(fetchDomainBlocksSuccess(false, []));
+      } else {
+        dispatch(fetchDomainBlocksFail(err));
+      }
+    });
+};
+
+const fetchDomainBlocksRequest = () => ({
+  type: SERVER_DOMAIN_BLOCKS_FETCH_REQUEST,
+});
+
+const fetchDomainBlocksSuccess = (isAvailable, blocks) => ({
+  type: SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS,
+  isAvailable,
+  blocks,
+});
+
+const fetchDomainBlocksFail = error => ({
+  type: SERVER_DOMAIN_BLOCKS_FETCH_FAIL,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/settings.js b/app/javascript/flavours/glitch/actions/settings.js
new file mode 100644
index 000000000..60f0abf95
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/settings.js
@@ -0,0 +1,34 @@
+import api from '../api';
+import { debounce } from 'lodash';
+import { showAlertForError } from './alerts';
+
+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 }))
+    .catch(error => dispatch(showAlertForError(error)));
+}, 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..487cd6988
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -0,0 +1,350 @@
+import api from '../api';
+
+import { deleteFromTimelines } from './timelines';
+import { importFetchedStatus, importFetchedStatuses } from './importer';
+import { ensureComposeIsVisible, setComposeToStatus } from './compose';
+
+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 const STATUS_REVEAL   = 'STATUS_REVEAL';
+export const STATUS_HIDE     = 'STATUS_HIDE';
+export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
+
+export const REDRAFT = 'REDRAFT';
+
+export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
+export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
+export const STATUS_FETCH_SOURCE_FAIL    = 'STATUS_FETCH_SOURCE_FAIL';
+
+export const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST';
+export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
+export const STATUS_TRANSLATE_FAIL    = 'STATUS_TRANSLATE_FAIL';
+export const STATUS_TRANSLATE_UNDO    = 'STATUS_TRANSLATE_UNDO';
+
+export function fetchStatusRequest(id, skipLoading) {
+  return {
+    type: STATUS_FETCH_REQUEST,
+    id,
+    skipLoading,
+  };
+}
+
+export function fetchStatus(id, forceFetch = false) {
+  return (dispatch, getState) => {
+    const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
+
+    dispatch(fetchContext(id));
+
+    if (skipLoading) {
+      return;
+    }
+
+    dispatch(fetchStatusRequest(id, skipLoading));
+
+    api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+      dispatch(importFetchedStatus(response.data));
+      dispatch(fetchStatusSuccess(skipLoading));
+    }).catch(error => {
+      dispatch(fetchStatusFail(id, error, skipLoading));
+    });
+  };
+}
+
+export function fetchStatusSuccess(skipLoading) {
+  return {
+    type: STATUS_FETCH_SUCCESS,
+    skipLoading,
+  };
+}
+
+export function fetchStatusFail(id, error, skipLoading) {
+  return {
+    type: STATUS_FETCH_FAIL,
+    id,
+    error,
+    skipLoading,
+    skipAlert: true,
+  };
+}
+
+export function redraft(status, raw_text, content_type) {
+  return {
+    type: REDRAFT,
+    status,
+    raw_text,
+    content_type,
+  };
+}
+
+export const editStatus = (id, routerHistory) => (dispatch, getState) => {
+  let status = getState().getIn(['statuses', id]);
+
+  if (status.get('poll')) {
+    status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
+  }
+
+  dispatch(fetchStatusSourceRequest());
+
+  api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
+    dispatch(fetchStatusSourceSuccess());
+    ensureComposeIsVisible(getState, routerHistory);
+    dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type));
+  }).catch(error => {
+    dispatch(fetchStatusSourceFail(error));
+  });
+};
+
+export const fetchStatusSourceRequest = () => ({
+  type: STATUS_FETCH_SOURCE_REQUEST,
+});
+
+export const fetchStatusSourceSuccess = () => ({
+  type: STATUS_FETCH_SOURCE_SUCCESS,
+});
+
+export const fetchStatusSourceFail = error => ({
+  type: STATUS_FETCH_SOURCE_FAIL,
+  error,
+});
+
+export function deleteStatus(id, routerHistory, withRedraft = false) {
+  return (dispatch, getState) => {
+    let status = getState().getIn(['statuses', id]);
+
+    if (status.get('poll')) {
+      status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
+    }
+
+    dispatch(deleteStatusRequest(id));
+
+    api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
+      dispatch(deleteStatusSuccess(id));
+      dispatch(deleteFromTimelines(id));
+
+      if (withRedraft) {
+        dispatch(redraft(status, response.data.text, response.data.content_type));
+
+        ensureComposeIsVisible(getState, routerHistory);
+      }
+    }).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 const updateStatus = status => dispatch =>
+  dispatch(importFetchedStatus(status));
+
+export function fetchContext(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchContextRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+      dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
+      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,
+  };
+}
+
+export function hideStatus(ids) {
+  if (!Array.isArray(ids)) {
+    ids = [ids];
+  }
+
+  return {
+    type: STATUS_HIDE,
+    ids,
+  };
+}
+
+export function revealStatus(ids) {
+  if (!Array.isArray(ids)) {
+    ids = [ids];
+  }
+
+  return {
+    type: STATUS_REVEAL,
+    ids,
+  };
+}
+
+export function toggleStatusCollapse(id, isCollapsed) {
+  return {
+    type: STATUS_COLLAPSE,
+    id,
+    isCollapsed,
+  };
+}
+
+export const translateStatus = id => (dispatch, getState) => {
+  dispatch(translateStatusRequest(id));
+
+  api(getState).post(`/api/v1/statuses/${id}/translate`).then(response => {
+    dispatch(translateStatusSuccess(id, response.data));
+  }).catch(error => {
+    dispatch(translateStatusFail(id, error));
+  });
+};
+
+export const translateStatusRequest = id => ({
+  type: STATUS_TRANSLATE_REQUEST,
+  id,
+});
+
+export const translateStatusSuccess = (id, translation) => ({
+  type: STATUS_TRANSLATE_SUCCESS,
+  id,
+  translation,
+});
+
+export const translateStatusFail = (id, error) => ({
+  type: STATUS_TRANSLATE_FAIL,
+  id,
+  error,
+});
+
+export const undoStatusTranslation = id => ({
+  type: STATUS_TRANSLATE_UNDO,
+  id,
+});
diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js
new file mode 100644
index 000000000..137b68e22
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/store.js
@@ -0,0 +1,39 @@
+import { Iterable, fromJS } from 'immutable';
+import { hydrateCompose } from './compose';
+import { importFetchedAccounts } from './importer';
+import { saveSettings } from './settings';
+
+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());
+
+const applyMigrations = (state) => {
+  return state.withMutations(state => {
+    // Migrate glitch-soc local-only “Show unread marker” setting to Mastodon's setting
+    if (state.getIn(['local_settings', 'notifications', 'show_unread']) !== undefined) {
+      // Only change if the Mastodon setting does not deviate from default
+      if (state.getIn(['settings', 'notifications', 'showUnread']) !== false) {
+        state.setIn(['settings', 'notifications', 'showUnread'], state.getIn(['local_settings', 'notifications', 'show_unread']));
+      }
+      state.removeIn(['local_settings', 'notifications', 'show_unread']);
+    }
+  });
+};
+
+export function hydrateStore(rawState) {
+  return dispatch => {
+    const state = applyMigrations(convertState(rawState));
+
+    dispatch({
+      type: STORE_HYDRATE,
+      state,
+    });
+
+    dispatch(hydrateCompose());
+    dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
+    dispatch(saveSettings());
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
new file mode 100644
index 000000000..ffac1b258
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -0,0 +1,168 @@
+// @ts-check
+
+import { connectStream } from '../stream';
+import {
+  updateTimeline,
+  deleteFromTimelines,
+  expandHomeTimeline,
+  connectTimeline,
+  disconnectTimeline,
+  fillHomeTimelineGaps,
+  fillPublicTimelineGaps,
+  fillCommunityTimelineGaps,
+  fillListTimelineGaps,
+} from './timelines';
+import { updateNotifications, expandNotifications } from './notifications';
+import { updateConversations } from './conversations';
+import { updateStatus } from './statuses';
+import {
+  fetchAnnouncements,
+  updateAnnouncements,
+  updateReaction as updateAnnouncementsReaction,
+  deleteAnnouncement,
+} from './announcements';
+import { getLocale } from 'mastodon/locales';
+
+const { messages } = getLocale();
+
+/**
+ * @param {number} max
+ * @return {number}
+ */
+const randomUpTo = max =>
+  Math.floor(Math.random() * Math.floor(max));
+
+/**
+ * @param {string} timelineId
+ * @param {string} channelName
+ * @param {Object.<string, string>} params
+ * @param {Object} options
+ * @param {function(Function, Function): void} [options.fallback]
+ * @param {function(): void} [options.fillGaps]
+ * @param {function(object): boolean} [options.accept]
+ * @return {function(): void}
+ */
+export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) =>
+  connectStream(channelName, params, (dispatch, getState) => {
+    const locale = getState().getIn(['meta', 'locale']);
+
+    let pollingId;
+
+    /**
+     * @param {function(Function, Function): void} fallback
+     */
+    const useFallback = fallback => {
+      fallback(dispatch, () => {
+        pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
+      });
+    };
+
+    return {
+      onConnect() {
+        dispatch(connectTimeline(timelineId));
+
+        if (pollingId) {
+          clearTimeout(pollingId);
+          pollingId = null;
+        }
+
+        if (options.fillGaps) {
+          dispatch(options.fillGaps());
+        }
+      },
+
+      onDisconnect() {
+        dispatch(disconnectTimeline(timelineId));
+
+        if (options.fallback) {
+          pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000));
+        }
+      },
+
+      onReceive (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
+          break;
+        case 'status.update':
+          dispatch(updateStatus(JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+          break;
+        case 'conversation':
+          dispatch(updateConversations(JSON.parse(data.payload)));
+          break;
+        case 'announcement':
+          dispatch(updateAnnouncements(JSON.parse(data.payload)));
+          break;
+        case 'announcement.reaction':
+          dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
+          break;
+        case 'announcement.delete':
+          dispatch(deleteAnnouncement(data.payload));
+          break;
+        }
+      },
+    };
+  });
+
+/**
+ * @param {Function} dispatch
+ * @param {function(): void} done
+ */
+const refreshHomeTimelineAndNotification = (dispatch, done) => {
+  dispatch(expandHomeTimeline({}, () =>
+    dispatch(expandNotifications({}, () =>
+      dispatch(fetchAnnouncements(done))))));
+};
+
+/**
+ * @return {function(): void}
+ */
+export const connectUserStream = () =>
+  connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps });
+
+/**
+ * @param {Object} options
+ * @param {boolean} [options.onlyMedia]
+ * @return {function(): void}
+ */
+export const connectCommunityStream = ({ onlyMedia } = {}) =>
+  connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });
+
+/**
+ * @param {Object} options
+ * @param {boolean} [options.onlyMedia]
+ * @param {boolean} [options.onlyRemote]
+ * @param {boolean} [options.allowLocalOnly]
+ * @return {function(): void}
+ */
+export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) =>
+  connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly }) });
+
+/**
+ * @param {string} columnId
+ * @param {string} tagName
+ * @param {boolean} onlyLocal
+ * @param {function(object): boolean} accept
+ * @return {function(): void}
+ */
+export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) =>
+  connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept });
+
+/**
+ * @return {function(): void}
+ */
+export const connectDirectStream = () =>
+  connectTimelineStream('direct', 'direct');
+
+/**
+ * @param {string} listId
+ * @return {function(): void}
+ */
+export const connectListStream = listId =>
+  connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) });
diff --git a/app/javascript/flavours/glitch/actions/suggestions.js b/app/javascript/flavours/glitch/actions/suggestions.js
new file mode 100644
index 000000000..9e8cd1ea4
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/suggestions.js
@@ -0,0 +1,64 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
+
+export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
+export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
+export const SUGGESTIONS_FETCH_FAIL    = 'SUGGESTIONS_FETCH_FAIL';
+
+export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
+
+export function fetchSuggestions(withRelationships = false) {
+  return (dispatch, getState) => {
+    dispatch(fetchSuggestionsRequest());
+
+    api(getState).get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
+      dispatch(importFetchedAccounts(response.data.map(x => x.account)));
+      dispatch(fetchSuggestionsSuccess(response.data));
+
+      if (withRelationships) {
+        dispatch(fetchRelationships(response.data.map(item => item.account.id)));
+      }
+    }).catch(error => dispatch(fetchSuggestionsFail(error)));
+  };
+}
+
+export function fetchSuggestionsRequest() {
+  return {
+    type: SUGGESTIONS_FETCH_REQUEST,
+    skipLoading: true,
+  };
+}
+
+export function fetchSuggestionsSuccess(suggestions) {
+  return {
+    type: SUGGESTIONS_FETCH_SUCCESS,
+    suggestions,
+    skipLoading: true,
+  };
+}
+
+export function fetchSuggestionsFail(error) {
+  return {
+    type: SUGGESTIONS_FETCH_FAIL,
+    error,
+    skipLoading: true,
+    skipAlert: true,
+  };
+}
+
+export const dismissSuggestion = accountId => (dispatch, getState) => {
+  dispatch({
+    type: SUGGESTIONS_DISMISS,
+    id: accountId,
+  });
+
+  api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
+    dispatch(fetchSuggestionsRequest());
+
+    api(getState).get('/api/v2/suggestions').then(response => {
+      dispatch(importFetchedAccounts(response.data.map(x => x.account)));
+      dispatch(fetchSuggestionsSuccess(response.data));
+    }).catch(error => dispatch(fetchSuggestionsFail(error)));
+  }).catch(() => {});
+};
diff --git a/app/javascript/flavours/glitch/actions/tags.js b/app/javascript/flavours/glitch/actions/tags.js
new file mode 100644
index 000000000..dda8c924b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/tags.js
@@ -0,0 +1,172 @@
+import api, { getLinks } from '../api';
+
+export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
+export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
+export const HASHTAG_FETCH_FAIL    = 'HASHTAG_FETCH_FAIL';
+
+export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
+export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
+export const FOLLOWED_HASHTAGS_FETCH_FAIL    = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
+
+export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
+export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
+export const FOLLOWED_HASHTAGS_EXPAND_FAIL    = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
+
+export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
+export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
+export const HASHTAG_FOLLOW_FAIL    = 'HASHTAG_FOLLOW_FAIL';
+
+export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
+export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
+export const HASHTAG_UNFOLLOW_FAIL    = 'HASHTAG_UNFOLLOW_FAIL';
+
+export const fetchHashtag = name => (dispatch, getState) => {
+  dispatch(fetchHashtagRequest());
+
+  api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => {
+    dispatch(fetchHashtagSuccess(name, data));
+  }).catch(err => {
+    dispatch(fetchHashtagFail(err));
+  });
+};
+
+export const fetchHashtagRequest = () => ({
+  type: HASHTAG_FETCH_REQUEST,
+});
+
+export const fetchHashtagSuccess = (name, tag) => ({
+  type: HASHTAG_FETCH_SUCCESS,
+  name,
+  tag,
+});
+
+export const fetchHashtagFail = error => ({
+  type: HASHTAG_FETCH_FAIL,
+  error,
+});
+
+export const fetchFollowedHashtags = () => (dispatch, getState) => {
+  dispatch(fetchFollowedHashtagsRequest());
+
+  api(getState).get('/api/v1/followed_tags').then(response => {
+    const next = getLinks(response).refs.find(link => link.rel === 'next');
+    dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+  }).catch(err => {
+    dispatch(fetchFollowedHashtagsFail(err));
+  });
+};
+
+export function fetchFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  };
+}
+
+export function fetchFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+    followed_tags,
+    next,
+  };
+}
+
+export function fetchFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_FAIL,
+    error,
+  };
+}
+
+export function expandFollowedHashtags() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['followed_tags', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowedHashtagsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFollowedHashtagsFail(error));
+    });
+  };
+}
+
+export function expandFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  };
+}
+
+export function expandFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+    followed_tags,
+    next,
+  };
+}
+
+export function expandFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
+    error,
+  };
+}
+
+export const followHashtag = name => (dispatch, getState) => {
+  dispatch(followHashtagRequest(name));
+
+  api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
+    dispatch(followHashtagSuccess(name, data));
+  }).catch(err => {
+    dispatch(followHashtagFail(name, err));
+  });
+};
+
+export const followHashtagRequest = name => ({
+  type: HASHTAG_FOLLOW_REQUEST,
+  name,
+});
+
+export const followHashtagSuccess = (name, tag) => ({
+  type: HASHTAG_FOLLOW_SUCCESS,
+  name,
+  tag,
+});
+
+export const followHashtagFail = (name, error) => ({
+  type: HASHTAG_FOLLOW_FAIL,
+  name,
+  error,
+});
+
+export const unfollowHashtag = name => (dispatch, getState) => {
+  dispatch(unfollowHashtagRequest(name));
+
+  api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
+    dispatch(unfollowHashtagSuccess(name, data));
+  }).catch(err => {
+    dispatch(unfollowHashtagFail(name, err));
+  });
+};
+
+export const unfollowHashtagRequest = name => ({
+  type: HASHTAG_UNFOLLOW_REQUEST,
+  name,
+});
+
+export const unfollowHashtagSuccess = (name, tag) => ({
+  type: HASHTAG_UNFOLLOW_SUCCESS,
+  name,
+  tag,
+});
+
+export const unfollowHashtagFail = (name, error) => ({
+  type: HASHTAG_UNFOLLOW_FAIL,
+  name,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
new file mode 100644
index 000000000..eb817daf9
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -0,0 +1,234 @@
+import { importFetchedStatus, importFetchedStatuses } from './importer';
+import { submitMarkers } from './markers';
+import api, { getLinks } from 'flavours/glitch/api';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'flavours/glitch/compare_id';
+import { me, usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
+import { toServerSideType } from 'flavours/glitch/utils/filters';
+
+export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
+export const TIMELINE_CLEAR   = 'TIMELINE_CLEAR';
+
+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_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
+export const TIMELINE_DISCONNECT   = 'TIMELINE_DISCONNECT';
+export const TIMELINE_CONNECT      = 'TIMELINE_CONNECT';
+
+export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
+
+export const loadPending = timeline => ({
+  type: TIMELINE_LOAD_PENDING,
+  timeline,
+});
+
+export function updateTimeline(timeline, status, accept) {
+  return (dispatch, getState) => {
+    if (typeof accept === 'function' && !accept(status)) {
+      return;
+    }
+
+    if (getState().getIn(['timelines', timeline, 'isPartial'])) {
+      // Prevent new items from being added to a partial timeline,
+      // since it will be reloaded anyway
+
+      return;
+    }
+
+    let filtered = false;
+
+    if (status.filtered) {
+      const contextType = toServerSideType(timeline);
+      const filters = status.filtered.filter(result => result.filter.context.includes(contextType));
+
+      filtered = filters.length > 0;
+    }
+
+    dispatch(importFetchedStatus(status));
+
+    dispatch({
+      type: TIMELINE_UPDATE,
+      timeline,
+      status,
+      usePendingItems: preferPendingItems,
+      filtered,
+    });
+
+    if (timeline === 'home') {
+      dispatch(submitMarkers());
+    }
+  };
+}
+
+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'));
+    const reblogOf   = getState().getIn(['statuses', id, 'reblog'], null);
+
+    dispatch({
+      type: TIMELINE_DELETE,
+      id,
+      accountId,
+      references,
+      reblogOf,
+    });
+  };
+}
+
+export function clearTimeline(timeline) {
+  return (dispatch) => {
+    dispatch({ type: TIMELINE_CLEAR, timeline });
+  };
+}
+
+const noOp = () => {};
+
+const parseTags = (tags = {}, mode) => {
+  return (tags[mode] || []).map((tag) => {
+    return tag.value;
+  });
+};
+
+export function expandTimeline(timelineId, path, params = {}, done = noOp) {
+  return (dispatch, getState) => {
+    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+    const isLoadingMore = !!params.max_id;
+
+    if (timeline.get('isLoading')) {
+      done();
+      return;
+    }
+
+    if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) {
+      const a = timeline.getIn(['pendingItems', 0]);
+      const b = timeline.getIn(['items', 0]);
+
+      if (a && b && compareId(a, b) > 0) {
+        params.since_id = a;
+      } else {
+        params.since_id = b || a;
+      }
+    }
+
+    const isLoadingRecent = !!params.since_id;
+
+    dispatch(expandTimelineRequest(timelineId, isLoadingMore));
+
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedStatuses(response.data));
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
+
+      if (timelineId === 'home') {
+        dispatch(submitMarkers());
+      }
+    }).catch(error => {
+      dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+    }).finally(() => {
+      done();
+    });
+  };
+}
+
+export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
+  return (dispatch, getState) => {
+    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+    const items = timeline.get('items');
+    const nullIndexes = items.map((statusId, index) => statusId === null ? index : null);
+    const gaps = nullIndexes.map(index => index > 0 ? items.get(index - 1) : null);
+
+    // Only expand at most two gaps to avoid doing too many requests
+    done = gaps.take(2).reduce((done, maxId) => {
+      return (() => dispatch(expandTimeline(timelineId, path, { ...params, maxId }, done)));
+    }, done);
+
+    done();
+  };
+}
+
+export const expandHomeTimeline            = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
+export const expandPublicTimeline          = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done);
+export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
+export const expandDirectTimeline          = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
+export const expandAccountTimeline         = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, tagged, max_id: maxId });
+export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
+export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
+export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
+export const expandHashtagTimeline         = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
+  return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
+    max_id: maxId,
+    any: parseTags(tags, 'any'),
+    all: parseTags(tags, 'all'),
+    none: parseTags(tags, 'none'),
+    local: local,
+  }, done);
+};
+
+export const fillHomeTimelineGaps      = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done);
+export const fillPublicTimelineGaps    = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }, done);
+export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done);
+export const fillListTimelineGaps      = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done);
+
+export function expandTimelineRequest(timeline, isLoadingMore) {
+  return {
+    type: TIMELINE_EXPAND_REQUEST,
+    timeline,
+    skipLoading: !isLoadingMore,
+  };
+}
+
+export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) {
+  return {
+    type: TIMELINE_EXPAND_SUCCESS,
+    timeline,
+    statuses,
+    next,
+    partial,
+    isLoadingRecent,
+    usePendingItems,
+    skipLoading: !isLoadingMore,
+  };
+}
+
+export function expandTimelineFail(timeline, error, isLoadingMore) {
+  return {
+    type: TIMELINE_EXPAND_FAIL,
+    timeline,
+    error,
+    skipLoading: !isLoadingMore,
+    skipNotFound: timeline.startsWith('account:'),
+  };
+}
+
+export function scrollTopTimeline(timeline, top) {
+  return {
+    type: TIMELINE_SCROLL_TOP,
+    timeline,
+    top,
+  };
+}
+
+export function connectTimeline(timeline) {
+  return {
+    type: TIMELINE_CONNECT,
+    timeline,
+    usePendingItems: preferPendingItems,
+  };
+}
+
+export const disconnectTimeline = timeline => ({
+  type: TIMELINE_DISCONNECT,
+  timeline,
+  usePendingItems: preferPendingItems,
+});
+
+export const markAsPartial = timeline => ({
+  type: TIMELINE_MARK_AS_PARTIAL,
+  timeline,
+});
diff --git a/app/javascript/flavours/glitch/actions/trends.js b/app/javascript/flavours/glitch/actions/trends.js
new file mode 100644
index 000000000..edda0b5b5
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/trends.js
@@ -0,0 +1,139 @@
+import api, { getLinks } from '../api';
+import { importFetchedStatuses } from './importer';
+
+export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
+export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
+export const TRENDS_TAGS_FETCH_FAIL    = 'TRENDS_TAGS_FETCH_FAIL';
+
+export const TRENDS_LINKS_FETCH_REQUEST = 'TRENDS_LINKS_FETCH_REQUEST';
+export const TRENDS_LINKS_FETCH_SUCCESS = 'TRENDS_LINKS_FETCH_SUCCESS';
+export const TRENDS_LINKS_FETCH_FAIL    = 'TRENDS_LINKS_FETCH_FAIL';
+
+export const TRENDS_STATUSES_FETCH_REQUEST = 'TRENDS_STATUSES_FETCH_REQUEST';
+export const TRENDS_STATUSES_FETCH_SUCCESS = 'TRENDS_STATUSES_FETCH_SUCCESS';
+export const TRENDS_STATUSES_FETCH_FAIL    = 'TRENDS_STATUSES_FETCH_FAIL';
+
+export const TRENDS_STATUSES_EXPAND_REQUEST = 'TRENDS_STATUSES_EXPAND_REQUEST';
+export const TRENDS_STATUSES_EXPAND_SUCCESS = 'TRENDS_STATUSES_EXPAND_SUCCESS';
+export const TRENDS_STATUSES_EXPAND_FAIL    = 'TRENDS_STATUSES_EXPAND_FAIL';
+
+export const fetchTrendingHashtags = () => (dispatch, getState) => {
+  dispatch(fetchTrendingHashtagsRequest());
+
+  api(getState)
+    .get('/api/v1/trends/tags')
+    .then(({ data }) => dispatch(fetchTrendingHashtagsSuccess(data)))
+    .catch(err => dispatch(fetchTrendingHashtagsFail(err)));
+};
+
+export const fetchTrendingHashtagsRequest = () => ({
+  type: TRENDS_TAGS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendingHashtagsSuccess = trends => ({
+  type: TRENDS_TAGS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendingHashtagsFail = error => ({
+  type: TRENDS_TAGS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const fetchTrendingLinks = () => (dispatch, getState) => {
+  dispatch(fetchTrendingLinksRequest());
+
+  api(getState)
+    .get('/api/v1/trends/links')
+    .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data)))
+    .catch(err => dispatch(fetchTrendingLinksFail(err)));
+};
+
+export const fetchTrendingLinksRequest = () => ({
+  type: TRENDS_LINKS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendingLinksSuccess = trends => ({
+  type: TRENDS_LINKS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendingLinksFail = error => ({
+  type: TRENDS_LINKS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const fetchTrendingStatuses = () => (dispatch, getState) => {
+  if (getState().getIn(['status_lists', 'trending', 'isLoading'])) {
+    return;
+  }
+
+  dispatch(fetchTrendingStatusesRequest());
+
+  api(getState).get('/api/v1/trends/statuses').then(response => {
+    const next = getLinks(response).refs.find(link => link.rel === 'next');
+    dispatch(importFetchedStatuses(response.data));
+    dispatch(fetchTrendingStatusesSuccess(response.data, next ? next.uri : null));
+  }).catch(err => dispatch(fetchTrendingStatusesFail(err)));
+};
+
+export const fetchTrendingStatusesRequest = () => ({
+  type: TRENDS_STATUSES_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendingStatusesSuccess = (statuses, next) => ({
+  type: TRENDS_STATUSES_FETCH_SUCCESS,
+  statuses,
+  next,
+  skipLoading: true,
+});
+
+export const fetchTrendingStatusesFail = error => ({
+  type: TRENDS_STATUSES_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+
+export const expandTrendingStatuses = () => (dispatch, getState) => {
+  const url = getState().getIn(['status_lists', 'trending', 'next'], null);
+
+  if (url === null || getState().getIn(['status_lists', 'trending', 'isLoading'])) {
+    return;
+  }
+
+  dispatch(expandTrendingStatusesRequest());
+
+  api(getState).get(url).then(response => {
+    const next = getLinks(response).refs.find(link => link.rel === 'next');
+    dispatch(importFetchedStatuses(response.data));
+    dispatch(expandTrendingStatusesSuccess(response.data, next ? next.uri : null));
+  }).catch(error => {
+    dispatch(expandTrendingStatusesFail(error));
+  });
+};
+
+export const expandTrendingStatusesRequest = () => ({
+  type: TRENDS_STATUSES_EXPAND_REQUEST,
+});
+
+export const expandTrendingStatusesSuccess = (statuses, next) => ({
+  type: TRENDS_STATUSES_EXPAND_SUCCESS,
+  statuses,
+  next,
+});
+
+export const expandTrendingStatusesFail = error => ({
+  type: TRENDS_STATUSES_EXPAND_FAIL,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/api.js b/app/javascript/flavours/glitch/api.js
new file mode 100644
index 000000000..6bbddbef6
--- /dev/null
+++ b/app/javascript/flavours/glitch/api.js
@@ -0,0 +1,75 @@
+// @ts-check
+
+import axios from 'axios';
+import LinkHeader from 'http-link-header';
+import ready from './ready';
+
+/**
+ * @param {import('axios').AxiosResponse} response
+ * @returns {LinkHeader}
+ */
+export const getLinks = response => {
+  const value = response.headers.link;
+
+  if (!value) {
+    return new LinkHeader();
+  }
+
+  return LinkHeader.parse(value);
+};
+
+/** @type {import('axios').RawAxiosRequestHeaders} */
+const csrfHeader = {};
+
+/**
+ * @returns {void}
+ */
+const setCSRFHeader = () => {
+  /** @type {HTMLMetaElement | null} */
+  const csrfToken = document.querySelector('meta[name=csrf-token]');
+
+  if (csrfToken) {
+    csrfHeader['X-CSRF-Token'] = csrfToken.content;
+  }
+};
+
+ready(setCSRFHeader);
+
+/**
+ * @param {() => import('immutable').Map} getState
+ * @returns {import('axios').RawAxiosRequestHeaders}
+ */
+const authorizationHeaderFromState = getState => {
+  const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
+
+  if (!accessToken) {
+    return {};
+  }
+
+  return {
+    'Authorization': `Bearer ${accessToken}`,
+  };
+};
+
+/**
+ * @param {() => import('immutable').Map} getState
+ * @returns {import('axios').AxiosInstance}
+ */
+export default function api(getState) {
+  return axios.create({
+    headers: {
+      ...csrfHeader,
+      ...authorizationHeaderFromState(getState),
+    },
+
+    transformResponse: [
+      function (data) {
+        try {
+          return JSON.parse(data);
+        } catch {
+          return data;
+        }
+      },
+    ],
+  });
+}
diff --git a/app/javascript/flavours/glitch/base_polyfills.js b/app/javascript/flavours/glitch/base_polyfills.js
new file mode 100644
index 000000000..d3ac0d510
--- /dev/null
+++ b/app/javascript/flavours/glitch/base_polyfills.js
@@ -0,0 +1,42 @@
+import 'intl';
+import 'intl/locale-data/jsonp/en';
+import 'es6-symbol/implement';
+import includes from 'array-includes';
+import assign from 'object-assign';
+import values from 'object.values';
+import { decode as decodeBase64 } from './utils/base64';
+import promiseFinally from 'promise.prototype.finally';
+
+if (!Array.prototype.includes) {
+  includes.shim();
+}
+
+if (!Object.assign) {
+  Object.assign = assign;
+}
+
+if (!Object.values) {
+  values.shim();
+}
+
+promiseFinally.shim();
+
+if (!HTMLCanvasElement.prototype.toBlob) {
+  const BASE64_MARKER = ';base64,';
+
+  Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
+    value(callback, type = 'image/png', quality) {
+      const dataURL = this.toDataURL(type, quality);
+      let data;
+
+      if (dataURL.indexOf(BASE64_MARKER) >= 0) {
+        const [, base64] = dataURL.split(BASE64_MARKER);
+        data = decodeBase64(base64);
+      } else {
+        [, data] = dataURL.split(',');
+      }
+
+      callback(new Blob([data], { type }));
+    },
+  });
+}
diff --git a/app/javascript/flavours/glitch/blurhash.js b/app/javascript/flavours/glitch/blurhash.js
new file mode 100644
index 000000000..5adcc3e77
--- /dev/null
+++ b/app/javascript/flavours/glitch/blurhash.js
@@ -0,0 +1,112 @@
+const DIGIT_CHARACTERS = [
+  '0',
+  '1',
+  '2',
+  '3',
+  '4',
+  '5',
+  '6',
+  '7',
+  '8',
+  '9',
+  'A',
+  'B',
+  'C',
+  'D',
+  'E',
+  'F',
+  'G',
+  'H',
+  'I',
+  'J',
+  'K',
+  'L',
+  'M',
+  'N',
+  'O',
+  'P',
+  'Q',
+  'R',
+  'S',
+  'T',
+  'U',
+  'V',
+  'W',
+  'X',
+  'Y',
+  'Z',
+  'a',
+  'b',
+  'c',
+  'd',
+  'e',
+  'f',
+  'g',
+  'h',
+  'i',
+  'j',
+  'k',
+  'l',
+  'm',
+  'n',
+  'o',
+  'p',
+  'q',
+  'r',
+  's',
+  't',
+  'u',
+  'v',
+  'w',
+  'x',
+  'y',
+  'z',
+  '#',
+  '$',
+  '%',
+  '*',
+  '+',
+  ',',
+  '-',
+  '.',
+  ':',
+  ';',
+  '=',
+  '?',
+  '@',
+  '[',
+  ']',
+  '^',
+  '_',
+  '{',
+  '|',
+  '}',
+  '~',
+];
+
+export const decode83 = (str) => {
+  let value = 0;
+  let c, digit;
+
+  for (let i = 0; i < str.length; i++) {
+    c = str[i];
+    digit = DIGIT_CHARACTERS.indexOf(c);
+    value = value * 83 + digit;
+  }
+
+  return value;
+};
+
+export const intToRGB = int => ({
+  r: Math.max(0, (int >> 16)),
+  g: Math.max(0, (int >> 8) & 255),
+  b: Math.max(0, (int & 255)),
+});
+
+export const getAverageFromBlurhash = blurhash => {
+  if (!blurhash) {
+    return null;
+  }
+
+  return intToRGB(decode83(blurhash.slice(2, 6)));
+};
diff --git a/app/javascript/flavours/glitch/compare_id.js b/app/javascript/flavours/glitch/compare_id.js
new file mode 100644
index 000000000..d2bd74f44
--- /dev/null
+++ b/app/javascript/flavours/glitch/compare_id.js
@@ -0,0 +1,11 @@
+export default function compareId (id1, id2) {
+  if (id1 === id2) {
+    return 0;
+  }
+
+  if (id1.length === id2.length) {
+    return id1 > id2 ? 1 : -1;
+  } else {
+    return id1.length > id2.length ? 1 : -1;
+  }
+}
diff --git a/app/javascript/flavours/glitch/components/account.jsx b/app/javascript/flavours/glitch/components/account.jsx
new file mode 100644
index 000000000..7b66d5a6e
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/account.jsx
@@ -0,0 +1,187 @@
+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/initial_state';
+import RelativeTimestamp from './relative_timestamp';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+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: 'Mute notifications from @{name}' },
+  unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+});
+
+class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    size: PropTypes.number,
+    account: ImmutablePropTypes.map,
+    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,
+    actionIcon: PropTypes.string,
+    actionTitle: PropTypes.string,
+    defaultAction: PropTypes.string,
+    onActionClick: PropTypes.func,
+  };
+
+  static defaultProps = {
+    size: 36,
+  };
+
+  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);
+  };
+
+  handleAction = () => {
+    this.props.onActionClick(this.props.account);
+  };
+
+  render () {
+    const {
+      account,
+      hidden,
+      intl,
+      small,
+      onActionClick,
+      actionIcon,
+      actionTitle,
+      defaultAction,
+      size,
+    } = this.props;
+
+    if (!account) {
+      return (
+        <div className='account'>
+          <div className='account__wrapper'>
+            <div className='account__display-name'>
+              <div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
+              <DisplayName />
+            </div>
+          </div>
+        </div>
+      );
+    }
+
+    if (hidden) {
+      return (
+        <Fragment>
+          {account.get('display_name')}
+          {account.get('username')}
+        </Fragment>
+      );
+    }
+
+    let buttons;
+
+    if (onActionClick) {
+      if (actionIcon) {
+        buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
+      }
+    } else 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' 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 (defaultAction === 'mute') {
+        buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />;
+      } else if (defaultAction === 'block') {
+        buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />;
+      } 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} />;
+      }
+    }
+
+    let mute_expires_at;
+    if (account.get('mute_expires_at')) {
+      mute_expires_at =  <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
+    }
+
+    return small ? (
+      <Permalink
+        className='account small'
+        href={account.get('url')}
+        to={`/@${account.get('acct')}`}
+      >
+        <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' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={size} /></div>
+            {mute_expires_at}
+            <DisplayName account={account} />
+          </Permalink>
+          {buttons ?
+            <div className='account__relationship'>
+              {buttons}
+            </div>
+            : null}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Account);
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.jsx b/app/javascript/flavours/glitch/components/admin/Counter.jsx
new file mode 100644
index 000000000..5b6a19f8d
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Counter.jsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/api';
+import { FormattedNumber } from 'react-intl';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import classNames from 'classnames';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+const percIncrease = (a, b) => {
+  let percent;
+
+  if (b !== 0) {
+    if (a !== 0) {
+      percent = (b - a) / a;
+    } else {
+      percent = 1;
+    }
+  } else if (b === 0 && a === 0) {
+    percent = 0;
+  } else {
+    percent = - 1;
+  }
+
+  return percent;
+};
+
+export default class Counter extends React.PureComponent {
+
+  static propTypes = {
+    measure: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    label: PropTypes.string.isRequired,
+    href: PropTypes.string,
+    params: PropTypes.object,
+    target: PropTypes.string,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { measure, start_at, end_at, params } = this.props;
+
+    api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, href, target } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><Skeleton width={43} /></span>
+          <span className='sparkline__value__change'><Skeleton width={43} /></span>
+        </React.Fragment>
+      );
+    } else {
+      const measure = data[0];
+      const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1);
+
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span>
+          {measure.previous_total && (<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)}
+        </React.Fragment>
+      );
+    }
+
+    const inner = (
+      <React.Fragment>
+        <div className='sparkline__value'>
+          {content}
+        </div>
+
+        <div className='sparkline__label'>
+          {label}
+        </div>
+
+        <div className='sparkline__graph'>
+          {!loading && (
+            <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
+              <SparklinesCurve />
+            </Sparklines>
+          )}
+        </div>
+      </React.Fragment>
+    );
+
+    if (href) {
+      return (
+        <a href={href} className='sparkline' target={target}>
+          {inner}
+        </a>
+      );
+    } else {
+      return (
+        <div className='sparkline'>
+          {inner}
+        </div>
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.jsx b/app/javascript/flavours/glitch/components/admin/Dimension.jsx
new file mode 100644
index 000000000..3dac8c6c2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Dimension.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/api';
+import { FormattedNumber } from 'react-intl';
+import { roundTo10 } from 'flavours/glitch/utils/numbers';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+export default class Dimension extends React.PureComponent {
+
+  static propTypes = {
+    dimension: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    limit: PropTypes.number.isRequired,
+    label: PropTypes.string.isRequired,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, dimension, limit, params } = this.props;
+
+    api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <table>
+          <tbody>
+            {Array.from(Array(limit)).map((_, i) => (
+              <tr className='dimension__item' key={i}>
+                <td className='dimension__item__key'>
+                  <Skeleton width={100} />
+                </td>
+
+                <td className='dimension__item__value'>
+                  <Skeleton width={60} />
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    } else {
+      const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
+
+      content = (
+        <table>
+          <tbody>
+            {data[0].data.map(item => (
+              <tr className='dimension__item' key={item.key}>
+                <td className='dimension__item__key'>
+                  <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
+                  <span title={item.key}>{item.human_key}</span>
+                </td>
+
+                <td className='dimension__item__value'>
+                  {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    return (
+      <div className='dimension'>
+        <h4>{label}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx
new file mode 100644
index 000000000..8478ba366
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/api';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  other: { id: 'report.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
+});
+
+class Category extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onSelect: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onSelect } = this.props;
+
+    if (!disabled) {
+      onSelect(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected, children } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
+        {selected && <input type='hidden' name='report[category]' value={id} />}
+
+        <div className='report-reason-selector__category__label'>
+          <span className={classNames('poll__input', { active: selected, disabled })} />
+          {text}
+        </div>
+
+        {(selected && children) && (
+          <div className='report-reason-selector__category__rules'>
+            {children}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+class Rule extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onToggle: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onToggle } = this.props;
+
+    if (!disabled) {
+      onToggle(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
+        <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
+        {selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
+        {text}
+      </div>
+    );
+  }
+
+}
+
+class ReportReasonSelector extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    category: PropTypes.string.isRequired,
+    rule_ids: PropTypes.arrayOf(PropTypes.string),
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    category: this.props.category,
+    rule_ids: this.props.rule_ids || [],
+    rules: [],
+  };
+
+  componentDidMount() {
+    api().get('/api/v1/instance').then(res => {
+      this.setState({
+        rules: res.data.rules,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  _save = () => {
+    const { id, disabled } = this.props;
+    const { category, rule_ids } = this.state;
+
+    if (disabled) {
+      return;
+    }
+
+    api().put(`/api/v1/admin/reports/${id}`, {
+      category,
+      rule_ids,
+    }).catch(err => {
+      console.error(err);
+    });
+  };
+
+  handleSelect = id => {
+    this.setState({ category: id }, () => this._save());
+  };
+
+  handleToggle = id => {
+    const { rule_ids } = this.state;
+
+    if (rule_ids.includes(id)) {
+      this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
+    } else {
+      this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
+    }
+  };
+
+  render () {
+    const { disabled, intl } = this.props;
+    const { rules, category, rule_ids } = this.state;
+
+    return (
+      <div className='report-reason-selector'>
+        <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
+          {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
+        </Category>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ReportReasonSelector);
diff --git a/app/javascript/flavours/glitch/components/admin/Retention.jsx b/app/javascript/flavours/glitch/components/admin/Retention.jsx
new file mode 100644
index 000000000..e1ba3f6c9
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Retention.jsx
@@ -0,0 +1,151 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/api';
+import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
+import classNames from 'classnames';
+import { roundTo10 } from 'flavours/glitch/utils/numbers';
+
+const dateForCohort = cohort => {
+  switch(cohort.frequency) {
+  case 'day':
+    return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
+  default:
+    return <FormattedDate value={cohort.period} month='long' year='numeric' />;
+  }
+};
+
+export default class Retention extends React.PureComponent {
+
+  static propTypes = {
+    start_at: PropTypes.string,
+    end_at: PropTypes.string,
+    frequency: PropTypes.string,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, frequency } = this.props;
+
+    api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { loading, data } = this.state;
+    const { frequency } = this.props;
+
+    let content;
+
+    if (loading) {
+      content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
+    } else {
+      content = (
+        <table className='retention__table'>
+          <thead>
+            <tr>
+              <th>
+                <div className='retention__table__date retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
+                </div>
+              </th>
+
+              <th>
+                <div className='retention__table__number retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
+                </div>
+              </th>
+
+              {data[0].data.slice(1).map((retention, i) => (
+                <th key={retention.date}>
+                  <div className='retention__table__number retention__table__label'>
+                    {i + 1}
+                  </div>
+                </th>
+              ))}
+            </tr>
+
+            <tr>
+              <td>
+                <div className='retention__table__date retention__table__average'>
+                  <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
+                </div>
+              </td>
+
+              <td>
+                <div className='retention__table__size'>
+                  <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
+                </div>
+              </td>
+
+              {data[0].data.slice(1).map((retention, i) => {
+                const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
+
+                return (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
+                      <FormattedNumber value={average} style='percent' />
+                    </div>
+                  </td>
+                );
+              })}
+            </tr>
+          </thead>
+
+          <tbody>
+            {data.slice(0, -1).map(cohort => (
+              <tr key={cohort.period}>
+                <td>
+                  <div className='retention__table__date'>
+                    {dateForCohort(cohort)}
+                  </div>
+                </td>
+
+                <td>
+                  <div className='retention__table__size'>
+                    <FormattedNumber value={cohort.data[0].value} />
+                  </div>
+                </td>
+
+                {cohort.data.slice(1).map(retention => (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}>
+                      <FormattedNumber value={retention.rate} style='percent' />
+                    </div>
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    let title = null;
+    switch(frequency) {
+    case 'day':
+      title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
+      break;
+    default:
+      title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
+    }
+
+    return (
+      <div className='retention'>
+        <h4>{title}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Trends.jsx b/app/javascript/flavours/glitch/components/admin/Trends.jsx
new file mode 100644
index 000000000..774bf36e6
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Trends.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/api';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Hashtag from 'flavours/glitch/components/hashtag';
+
+export default class Trends extends React.PureComponent {
+
+  static propTypes = {
+    limit: PropTypes.number.isRequired,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { limit } = this.props;
+
+    api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <div>
+          {Array.from(Array(limit)).map((_, i) => (
+            <Hashtag key={i} />
+          ))}
+        </div>
+      );
+    } else {
+      content = (
+        <div>
+          {data.map(hashtag => (
+            <Hashtag
+              key={hashtag.name}
+              name={hashtag.name}
+              href={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
+              people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
+              uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
+              history={hashtag.history.reverse().map(day => day.uses)}
+              className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
+            />
+          ))}
+        </div>
+      );
+    }
+
+    return (
+      <div className='trends trends--compact'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/animated_number.jsx b/app/javascript/flavours/glitch/components/animated_number.jsx
new file mode 100644
index 000000000..dd21d97f0
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/animated_number.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ShortNumber from 'mastodon/components/short_number';
+import TransitionMotion from 'react-motion/lib/TransitionMotion';
+import spring from 'react-motion/lib/spring';
+import { reduceMotion } from 'flavours/glitch/initial_state';
+
+const obfuscatedCount = count => {
+  if (count < 0) {
+    return 0;
+  } else if (count <= 1) {
+    return count;
+  } else {
+    return '1+';
+  }
+};
+
+export default class AnimatedNumber extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.number.isRequired,
+    obfuscate: PropTypes.bool,
+  };
+
+  state = {
+    direction: 1,
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.value > this.props.value) {
+      this.setState({ direction: 1 });
+    } else if (nextProps.value < this.props.value) {
+      this.setState({ direction: -1 });
+    }
+  }
+
+  willEnter = () => {
+    const { direction } = this.state;
+
+    return { y: -1 * direction };
+  };
+
+  willLeave = () => {
+    const { direction } = this.state;
+
+    return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
+  };
+
+  render () {
+    const { value, obfuscate } = this.props;
+    const { direction } = this.state;
+
+    if (reduceMotion) {
+      return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
+    }
+
+    const styles = [{
+      key: `${value}`,
+      data: value,
+      style: { y: spring(0, { damping: 35, stiffness: 400 }) },
+    }];
+
+    return (
+      <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
+        {items => (
+          <span className='animated-number'>
+            {items.map(({ key, data, style }) => (
+              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
+            ))}
+          </span>
+        )}
+      </TransitionMotion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/attachment_list.jsx b/app/javascript/flavours/glitch/components/attachment_list.jsx
new file mode 100644
index 000000000..68b80b19f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/attachment_list.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+export default class AttachmentList extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.list.isRequired,
+    compact: PropTypes.bool,
+  };
+
+  render () {
+    const { media, compact } = this.props;
+
+    return (
+      <div className={classNames('attachment-list', { compact })}>
+        {!compact && (
+          <div className='attachment-list__icon'>
+            <Icon id='link' />
+          </div>
+        )}
+
+        <ul className='attachment-list__list'>
+          {media.map(attachment => {
+            const displayUrl = attachment.get('remote_url') || attachment.get('url');
+
+            return (
+              <li key={attachment.get('id')}>
+                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>
+                  {compact && <Icon id='link' />}
+                  {compact && ' ' }
+                  {displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
+                </a>
+              </li>
+            );
+          })}
+        </ul>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx b/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx
new file mode 100644
index 000000000..83fafbd10
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import unicodeMapping from 'flavours/glitch/features/emoji/emoji_unicode_mapping_light';
+
+import { assetHost } from 'flavours/glitch/utils/config';
+
+export default class AutosuggestEmoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { emoji } = this.props;
+    let url;
+
+    if (emoji.custom) {
+      url = emoji.imageUrl;
+    } else {
+      const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+      if (!mapping) {
+        return null;
+      }
+
+      url = `${assetHost}/emoji/${mapping.filename}.svg`;
+    }
+
+    return (
+      <div className='emoji'>
+        <img
+          className='emojione'
+          src={url}
+          alt={emoji.native || emoji.colons}
+        />
+
+        {emoji.colons}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx b/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx
new file mode 100644
index 000000000..d787ed07a
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ShortNumber from 'flavours/glitch/components/short_number';
+import { FormattedMessage } from 'react-intl';
+
+export default class AutosuggestHashtag extends React.PureComponent {
+
+  static propTypes = {
+    tag: PropTypes.shape({
+      name: PropTypes.string.isRequired,
+      url: PropTypes.string,
+      history: PropTypes.array,
+    }).isRequired,
+  };
+
+  render() {
+    const { tag } = this.props;
+    const weeklyUses = tag.history && (
+      <ShortNumber
+        value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
+      />
+    );
+
+    return (
+      <div className='autosuggest-hashtag'>
+        <div className='autosuggest-hashtag__name'>
+          #<strong>{tag.name}</strong>
+        </div>
+        {tag.history !== undefined && (
+          <div className='autosuggest-hashtag__uses'>
+            <FormattedMessage
+              id='autosuggest_hashtag.per_week'
+              defaultMessage='{count} per week'
+              values={{ count: weeklyUses }}
+            />
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.jsx b/app/javascript/flavours/glitch/components/autosuggest_input.jsx
new file mode 100644
index 000000000..90ff298c0
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_input.jsx
@@ -0,0 +1,227 @@
+import React from 'react';
+import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import AutosuggestHashtag from './autosuggest_hashtag';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
+  let right = str.slice(caretPosition).search(/[\s\u200B]/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
+    return [null, null];
+  }
+
+  word = word.trim().toLowerCase();
+
+  if (word.length > 0) {
+    return [left, word];
+  } else {
+    return [null, null];
+  }
+};
+
+export default class AutosuggestInput extends ImmutablePureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    disabled: PropTypes.bool,
+    placeholder: PropTypes.string,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    onSuggestionsClearRequested: PropTypes.func.isRequired,
+    onSuggestionsFetchRequested: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onKeyUp: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    autoFocus: PropTypes.bool,
+    className: PropTypes.string,
+    id: PropTypes.string,
+    searchTokens: PropTypes.arrayOf(PropTypes.string),
+    maxLength: PropTypes.number,
+    lang: PropTypes.string,
+    spellCheck: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    autoFocus: true,
+    searchTokens: ['@', ':', '#'],
+  };
+
+  state = {
+    suggestionsHidden: true,
+    focused: false,
+    selectedSuggestion: 0,
+    lastToken: null,
+    tokenStart: 0,
+  };
+
+  onChange = (e) => {
+    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
+
+    if (token !== null && this.state.lastToken !== token) {
+      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+      this.props.onSuggestionsFetchRequested(token);
+    } else if (token === null) {
+      this.setState({ lastToken: null });
+      this.props.onSuggestionsClearRequested();
+    }
+
+    this.props.onChange(e);
+  };
+
+  onKeyDown = (e) => {
+    const { suggestions, disabled } = this.props;
+    const { selectedSuggestion, suggestionsHidden } = this.state;
+
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    if (e.which === 229 || e.isComposing) {
+      // Ignore key events during text composition
+      // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+      return;
+    }
+
+    switch(e.key) {
+    case 'Escape':
+      if (suggestions.size === 0 || suggestionsHidden) {
+        document.querySelector('.ui').parentElement.focus();
+      } else {
+        e.preventDefault();
+        this.setState({ suggestionsHidden: true });
+      }
+
+      break;
+    case 'ArrowDown':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+
+      break;
+    case 'ArrowUp':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+
+      break;
+    case 'Enter':
+    case 'Tab':
+      // Select suggestion
+      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+      }
+
+      break;
+    }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
+  };
+
+  onBlur = () => {
+    this.setState({ suggestionsHidden: true, focused: false });
+  };
+
+  onFocus = () => {
+    this.setState({ focused: true });
+  };
+
+  onSuggestionClick = (e) => {
+    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+    e.preventDefault();
+    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.input.focus();
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  setInput = (c) => {
+    this.input = c;
+  };
+
+  renderSuggestion = (suggestion, i) => {
+    const { selectedSuggestion } = this.state;
+    let inner, key;
+
+    if (suggestion.type === 'emoji') {
+      inner = <AutosuggestEmoji emoji={suggestion} />;
+      key   = suggestion.id;
+    } else if (suggestion.type ==='hashtag') {
+      inner = <AutosuggestHashtag tag={suggestion} />;
+      key   = suggestion.name;
+    } else if (suggestion.type === 'account') {
+      inner = <AutosuggestAccountContainer id={suggestion.id} />;
+      key   = suggestion.id;
+    }
+
+    return (
+      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
+        {inner}
+      </div>
+    );
+  };
+
+  render () {
+    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, lang, spellCheck } = this.props;
+    const { suggestionsHidden } = this.state;
+
+    return (
+      <div className='autosuggest-input'>
+        <label>
+          <span style={{ display: 'none' }}>{placeholder}</span>
+
+          <input
+            type='text'
+            ref={this.setInput}
+            disabled={disabled}
+            placeholder={placeholder}
+            autoFocus={autoFocus}
+            value={value}
+            onChange={this.onChange}
+            onKeyDown={this.onKeyDown}
+            onKeyUp={onKeyUp}
+            onFocus={this.onFocus}
+            onBlur={this.onBlur}
+            dir='auto'
+            aria-autocomplete='list'
+            id={id}
+            className={className}
+            maxLength={maxLength}
+            lang={lang}
+            spellCheck={spellCheck}
+          />
+        </label>
+
+        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
+          {suggestions.map(this.renderSuggestion)}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
new file mode 100644
index 000000000..6e6e567b9
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
@@ -0,0 +1,235 @@
+import React from 'react';
+import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import AutosuggestHashtag from './autosuggest_hashtag';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Textarea from 'react-textarea-autosize';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
+  let right = str.slice(caretPosition).search(/[\s\u200B]/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
+    return [null, null];
+  }
+
+  word = word.trim().toLowerCase();
+
+  if (word.length > 0) {
+    return [left, word];
+  } else {
+    return [null, null];
+  }
+};
+
+export default class AutosuggestTextarea extends ImmutablePureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    disabled: PropTypes.bool,
+    placeholder: PropTypes.string,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    onSuggestionsClearRequested: PropTypes.func.isRequired,
+    onSuggestionsFetchRequested: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onKeyUp: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    onPaste: PropTypes.func.isRequired,
+    autoFocus: PropTypes.bool,
+    lang: PropTypes.string,
+  };
+
+  static defaultProps = {
+    autoFocus: true,
+  };
+
+  state = {
+    suggestionsHidden: true,
+    focused: false,
+    selectedSuggestion: 0,
+    lastToken: null,
+    tokenStart: 0,
+  };
+
+  onChange = (e) => {
+    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+    if (token !== null && this.state.lastToken !== token) {
+      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+      this.props.onSuggestionsFetchRequested(token);
+    } else if (token === null) {
+      this.setState({ lastToken: null });
+      this.props.onSuggestionsClearRequested();
+    }
+
+    this.props.onChange(e);
+  };
+
+  onKeyDown = (e) => {
+    const { suggestions, disabled } = this.props;
+    const { selectedSuggestion, suggestionsHidden } = this.state;
+
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    if (e.which === 229 || e.isComposing) {
+      // Ignore key events during text composition
+      // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+      return;
+    }
+
+    switch(e.key) {
+    case 'Escape':
+      if (suggestions.size === 0 || suggestionsHidden) {
+        document.querySelector('.ui').parentElement.focus();
+      } else {
+        e.preventDefault();
+        this.setState({ suggestionsHidden: true });
+      }
+
+      break;
+    case 'ArrowDown':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+
+      break;
+    case 'ArrowUp':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+
+      break;
+    case 'Enter':
+    case 'Tab':
+      // Select suggestion
+      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+      }
+
+      break;
+    }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
+  };
+
+  onBlur = () => {
+    this.setState({ suggestionsHidden: true, focused: false });
+  };
+
+  onFocus = (e) => {
+    this.setState({ focused: true });
+    if (this.props.onFocus) {
+      this.props.onFocus(e);
+    }
+  };
+
+  onSuggestionClick = (e) => {
+    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+    e.preventDefault();
+    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.textarea.focus();
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  setTextarea = (c) => {
+    this.textarea = c;
+  };
+
+  onPaste = (e) => {
+    if (e.clipboardData && e.clipboardData.files.length === 1) {
+      this.props.onPaste(e.clipboardData.files);
+      e.preventDefault();
+    }
+  };
+
+  renderSuggestion = (suggestion, i) => {
+    const { selectedSuggestion } = this.state;
+    let inner, key;
+
+    if (suggestion.type === 'emoji') {
+      inner = <AutosuggestEmoji emoji={suggestion} />;
+      key   = suggestion.id;
+    } else if (suggestion.type === 'hashtag') {
+      inner = <AutosuggestHashtag tag={suggestion} />;
+      key   = suggestion.name;
+    } else if (suggestion.type === 'account') {
+      inner = <AutosuggestAccountContainer id={suggestion.id} />;
+      key   = suggestion.id;
+    }
+
+    return (
+      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
+        {inner}
+      </div>
+    );
+  };
+
+  render () {
+    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
+    const { suggestionsHidden } = this.state;
+
+    return [
+      <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
+        <div className='autosuggest-textarea'>
+          <label>
+            <span style={{ display: 'none' }}>{placeholder}</span>
+
+            <Textarea
+              ref={this.setTextarea}
+              className='autosuggest-textarea__textarea'
+              disabled={disabled}
+              placeholder={placeholder}
+              autoFocus={autoFocus}
+              value={value}
+              onChange={this.onChange}
+              onKeyDown={this.onKeyDown}
+              onKeyUp={onKeyUp}
+              onFocus={this.onFocus}
+              onBlur={this.onBlur}
+              onPaste={this.onPaste}
+              dir='auto'
+              aria-autocomplete='list'
+              lang={lang}
+            />
+          </label>
+        </div>
+        {children}
+      </div>,
+
+      <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
+        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
+          {suggestions.map(this.renderSuggestion)}
+        </div>
+      </div>,
+    ];
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/avatar.jsx b/app/javascript/flavours/glitch/components/avatar.jsx
new file mode 100644
index 000000000..f30b33e70
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/avatar.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+import classNames from 'classnames';
+
+export default class Avatar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    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 style = {
+      ...this.props.style,
+      width: `${size}px`,
+      height: `${size}px`,
+      backgroundSize: `${size}px ${size}px`,
+    };
+
+    if (account) {
+      const src = account.get('avatar');
+      const staticSrc = account.get('avatar_static');
+
+      if (hovering || animate) {
+        style.backgroundImage = `url(${src})`;
+      } else {
+        style.backgroundImage = `url(${staticSrc})`;
+      }
+    }
+
+    return (
+      <div
+        className={classNames('account__avatar', { 'account__avatar-inline': inline }, className)}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        style={style}
+        data-avatar-of={account && `@${account.get('acct')}`}
+        role='img'
+        aria-label={account?.get('acct')}
+      />
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/avatar_composite.jsx b/app/javascript/flavours/glitch/components/avatar_composite.jsx
new file mode 100644
index 000000000..c0ce7761d
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/avatar_composite.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+
+export default class AvatarComposite extends React.PureComponent {
+
+  static propTypes = {
+    accounts: ImmutablePropTypes.list.isRequired,
+    animate: PropTypes.bool,
+    size: PropTypes.number.isRequired,
+  };
+
+  static defaultProps = {
+    animate: autoPlayGif,
+  };
+
+  renderItem (account, size, index) {
+    const { animate } = 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 = '1px';
+      } else {
+        left = '1px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '1px';
+      } else if (index > 0) {
+        left = '1px';
+      }
+
+      if (index === 1) {
+        bottom = '1px';
+      } else if (index > 1) {
+        top = '1px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '1px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '1px';
+      }
+
+      if (index < 2) {
+        bottom = '1px';
+      } else {
+        top = '1px';
+      }
+    }
+
+    const style = {
+      left: left,
+      top: top,
+      right: right,
+      bottom: bottom,
+      width: `${width}%`,
+      height: `${height}%`,
+      backgroundSize: 'cover',
+      backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
+    };
+
+    return (
+      <a
+        href={account.get('url')}
+        target='_blank'
+        onClick={(e) => this.props.onAccountClick(account.get('acct'), e)}
+        title={`@${account.get('acct')}`}
+        key={account.get('id')}
+      >
+        <div style={style} data-avatar-of={`@${account.get('acct')}`} />
+      </a>
+    );
+  }
+
+  render() {
+    const { accounts, size } = this.props;
+
+    return (
+      <div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
+        {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))}
+
+        {accounts.size > 4 && (
+          <span className='account__avatar-composite__label'>
+            +{accounts.size - 4}
+          </span>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.jsx b/app/javascript/flavours/glitch/components/avatar_overlay.jsx
new file mode 100644
index 000000000..01dec587a
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/avatar_overlay.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from 'flavours/glitch/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/blurhash.jsx b/app/javascript/flavours/glitch/components/blurhash.jsx
new file mode 100644
index 000000000..2af5cfc56
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/blurhash.jsx
@@ -0,0 +1,65 @@
+// @ts-check
+
+import { decode } from 'blurhash';
+import React, { useRef, useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+/**
+ * @typedef BlurhashPropsBase
+ * @property {string?} hash Hash to render
+ * @property {number} width
+ * Width of the blurred region in pixels. Defaults to 32
+ * @property {number} [height]
+ * Height of the blurred region in pixels. Defaults to width
+ * @property {boolean} [dummy]
+ * Whether dummy mode is enabled. If enabled, nothing is rendered
+ * and canvas left untouched
+ */
+
+/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
+
+/**
+ * Component that is used to render blurred of blurhash string
+ *
+ * @param {BlurhashProps} param1 Props of the component
+ * @returns Canvas which will render blurred region element to embed
+ */
+function Blurhash({
+  hash,
+  width = 32,
+  height = width,
+  dummy = false,
+  ...canvasProps
+}) {
+  const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
+
+  useEffect(() => {
+    const { current: canvas } = canvasRef;
+    canvas.width = canvas.width; // resets canvas
+
+    if (dummy || !hash) return;
+
+    try {
+      const pixels = decode(hash, width, height);
+      const ctx = canvas.getContext('2d');
+      const imageData = new ImageData(pixels, width, height);
+
+      ctx.putImageData(imageData, 0, 0);
+    } catch (err) {
+      console.error('Blurhash decoding failure', { err, hash });
+    }
+  }, [dummy, hash, width, height]);
+
+  return (
+    <canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
+  );
+}
+
+Blurhash.propTypes = {
+  hash: PropTypes.string.isRequired,
+  width: PropTypes.number,
+  height: PropTypes.number,
+  dummy: PropTypes.bool,
+};
+
+export default React.memo(Blurhash);
diff --git a/app/javascript/flavours/glitch/components/button.jsx b/app/javascript/flavours/glitch/components/button.jsx
new file mode 100644
index 000000000..40b8f5a15
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/button.jsx
@@ -0,0 +1,52 @@
+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,
+    className: PropTypes.string,
+    title: PropTypes.string,
+    children: PropTypes.node,
+  };
+
+  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,
+    };
+
+    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/check.jsx b/app/javascript/flavours/glitch/components/check.jsx
new file mode 100644
index 000000000..ee2ef1595
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/check.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+const Check = () => (
+  <svg width='14' height='11' viewBox='0 0 14 11'>
+    <path d='M11.264 0L5.26 6.004 2.103 2.847 0 4.95l5.26 5.26 8.108-8.107L11.264 0' fill='currentColor' fillRule='evenodd' />
+  </svg>
+);
+
+export default Check;
diff --git a/app/javascript/flavours/glitch/components/column.jsx b/app/javascript/flavours/glitch/components/column.jsx
new file mode 100644
index 000000000..47293ef18
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { scrollTop } from '../scroll';
+
+export default class Column extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+    extraClasses: PropTypes.string,
+    name: PropTypes.string,
+    label: PropTypes.string,
+    bindToDocument: PropTypes.bool,
+  };
+
+  scrollTop () {
+    const scrollable = this.props.bindToDocument ? document.scrollingElement : 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 () {
+    if (this.props.bindToDocument) {
+      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
+    } else {
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.props.bindToDocument) {
+      document.removeEventListener('wheel', this.handleWheel);
+    } else {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  render () {
+    const { children, extraClasses, name, label } = this.props;
+
+    return (
+      <div role='region' aria-label={label} data-column={name} className={`column ${extraClasses || ''}`} ref={this.setRef}>
+        {children}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/column_back_button.jsx b/app/javascript/flavours/glitch/components/column_back_button.jsx
new file mode 100644
index 000000000..e9e2615cb
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column_back_button.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+import { createPortal } from 'react-dom';
+
+export default class ColumnBackButton extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    multiColumn: PropTypes.bool,
+  };
+
+  handleClick = (event) => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history.state) {
+      const state = this.context.router.history.location.state;
+      if (event.shiftKey && state && state.mastodonBackSteps) {
+        this.context.router.history.go(-state.mastodonBackSteps);
+      } else {
+        this.context.router.history.goBack();
+      }
+    } else {
+      this.context.router.history.push('/');
+    }
+  };
+
+  render () {
+    const { multiColumn } = this.props;
+
+    const component = (
+      <button onClick={this.handleClick} className='column-back-button'>
+        <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
+        <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+      </button>
+    );
+
+    if (multiColumn) {
+      return component;
+    } else {
+      // The portal container and the component may be rendered to the DOM in
+      // the same React render pass, so the container might not be available at
+      // the time `render()` is called.
+      const container = document.getElementById('tabs-bar__portal');
+      if (container === null) {
+        // The container wasn't available, force a re-render so that the
+        // component can eventually be inserted in the container and not scroll
+        // with the rest of the area.
+        this.forceUpdate();
+        return component;
+      } else {
+        return createPortal(component, container);
+      }
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.jsx b/app/javascript/flavours/glitch/components/column_back_button_slim.jsx
new file mode 100644
index 000000000..b43d85b3b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column_back_button_slim.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class ColumnBackButtonSlim extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  handleClick = (event) => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history.state) {
+      const state = this.context.router.history.location.state;
+      if (event.shiftKey && state && state.mastodonBackSteps) {
+        this.context.router.history.go(-state.mastodonBackSteps);
+      } else {
+        this.context.router.history.goBack();
+      }
+    } else {
+      this.context.router.history.push('/');
+    }
+  };
+
+  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'>
+          <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
+          <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/column_header.jsx b/app/javascript/flavours/glitch/components/column_header.jsx
new file mode 100644
index 000000000..6fbe2955d
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column_header.jsx
@@ -0,0 +1,221 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { createPortal } from 'react-dom';
+import classNames from 'classnames';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+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' },
+});
+
+class ColumnHeader extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    title: PropTypes.node,
+    icon: PropTypes.string,
+    active: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    extraButton: PropTypes.node,
+    showBackButton: PropTypes.bool,
+    children: PropTypes.node,
+    pinned: PropTypes.bool,
+    placeholder: PropTypes.bool,
+    onPin: PropTypes.func,
+    onMove: PropTypes.func,
+    onClick: PropTypes.func,
+    appendContent: PropTypes.node,
+    collapseIssues: PropTypes.bool,
+  };
+
+  state = {
+    collapsed: true,
+    animating: false,
+  };
+
+  historyBack = (skip) => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history.state) {
+      const state = this.context.router.history.location.state;
+      if (skip && state && state.mastodonBackSteps) {
+        this.context.router.history.go(-state.mastodonBackSteps);
+      } else {
+        this.context.router.history.goBack();
+      }
+    } else {
+      this.context.router.history.push('/');
+    }
+  };
+
+  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 = (event) => {
+    this.historyBack(event.shiftKey);
+  };
+
+  handleTransitionEnd = () => {
+    this.setState({ animating: false });
+  };
+
+  handlePin = () => {
+    if (!this.props.pinned) {
+      this.historyBack();
+    }
+    this.props.onPin();
+  };
+
+  render () {
+    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
+    const { collapsed, animating } = this.state;
+
+    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,
+    });
+
+    let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+    if (children) {
+      extraContent = (
+        <div key='extra-content' className='column-header__collapsible__extra'>
+          {children}
+        </div>
+      );
+    }
+
+    if (multiColumn && pinned) {
+      pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='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='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
+          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
+        </div>
+      );
+    } else if (multiColumn && this.props.onPin) {
+      pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
+    }
+
+    if (!pinned && (multiColumn || showBackButton)) {
+      backButton = (
+        <button onClick={this.handleBackClick} className='column-header__back-button'>
+          <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
+          <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+        </button>
+      );
+    }
+
+    const collapsedContent = [
+      extraContent,
+    ];
+
+    if (multiColumn) {
+      collapsedContent.push(pinButton);
+      collapsedContent.push(moveButtons);
+    }
+
+    if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
+      collapseButton = (
+        <button
+          className={collapsibleButtonClassName}
+          title={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
+          onClick={this.handleToggleClick}
+        >
+          <i className='icon-with-badge'>
+            <Icon id='sliders' />
+            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+          </i>
+        </button>
+      );
+    }
+
+    const hasTitle = icon && title;
+
+    const component = (
+      <div className={wrapperClassName}>
+        <h1 className={buttonClassName}>
+          {hasTitle && (
+            <button onClick={this.handleTitleClick}>
+              <Icon id={icon} fixedWidth className='column-header__icon' />
+              {title}
+            </button>
+          )}
+
+          {!hasTitle && backButton}
+
+          <div className='column-header__buttons'>
+            {hasTitle && backButton}
+            {extraButton}
+            {collapseButton}
+          </div>
+        </h1>
+
+        <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
+          <div className='column-header__collapsible-inner'>
+            {(!collapsed || animating) && collapsedContent}
+          </div>
+        </div>
+
+        {appendContent}
+      </div>
+    );
+
+    if (multiColumn || placeholder) {
+      return component;
+    } else {
+      // The portal container and the component may be rendered to the DOM in
+      // the same React render pass, so the container might not be available at
+      // the time `render()` is called.
+      const container = document.getElementById('tabs-bar__portal');
+      if (container === null) {
+        // The container wasn't available, force a re-render so that the
+        // component can eventually be inserted in the container and not scroll
+        // with the rest of the area.
+        this.forceUpdate();
+        return component;
+      } else {
+        return createPortal(component, container);
+      }
+    }
+  }
+
+}
+
+export default injectIntl(ColumnHeader);
diff --git a/app/javascript/flavours/glitch/components/common_counter.jsx b/app/javascript/flavours/glitch/components/common_counter.jsx
new file mode 100644
index 000000000..dd9b62de9
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/common_counter.jsx
@@ -0,0 +1,62 @@
+// @ts-check
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+/**
+ * Returns custom renderer for one of the common counter types
+ *
+ * @param {"statuses" | "following" | "followers"} counterType
+ * Type of the counter
+ * @param {boolean} isBold Whether display number must be displayed in bold
+ * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
+ * Renderer function
+ * @throws If counterType is not covered by this function
+ */
+export function counterRenderer(counterType, isBold = true) {
+  /**
+   * @type {(displayNumber: JSX.Element) => JSX.Element}
+   */
+  const renderCounter = isBold
+    ? (displayNumber) => <strong>{displayNumber}</strong>
+    : (displayNumber) => displayNumber;
+
+  switch (counterType) {
+  case 'statuses': {
+    return (displayNumber, pluralReady) => (
+      <FormattedMessage
+        id='account.statuses_counter'
+        defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
+        values={{
+          count: pluralReady,
+          counter: renderCounter(displayNumber),
+        }}
+      />
+    );
+  }
+  case 'following': {
+    return (displayNumber, pluralReady) => (
+      <FormattedMessage
+        id='account.following_counter'
+        defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
+        values={{
+          count: pluralReady,
+          counter: renderCounter(displayNumber),
+        }}
+      />
+    );
+  }
+  case 'followers': {
+    return (displayNumber, pluralReady) => (
+      <FormattedMessage
+        id='account.followers_counter'
+        defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
+        values={{
+          count: pluralReady,
+          counter: renderCounter(displayNumber),
+        }}
+      />
+    );
+  }
+  default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
+  }
+}
diff --git a/app/javascript/flavours/glitch/components/dismissable_banner.jsx b/app/javascript/flavours/glitch/components/dismissable_banner.jsx
new file mode 100644
index 000000000..9b3faf6f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/dismissable_banner.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import IconButton from './icon_button';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import { bannerSettings } from 'flavours/glitch/settings';
+
+const messages = defineMessages({
+  dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
+});
+
+class DismissableBanner extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    children: PropTypes.node,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    visible: !bannerSettings.get(this.props.id),
+  };
+
+  handleDismiss = () => {
+    const { id } = this.props;
+    this.setState({ visible: false }, () => bannerSettings.set(id, true));
+  };
+
+  render () {
+    const { visible } = this.state;
+
+    if (!visible) {
+      return null;
+    }
+
+    const { children, intl } = this.props;
+
+    return (
+      <div className='dismissable-banner'>
+        <div className='dismissable-banner__message'>
+          {children}
+        </div>
+
+        <div className='dismissable-banner__action'>
+          <IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(DismissableBanner);
diff --git a/app/javascript/flavours/glitch/components/display_name.jsx b/app/javascript/flavours/glitch/components/display_name.jsx
new file mode 100644
index 000000000..19f63ec60
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/display_name.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+export default class DisplayName extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    className: PropTypes.string,
+    inline: PropTypes.bool,
+    localDomain: PropTypes.string,
+    others: ImmutablePropTypes.list,
+    handleClick: PropTypes.func,
+  };
+
+  handleMouseEnter = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-original');
+    }
+  };
+
+  handleMouseLeave = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-static');
+    }
+  };
+
+  render() {
+    const { account, className, inline, localDomain, others, onAccountClick } = this.props;
+
+    const computedClass = classNames('display-name', { inline }, className);
+
+    let displayName, suffix;
+    let acct;
+
+    if (account) {
+      acct = account.get('acct');
+
+      if (acct.indexOf('@') === -1 && localDomain) {
+        acct = `${acct}@${localDomain}`;
+      }
+    }
+
+    if (others && others.size > 0) {
+      displayName = others.take(2).map(a => (
+        <a
+          href={a.get('url')}
+          target='_blank'
+          onClick={(e) => onAccountClick(a.get('acct'), e)}
+          title={`@${a.get('acct')}`}
+          rel='noopener noreferrer'
+        >
+          <bdi key={a.get('id')}>
+            <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} />
+          </bdi>
+        </a>
+      )).reduce((prev, cur) => [prev, ', ', cur]);
+
+      if (others.size - 2 > 0) {
+        displayName.push(` +${others.size - 2}`);
+      }
+
+      suffix = (
+        <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('acct'), e)} rel='noopener noreferrer'>
+          <span className='display-name__account'>@{acct}</span>
+        </a>
+      );
+    } else if (account) {
+      displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
+      suffix      = <span className='display-name__account'>@{acct}</span>;
+    } else {
+      displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
+      suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
+    }
+
+    return (
+      <span className={computedClass} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        {displayName}
+        {inline ? ' ' : null}
+        {suffix}
+      </span>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/domain.jsx b/app/javascript/flavours/glitch/components/domain.jsx
new file mode 100644
index 000000000..85ebdbde9
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/domain.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+});
+
+class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    domain: PropTypes.string,
+    onUnblockDomain: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleDomainUnblock = () => {
+    this.props.onUnblockDomain(this.props.domain);
+  };
+
+  render () {
+    const { domain, intl } = this.props;
+
+    return (
+      <div className='domain'>
+        <div className='domain__wrapper'>
+          <span className='domain__domain-name'>
+            <strong>{domain}</strong>
+          </span>
+
+          <div className='domain__buttons'>
+            <IconButton active icon='unlock' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Account);
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.jsx b/app/javascript/flavours/glitch/components/dropdown_menu.jsx
new file mode 100644
index 000000000..f4b6e059f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.jsx
@@ -0,0 +1,335 @@
+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/Overlay';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import { CircularProgress } from 'flavours/glitch/components/loading_indicator';
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+let id = 0;
+
+class DropdownMenu extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+    loading: PropTypes.bool,
+    scrollable: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    openedViaKeyboard: PropTypes.bool,
+    renderItem: PropTypes.func,
+    renderHeader: PropTypes.func,
+    onItemClick: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  };
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('keydown', this.handleKeyDown, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
+    if (this.focusedItem && this.props.openedViaKeyboard) {
+      this.focusedItem.focus({ preventScroll: true });
+    }
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('keydown', this.handleKeyDown, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  setFocusRef = c => {
+    this.focusedItem = c;
+  };
+
+  handleKeyDown = e => {
+    const items = Array.from(this.node.querySelectorAll('a, button'));
+    const index = items.indexOf(document.activeElement);
+    let element = null;
+
+    switch(e.key) {
+    case 'ArrowDown':
+      element = items[index+1] || items[0];
+      break;
+    case 'ArrowUp':
+      element = items[index-1] || items[items.length-1];
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = items[index-1] || items[items.length-1];
+      } else {
+        element = items[index+1] || items[0];
+      }
+      break;
+    case 'Home':
+      element = items[0];
+      break;
+    case 'End':
+      element = items[items.length-1];
+      break;
+    case 'Escape':
+      this.props.onClose();
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  };
+
+  handleItemKeyPress = e => {
+    if (e.key === 'Enter' || e.key === ' ') {
+      this.handleClick(e);
+    }
+  };
+
+  handleClick = e => {
+    const { onItemClick } = this.props;
+    onItemClick(e);
+  };
+
+  renderItem = (option, i) => {
+    if (option === null) {
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
+    }
+
+    const { text, href = '#', target = '_blank', method } = option;
+
+    return (
+      <li className='dropdown-menu__item' key={`${text}-${i}`}>
+        <a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
+          {text}
+        </a>
+      </li>
+    );
+  };
+
+  render () {
+    const { items, scrollable, renderHeader, loading } = this.props;
+
+    let renderItem = this.props.renderItem || this.renderItem;
+
+    return (
+      <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
+        {loading && (
+          <CircularProgress size={30} strokeWidth={3.5} />
+        )}
+
+        {!loading && renderHeader && (
+          <div className='dropdown-menu__container__header'>
+            {renderHeader(items)}
+          </div>
+        )}
+
+        {!loading && (
+          <ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
+            {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
+          </ul>
+        )}
+      </div>
+    );
+  }
+
+}
+
+export default class Dropdown extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    children: PropTypes.node,
+    icon: PropTypes.string,
+    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+    loading: PropTypes.bool,
+    size: PropTypes.number,
+    title: PropTypes.string,
+    disabled: PropTypes.bool,
+    scrollable: PropTypes.bool,
+    status: ImmutablePropTypes.map,
+    isUserTouching: PropTypes.func,
+    onOpen: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    openDropdownId: PropTypes.number,
+    openedViaKeyboard: PropTypes.bool,
+    renderItem: PropTypes.func,
+    renderHeader: PropTypes.func,
+    onItemClick: PropTypes.func,
+  };
+
+  static defaultProps = {
+    title: 'Menu',
+  };
+
+  state = {
+    id: id++,
+  };
+
+  handleClick = ({ type }) => {
+    if (this.state.id === this.props.openDropdownId) {
+      this.handleClose();
+    } else {
+      this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
+    }
+  };
+
+  handleClose = () => {
+    if (this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
+      this.activeElement = null;
+    }
+    this.props.onClose(this.state.id);
+  };
+
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  };
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleMouseDown();
+      break;
+    }
+  };
+
+  handleKeyPress = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleClick(e);
+      e.stopPropagation();
+      e.preventDefault();
+      break;
+    }
+  };
+
+  handleItemClick = e => {
+    const { onItemClick } = this.props;
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+    const item = this.props.items[i];
+
+    this.handleClose();
+
+    if (typeof onItemClick === 'function') {
+      e.preventDefault();
+      onItemClick(item, i);
+    } else if (item && typeof item.action === 'function') {
+      e.preventDefault();
+      item.action();
+    } else if (item && item.to) {
+      e.preventDefault();
+      this.context.router.history.push(item.to);
+    }
+  };
+
+  setTargetRef = c => {
+    this.target = c;
+  };
+
+  findTarget = () => {
+    return this.target;
+  };
+
+  componentWillUnmount = () => {
+    if (this.state.id === this.props.openDropdownId) {
+      this.handleClose();
+    }
+  };
+
+  close = () => {
+    this.handleClose();
+  };
+
+  render () {
+    const {
+      icon,
+      items,
+      size,
+      title,
+      disabled,
+      loading,
+      scrollable,
+      openDropdownId,
+      openedViaKeyboard,
+      children,
+      renderItem,
+      renderHeader,
+    } = this.props;
+
+    const open = this.state.id === openDropdownId;
+
+    const button = children ? React.cloneElement(React.Children.only(children), {
+      onClick: this.handleClick,
+      onMouseDown: this.handleMouseDown,
+      onKeyDown: this.handleButtonKeyDown,
+      onKeyPress: this.handleKeyPress,
+    }) : (
+      <IconButton
+        icon={icon}
+        title={title}
+        active={open}
+        disabled={disabled}
+        size={size}
+        onClick={this.handleClick}
+        onMouseDown={this.handleMouseDown}
+        onKeyDown={this.handleButtonKeyDown}
+        onKeyPress={this.handleKeyPress}
+      />
+    );
+
+    return (
+      <React.Fragment>
+        <span ref={this.setTargetRef}>
+          {button}
+        </span>
+        <Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
+          {({ props, arrowProps, placement }) => (
+            <div {...props}>
+              <div className={`dropdown-animation dropdown-menu ${placement}`}>
+                <div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
+                <DropdownMenu
+                  items={items}
+                  loading={loading}
+                  scrollable={scrollable}
+                  onClose={this.handleClose}
+                  openedViaKeyboard={openedViaKeyboard}
+                  renderItem={renderItem}
+                  renderHeader={renderHeader}
+                  onItemClick={this.handleItemClick}
+                />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </React.Fragment>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..a1519757d
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu';
+import { fetchHistory } from 'flavours/glitch/actions/history';
+import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
+
+const mapStateToProps = (state, { statusId }) => ({
+  openDropdownId: state.getIn(['dropdown_menu', 'openId']),
+  openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
+  items: state.getIn(['history', statusId, 'items']),
+  loading: state.getIn(['history', statusId, 'loading']),
+});
+
+const mapDispatchToProps = (dispatch, { statusId }) => ({
+
+  onOpen (id, onItemClick, keyboard) {
+    dispatch(fetchHistory(statusId));
+    dispatch(openDropdownMenu(id, keyboard));
+  },
+
+  onClose (id) {
+    dispatch(closeDropdownMenu(id));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/flavours/glitch/components/edited_timestamp/index.jsx b/app/javascript/flavours/glitch/components/edited_timestamp/index.jsx
new file mode 100644
index 000000000..6d73fa68c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/edited_timestamp/index.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+import DropdownMenu from './containers/dropdown_menu_container';
+import { connect } from 'react-redux';
+import { openModal } from 'flavours/glitch/actions/modal';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import InlineAccount from 'flavours/glitch/components/inline_account';
+
+const mapDispatchToProps = (dispatch, { statusId }) => ({
+
+  onItemClick (index) {
+    dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
+  },
+
+});
+
+class EditedTimestamp extends React.PureComponent {
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    timestamp: PropTypes.string.isRequired,
+    intl: PropTypes.object.isRequired,
+    onItemClick: PropTypes.func.isRequired,
+  };
+
+  handleItemClick = (item, i) => {
+    const { onItemClick } = this.props;
+    onItemClick(i);
+  };
+
+  renderHeader = items => {
+    return (
+      <FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {{count} time} other {{count} times}}' values={{ count: items.size - 1 }} />
+    );
+  };
+
+  renderItem = (item, index, { onClick, onKeyPress }) => {
+    const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
+    const formattedName = <InlineAccount accountId={item.get('account')} />;
+
+    const label = item.get('original') ? (
+      <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
+    ) : (
+      <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
+    );
+
+    return (
+      <li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
+        <button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
+      </li>
+    );
+  };
+
+  render () {
+    const { timestamp, intl, statusId } = this.props;
+
+    return (
+      <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
+        <button className='dropdown-menu__text-button'>
+          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' />
+        </button>
+      </DropdownMenu>
+    );
+  }
+
+}
+
+export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));
diff --git a/app/javascript/flavours/glitch/components/error_boundary.jsx b/app/javascript/flavours/glitch/components/error_boundary.jsx
new file mode 100644
index 000000000..8518dfc86
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/error_boundary.jsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { source_url } from 'flavours/glitch/initial_state';
+import { preferencesLink } from 'flavours/glitch/utils/backend_links';
+import StackTrace from 'stacktrace-js';
+import { Helmet } from 'react-helmet';
+
+export default class ErrorBoundary extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+  };
+
+  state = {
+    hasError: false,
+    errorMessage: undefined,
+    stackTrace: undefined,
+    mappedStackTrace: undefined,
+    componentStack: undefined,
+  };
+
+  componentDidCatch(error, info) {
+    this.setState({
+      hasError: true,
+      errorMessage: error.toString(),
+      stackTrace: error.stack,
+      componentStack: info && info.componentStack,
+      mappedStackTrace: undefined,
+    });
+
+    StackTrace.fromError(error).then((stackframes) => {
+      this.setState({
+        mappedStackTrace: stackframes.map((sf) => sf.toString()).join('\n'),
+      });
+    }).catch(() => {
+      this.setState({
+        mappedStackTrace: undefined,
+      });
+    });
+  }
+
+  handleReload(e) {
+    e.preventDefault();
+    window.location.reload();
+  }
+
+  render() {
+    const { hasError, errorMessage, stackTrace, mappedStackTrace, componentStack } = this.state;
+
+    if (!hasError) return this.props.children;
+
+    const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
+
+    let debugInfo = '';
+    if (stackTrace) {
+      debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```';
+    }
+    if (mappedStackTrace) {
+      debugInfo += 'Mapped stack trace\n-----------\n\n```\n' + errorMessage + '\n' + mappedStackTrace.toString() + '\n```';
+    }
+    if (componentStack) {
+      if (debugInfo) {
+        debugInfo += '\n\n\n';
+      }
+      debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```';
+    }
+
+    let issueTracker = source_url;
+    if (source_url.match(/^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/)) {
+      issueTracker = source_url + '/issues';
+    }
+
+    return (
+      <div tabIndex='-1'>
+        <div className='error-boundary'>
+          <h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1>
+          <p>
+            <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
+          </p>
+          <ul>
+            { likelyBrowserAddonIssue && (
+              <li>
+                <FormattedMessage
+                  id='web_app_crash.disable_addons'
+                  defaultMessage='Disable browser add-ons or built-in translation tools'
+                />
+              </li>
+            ) }
+            <li>
+              <FormattedMessage
+                id='web_app_crash.report_issue'
+                defaultMessage='Report a bug in the {issuetracker}'
+                values={{ issuetracker: <a href={issueTracker} rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
+              />
+              { debugInfo !== '' && (
+                <details>
+                  <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
+                  <textarea
+                    className='web_app_crash-stacktrace'
+                    value={debugInfo}
+                    rows='10'
+                    readOnly
+                  />
+                </details>
+              )}
+            </li>
+            <li>
+              <FormattedMessage
+                id='web_app_crash.reload_page'
+                defaultMessage='{reload} the current page'
+                values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
+              />
+            </li>
+            { preferencesLink !== undefined && (
+              <li>
+                <FormattedMessage
+                  id='web_app_crash.change_your_settings'
+                  defaultMessage='Change your {settings}'
+                  values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
+                />
+              </li>
+            )}
+          </ul>
+        </div>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/gifv.jsx b/app/javascript/flavours/glitch/components/gifv.jsx
new file mode 100644
index 000000000..9ec201c6c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/gifv.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class GIFV extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    lang: PropTypes.string,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    onClick: PropTypes.func,
+  };
+
+  state = {
+    loading: true,
+  };
+
+  handleLoadedData = () => {
+    this.setState({ loading: false });
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.src !== this.props.src) {
+      this.setState({ loading: true });
+    }
+  }
+
+  handleClick = e => {
+    const { onClick } = this.props;
+
+    if (onClick) {
+      e.stopPropagation();
+      onClick();
+    }
+  };
+
+  render () {
+    const { src, width, height, alt, lang } = this.props;
+    const { loading } = this.state;
+
+    return (
+      <div className='gifv' style={{ position: 'relative' }}>
+        {loading && (
+          <canvas
+            width={width}
+            height={height}
+            role='button'
+            tabIndex='0'
+            aria-label={alt}
+            title={alt}
+            lang={lang}
+            onClick={this.handleClick}
+          />
+        )}
+
+        <video
+          src={src}
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          title={alt}
+          lang={lang}
+          muted
+          loop
+          autoPlay
+          playsInline
+          onClick={this.handleClick}
+          onLoadedData={this.handleLoadedData}
+          style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/hashtag.jsx b/app/javascript/flavours/glitch/components/hashtag.jsx
new file mode 100644
index 000000000..422b9a8fa
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/hashtag.jsx
@@ -0,0 +1,115 @@
+// @ts-check
+import React from 'react';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Permalink from './permalink';
+import ShortNumber from 'flavours/glitch/components/short_number';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import classNames from 'classnames';
+
+class SilentErrorBoundary extends React.Component {
+
+  static propTypes = {
+    children: PropTypes.node,
+  };
+
+  state = {
+    error: false,
+  };
+
+  componentDidCatch () {
+    this.setState({ error: true });
+  }
+
+  render () {
+    if (this.state.error) {
+      return null;
+    }
+
+    return this.props.children;
+  }
+
+}
+
+/**
+ * Used to render counter of how much people are talking about hashtag
+ *
+ * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
+ */
+export const accountsCountRenderer = (displayNumber, pluralReady) => (
+  <FormattedMessage
+    id='trends.counter_by_accounts'
+    defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}'
+    values={{
+      count: pluralReady,
+      counter: <strong>{displayNumber}</strong>,
+      days: 2,
+    }}
+  />
+);
+
+export const ImmutableHashtag = ({ hashtag }) => (
+  <Hashtag
+    name={hashtag.get('name')}
+    href={hashtag.get('url')}
+    to={`/tags/${hashtag.get('name')}`}
+    people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+    history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+  />
+);
+
+ImmutableHashtag.propTypes = {
+  hashtag: ImmutablePropTypes.map.isRequired,
+};
+
+const Hashtag = ({ name, href, to, people, uses, history, className, description, withGraph }) => (
+  <div className={classNames('trends__item', className)}>
+    <div className='trends__item__name'>
+      <Permalink href={href} to={to}>
+        {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
+      </Permalink>
+
+      {description ? (
+        <span>{description}</span>
+      ) : (
+        typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />
+      )}
+    </div>
+
+    {typeof uses !== 'undefined' && (
+      <div className='trends__item__current'>
+        <ShortNumber value={uses} />
+      </div>
+    )}
+
+    {withGraph && (
+      <div className='trends__item__sparkline'>
+        <SilentErrorBoundary>
+          <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
+            <SparklinesCurve style={{ fill: 'none' }} />
+          </Sparklines>
+        </SilentErrorBoundary>
+      </div>
+    )}
+  </div>
+);
+
+Hashtag.propTypes = {
+  name: PropTypes.string,
+  href: PropTypes.string,
+  to: PropTypes.string,
+  people: PropTypes.number,
+  description: PropTypes.node,
+  uses: PropTypes.number,
+  history: PropTypes.arrayOf(PropTypes.number),
+  className: PropTypes.string,
+  withGraph: PropTypes.bool,
+};
+
+Hashtag.defaultProps = {
+  withGraph: true,
+};
+
+export default Hashtag;
diff --git a/app/javascript/flavours/glitch/components/icon.jsx b/app/javascript/flavours/glitch/components/icon.jsx
new file mode 100644
index 000000000..d8a17722f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class Icon extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    className: PropTypes.string,
+    fixedWidth: PropTypes.bool,
+  };
+
+  render () {
+    const { id, className, fixedWidth, ...other } = this.props;
+
+    return (
+      <i role='img' className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/icon_button.jsx b/app/javascript/flavours/glitch/components/icon_button.jsx
new file mode 100644
index 000000000..10d7926be
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon_button.jsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import Motion from '../features/ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
+
+export default class IconButton extends React.PureComponent {
+
+  static propTypes = {
+    className: PropTypes.string,
+    title: PropTypes.string.isRequired,
+    icon: PropTypes.string.isRequired,
+    onClick: PropTypes.func,
+    onMouseDown: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    onKeyPress: PropTypes.func,
+    size: PropTypes.number,
+    active: PropTypes.bool,
+    expanded: PropTypes.bool,
+    style: PropTypes.object,
+    activeStyle: PropTypes.object,
+    disabled: PropTypes.bool,
+    inverted: PropTypes.bool,
+    animate: PropTypes.bool,
+    overlay: PropTypes.bool,
+    tabIndex: PropTypes.string,
+    label: PropTypes.string,
+    counter: PropTypes.number,
+    obfuscateCount: PropTypes.bool,
+    href: PropTypes.string,
+    ariaHidden: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    size: 18,
+    active: false,
+    disabled: false,
+    animate: false,
+    overlay: false,
+    tabIndex: '0',
+    ariaHidden: false,
+  };
+
+  state = {
+    activate: false,
+    deactivate: false,
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (!nextProps.animate) return;
+
+    if (this.props.active && !nextProps.active) {
+      this.setState({ activate: false, deactivate: true });
+    } else if (!this.props.active && nextProps.active) {
+      this.setState({ activate: true, deactivate: false });
+    }
+  }
+
+  handleClick = (e) =>  {
+    e.preventDefault();
+
+    if (!this.props.disabled) {
+      this.props.onClick(e);
+    }
+  };
+
+  handleKeyPress = (e) => {
+    if (this.props.onKeyPress && !this.props.disabled) {
+      this.props.onKeyPress(e);
+    }
+  };
+
+  handleMouseDown = (e) => {
+    if (!this.props.disabled && this.props.onMouseDown) {
+      this.props.onMouseDown(e);
+    }
+  };
+
+  handleKeyDown = (e) => {
+    if (!this.props.disabled && this.props.onKeyDown) {
+      this.props.onKeyDown(e);
+    }
+  };
+
+  render () {
+    // Hack required for some icons which have an overriden size
+    let containerSize = '1.28571429em';
+    if (this.props.style?.fontSize) {
+      containerSize = `${this.props.size * 1.28571429}px`;
+    }
+
+    let style = {
+      fontSize: `${this.props.size}px`,
+      height: containerSize,
+      lineHeight: `${this.props.size}px`,
+      ...this.props.style,
+      ...(this.props.active ? this.props.activeStyle : {}),
+    };
+    if (!this.props.label) {
+      style.width = containerSize;
+    } else {
+      style.textAlign = 'left';
+    }
+
+    const {
+      active,
+      className,
+      disabled,
+      expanded,
+      icon,
+      inverted,
+      overlay,
+      tabIndex,
+      title,
+      counter,
+      obfuscateCount,
+      href,
+      ariaHidden,
+    } = this.props;
+
+    const {
+      activate,
+      deactivate,
+    } = this.state;
+
+    const classes = classNames(className, 'icon-button', {
+      active,
+      disabled,
+      inverted,
+      activate,
+      deactivate,
+      overlayed: overlay,
+      'icon-button--with-counter': typeof counter !== 'undefined',
+    });
+
+    if (typeof counter !== 'undefined') {
+      style.width = 'auto';
+    }
+
+    let contents = (
+      <React.Fragment>
+        <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
+        {this.props.label}
+      </React.Fragment>
+    );
+
+    if (href && !this.prop) {
+      contents = (
+        <a href={href} target='_blank' rel='noopener noreferrer'>
+          {contents}
+        </a>
+      );
+    }
+
+    return (
+      <button
+        aria-label={title}
+        aria-expanded={expanded}
+        aria-hidden={ariaHidden}
+        title={title}
+        className={classes}
+        onClick={this.handleClick}
+        onMouseDown={this.handleMouseDown}
+        onKeyDown={this.handleKeyDown}
+        onKeyPress={this.handleKeyPress}
+        style={style}
+        tabIndex={tabIndex}
+        disabled={disabled}
+      >
+        {contents}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.jsx b/app/javascript/flavours/glitch/components/icon_with_badge.jsx
new file mode 100644
index 000000000..a42ba4589
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon_with_badge.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+
+const formatNumber = num => num > 40 ? '40+' : num;
+
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
+  <i className='icon-with-badge'>
+    <Icon id={id} fixedWidth className={className} />
+    {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+    {issueBadge && <i className='icon-with-badge__issue-badge' />}
+  </i>
+);
+
+IconWithBadge.propTypes = {
+  id: PropTypes.string.isRequired,
+  count: PropTypes.number.isRequired,
+  issueBadge: PropTypes.bool,
+  className: PropTypes.string,
+};
+
+export default IconWithBadge;
diff --git a/app/javascript/flavours/glitch/components/image.jsx b/app/javascript/flavours/glitch/components/image.jsx
new file mode 100644
index 000000000..6e81ddf08
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/image.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Blurhash from './blurhash';
+import classNames from 'classnames';
+
+export default class Image extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string,
+    srcSet: PropTypes.string,
+    blurhash: PropTypes.string,
+    className: PropTypes.string,
+  };
+
+  state = {
+    loaded: false,
+  };
+
+  handleLoad = () => this.setState({ loaded: true });
+
+  render () {
+    const { src, srcSet, blurhash, className } = this.props;
+    const { loaded } = this.state;
+
+    return (
+      <div className={classNames('image', { loaded }, className)} role='presentation'>
+        {blurhash && <Blurhash hash={blurhash} className='image__preview' />}
+        <img src={src} srcSet={srcSet} alt='' onLoad={this.handleLoad} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/inline_account.jsx b/app/javascript/flavours/glitch/components/inline_account.jsx
new file mode 100644
index 000000000..c04618d66
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/inline_account.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Avatar from 'flavours/glitch/components/avatar';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+class InlineAccount extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <span className='inline-account'>
+        <Avatar size={13} account={account} /> <strong>{account.get('username')}</strong>
+      </span>
+    );
+  }
+
+}
+
+export default connect(makeMapStateToProps)(InlineAccount);
diff --git a/app/javascript/flavours/glitch/components/intersection_observer_article.jsx b/app/javascript/flavours/glitch/components/intersection_observer_article.jsx
new file mode 100644
index 000000000..77cd66358
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/intersection_observer_article.jsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
+
+// 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;
+    }
+    // If we are and remain hidden, diff based on props
+    if (isUnrendered) {
+      return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
+    }
+    // Else, assume the children have changed
+    return true;
+  }
+
+
+  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 !== false && !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/mastodon/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;
+
+    const style = {};
+
+    if (!isIntersecting && (isHidden || cachedHeight)) {
+      style.height = `${this.height || cachedHeight || 150}px`;
+      style.opacity = 0;
+      style.overflow = 'hidden';
+    }
+
+    return (
+      <article
+        ref={this.handleRef}
+        aria-posinset={index + 1}
+        aria-setsize={listLength}
+        data-id={id}
+        tabIndex='0'
+        style={style}
+      >
+        {children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || !!cachedHeight) })}
+      </article>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/link.jsx b/app/javascript/flavours/glitch/components/link.jsx
new file mode 100644
index 000000000..bbec121a8
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/link.jsx
@@ -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/utils/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_gap.jsx b/app/javascript/flavours/glitch/components/load_gap.jsx
new file mode 100644
index 000000000..e70365d9e
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/load_gap.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
+});
+
+class LoadGap extends React.PureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    maxId: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onClick(this.props.maxId);
+  };
+
+  render () {
+    const { disabled, intl } = this.props;
+
+    return (
+      <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
+        <Icon id='ellipsis-h' />
+      </button>
+    );
+  }
+
+}
+
+export default injectIntl(LoadGap);
diff --git a/app/javascript/flavours/glitch/components/load_more.jsx b/app/javascript/flavours/glitch/components/load_more.jsx
new file mode 100644
index 000000000..ab9428e35
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/load_more.jsx
@@ -0,0 +1,27 @@
+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,
+    disabled: PropTypes.bool,
+    visible: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    visible: true,
+  };
+
+  render() {
+    const { disabled, visible } = this.props;
+
+    return (
+      <button className='load-more' disabled={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/load_pending.jsx b/app/javascript/flavours/glitch/components/load_pending.jsx
new file mode 100644
index 000000000..a75259146
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/load_pending.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class LoadPending extends React.PureComponent {
+
+  static propTypes = {
+    onClick: PropTypes.func,
+    count: PropTypes.number,
+  };
+
+  render() {
+    const { count } = this.props;
+
+    return (
+      <button className='load-more load-gap' onClick={this.props.onClick}>
+        <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} />
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/loading_indicator.jsx b/app/javascript/flavours/glitch/components/loading_indicator.jsx
new file mode 100644
index 000000000..59f721c50
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/loading_indicator.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export const CircularProgress = ({ size, strokeWidth }) => {
+  const viewBox = `0 0 ${size} ${size}`;
+  const radius  = (size - strokeWidth) / 2;
+
+  return (
+    <svg width={size} heigh={size} viewBox={viewBox} className='circular-progress' role='progressbar'>
+      <circle
+        fill='none'
+        cx={size / 2}
+        cy={size / 2}
+        r={radius}
+        strokeWidth={`${strokeWidth}px`}
+      />
+    </svg>
+  );
+};
+
+CircularProgress.propTypes = {
+  size: PropTypes.number.isRequired,
+  strokeWidth: PropTypes.number.isRequired,
+};
+
+const LoadingIndicator = () => (
+  <div className='loading-indicator'>
+    <CircularProgress size={50} strokeWidth={6} />
+  </div>
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/flavours/glitch/components/logo.jsx b/app/javascript/flavours/glitch/components/logo.jsx
new file mode 100644
index 000000000..ee5c22496
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/logo.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+
+const Logo = () => (
+  <svg viewBox='0 0 261 66' className='logo' role='img'>
+    <title>Mastodon</title>
+    <use xlinkHref='#logo-symbol-wordmark' />
+  </svg>
+);
+
+export default Logo;
diff --git a/app/javascript/flavours/glitch/components/media_attachments.jsx b/app/javascript/flavours/glitch/components/media_attachments.jsx
new file mode 100644
index 000000000..b11d3526f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/media_attachments.jsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video, Audio } from 'flavours/glitch/features/ui/util/async-components';
+import Bundle from 'flavours/glitch/features/ui/components/bundle';
+import noop from 'lodash/noop';
+
+export default class MediaAttachments extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    lang: PropTypes.string,
+    height: PropTypes.number,
+    width: PropTypes.number,
+    revealed: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    height: 110,
+    width: 239,
+  };
+
+  updateOnProps = [
+    'status',
+  ];
+
+  renderLoadingMediaGallery = () => {
+    const { height, width } = this.props;
+
+    return (
+      <div className='media-gallery' style={{ height, width }} />
+    );
+  };
+
+  renderLoadingVideoPlayer = () => {
+    const { height, width } = this.props;
+
+    return (
+      <div className='video-player' style={{ height, width }} />
+    );
+  };
+
+  renderLoadingAudioPlayer = () => {
+    const { height, width } = this.props;
+
+    return (
+      <div className='audio-player' style={{ height, width }} />
+    );
+  };
+
+  render () {
+    const { status, lang, width, height, revealed } = this.props;
+    const mediaAttachments = status.get('media_attachments');
+
+    if (mediaAttachments.size === 0) {
+      return null;
+    }
+
+    if (mediaAttachments.getIn([0, 'type']) === 'audio') {
+      const audio = mediaAttachments.get(0);
+
+      return (
+        <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
+          {Component => (
+            <Component
+              src={audio.get('url')}
+              alt={audio.get('description')}
+              lang={lang || status.get('language')}
+              width={width}
+              height={height}
+              poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+              backgroundColor={audio.getIn(['meta', 'colors', 'background'])}
+              foregroundColor={audio.getIn(['meta', 'colors', 'foreground'])}
+              accentColor={audio.getIn(['meta', 'colors', 'accent'])}
+              duration={audio.getIn(['meta', 'original', 'duration'], 0)}
+            />
+          )}
+        </Bundle>
+      );
+    } else if (mediaAttachments.getIn([0, 'type']) === 'video') {
+      const video = mediaAttachments.get(0);
+
+      return (
+        <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
+          {Component => (
+            <Component
+              preview={video.get('preview_url')}
+              frameRate={video.getIn(['meta', 'original', 'frame_rate'])}
+              blurhash={video.get('blurhash')}
+              src={video.get('url')}
+              alt={video.get('description')}
+              lang={lang || status.get('language')}
+              width={width}
+              height={height}
+              inline
+              sensitive={status.get('sensitive')}
+              revealed={revealed}
+              onOpenVideo={noop}
+            />
+          )}
+        </Bundle>
+      );
+    } else {
+      return (
+        <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
+          {Component => (
+            <Component
+              media={mediaAttachments}
+              lang={lang || status.get('language')}
+              sensitive={status.get('sensitive')}
+              defaultWidth={width}
+              revealed={revealed}
+              height={height}
+              onOpenMedia={noop}
+            />
+          )}
+        </Bundle>
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/media_gallery.jsx b/app/javascript/flavours/glitch/components/media_gallery.jsx
new file mode 100644
index 000000000..b38f732f1
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/media_gallery.jsx
@@ -0,0 +1,409 @@
+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 classNames from 'classnames';
+import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
+import { debounce } from 'lodash';
+import Blurhash from 'flavours/glitch/components/blurhash';
+
+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: '{number, plural, one {Hide image} other {Hide images}}',
+    id: 'media_gallery.toggle_visible',
+  },
+  warning: {
+    defaultMessage: 'Sensitive content',
+    id: 'status.sensitive_warning',
+  },
+});
+
+class Item extends React.PureComponent {
+
+  static propTypes = {
+    attachment: ImmutablePropTypes.map.isRequired,
+    lang: PropTypes.string,
+    standalone: PropTypes.bool,
+    index: PropTypes.number.isRequired,
+    size: PropTypes.number.isRequired,
+    letterbox: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    displayWidth: PropTypes.number,
+    visible: PropTypes.bool.isRequired,
+    autoplay: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    standalone: false,
+    index: 0,
+    size: 1,
+  };
+
+  state = {
+    loaded: false,
+  };
+
+  handleMouseEnter = (e) => {
+    if (this.hoverToPlay()) {
+      e.target.play();
+    }
+  };
+
+  handleMouseLeave = (e) => {
+    if (this.hoverToPlay()) {
+      e.target.pause();
+      e.target.currentTime = 0;
+    }
+  };
+
+  getAutoPlay() {
+    return this.props.autoplay || autoPlayGif;
+  }
+
+  hoverToPlay () {
+    const { attachment } = this.props;
+    return !this.getAutoPlay() && attachment.get('type') === 'gifv';
+  }
+
+  handleClick = (e) => {
+    const { index, onClick } = this.props;
+
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      if (this.hoverToPlay()) {
+        e.target.pause();
+        e.target.currentTime = 0;
+      }
+      e.preventDefault();
+      onClick(index);
+    }
+
+    e.stopPropagation();
+  };
+
+  handleImageLoad = () => {
+    this.setState({ loaded: true });
+  };
+
+  render () {
+    const { attachment, lang, index, size, standalone, letterbox, displayWidth, visible } = 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') === 'unknown') {
+      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}%` }}>
+          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'>
+            <Blurhash
+              hash={attachment.get('blurhash')}
+              className='media-gallery__preview'
+              dummy={!useBlurhash}
+            />
+          </a>
+        </div>
+      );
+    } else 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 && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
+
+      const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
+      const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
+      const x      = ((focusX /  2) + .5) * 100;
+      const y      = ((focusY / -2) + .5) * 100;
+
+      thumbnail = (
+        <a
+          className='media-gallery__item-thumbnail'
+          href={attachment.get('remote_url') || originalUrl}
+          onClick={this.handleClick}
+          target='_blank'
+          rel='noopener noreferrer'
+        >
+          <img
+            className={letterbox ? 'letterbox' : null}
+            src={previewUrl}
+            srcSet={srcSet}
+            sizes={sizes}
+            alt={attachment.get('description')}
+            title={attachment.get('description')}
+            lang={lang}
+            style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }}
+            onLoad={this.handleImageLoad}
+          />
+        </a>
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      const autoPlay = this.getAutoPlay();
+
+      thumbnail = (
+        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
+          <video
+            className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
+            aria-label={attachment.get('description')}
+            title={attachment.get('description')}
+            lang={lang}
+            role='application'
+            src={attachment.get('url')}
+            onClick={this.handleClick}
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            autoPlay={autoPlay}
+            playsInline
+            loop
+            muted
+          />
+
+          <span className='media-gallery__gifv__label'>GIF</span>
+        </div>
+      );
+    }
+
+    return (
+      <div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        <Blurhash
+          hash={attachment.get('blurhash')}
+          dummy={!useBlurhash}
+          className={classNames('media-gallery__preview', {
+            'media-gallery__preview--hidden': visible && this.state.loaded,
+          })}
+        />
+        {visible && thumbnail}
+      </div>
+    );
+  }
+
+}
+
+class MediaGallery extends React.PureComponent {
+
+  static propTypes = {
+    sensitive: PropTypes.bool,
+    standalone: PropTypes.bool,
+    letterbox: PropTypes.bool,
+    fullwidth: PropTypes.bool,
+    hidden: PropTypes.bool,
+    media: ImmutablePropTypes.list.isRequired,
+    lang: PropTypes.string,
+    size: PropTypes.object,
+    onOpenMedia: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    defaultWidth: PropTypes.number,
+    cacheWidth: PropTypes.func,
+    visible: PropTypes.bool,
+    autoplay: PropTypes.bool,
+    onToggleVisibility: PropTypes.func,
+  };
+
+  static defaultProps = {
+    standalone: false,
+  };
+
+  state = {
+    visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
+    width: this.props.defaultWidth,
+  };
+
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
+      this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
+    } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+      this.setState({ visible: nextProps.visible });
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.node) {
+      this.handleResize();
+    }
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    leading: true,
+    trailing: true,
+  });
+
+  handleOpen = () => {
+    if (this.props.onToggleVisibility) {
+      this.props.onToggleVisibility();
+    } else {
+      this.setState({ visible: !this.state.visible });
+    }
+  };
+
+  handleClick = (index) => {
+    this.props.onOpenMedia(this.props.media, index);
+  };
+
+  handleRef = (node) => {
+    this.node = node;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  };
+
+  _setDimensions () {
+    const width = this.node.offsetWidth;
+
+    if (width && width != this.state.width) {
+      // offsetWidth triggers a layout, so only calculate when we need to
+      if (this.props.cacheWidth) {
+        this.props.cacheWidth(width);
+      }
+
+      this.setState({
+        width: width,
+      });
+    }
+  }
+
+  isStandaloneEligible() {
+    const { media, standalone } = this.props;
+    return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+  }
+
+  render () {
+    const { media, lang, intl, sensitive, letterbox, fullwidth, defaultWidth, autoplay } = this.props;
+    const { visible } = this.state;
+    const size     = media.take(4).size;
+    const uncached = media.every(attachment => attachment.get('type') === 'unknown');
+
+    const width = this.state.width || defaultWidth;
+
+    let children, spoilerButton;
+
+    const style = {};
+
+    const computedClass = classNames('media-gallery', { 'full-width': fullwidth });
+
+    if (this.isStandaloneEligible() && width) {
+      style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
+    } else if (width) {
+      style.height = width / (16/9);
+    } else {
+      return (<div className={computedClass} ref={this.handleRef} />);
+    }
+
+    if (this.isStandaloneEligible()) {
+      children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} lang={lang} displayWidth={width} visible={visible} />;
+    } else {
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />);
+    }
+
+    if (uncached) {
+      spoilerButton = (
+        <button type='button' disabled className='spoiler-button__overlay'>
+          <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
+        </button>
+      );
+    } else if (visible) {
+      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} ariaHidden />;
+    } else {
+      spoilerButton = (
+        <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
+          <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
+        </button>
+      );
+    }
+
+    return (
+      <div className={computedClass} style={style} ref={this.handleRef}>
+        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
+          {spoilerButton}
+          {visible && sensitive && (
+            <span className='sensitive-marker'>
+              <FormattedMessage {...messages.sensitive} />
+            </span>
+          )}
+        </div>
+
+        {children}
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(MediaGallery);
diff --git a/app/javascript/flavours/glitch/components/missing_indicator.jsx b/app/javascript/flavours/glitch/components/missing_indicator.jsx
new file mode 100644
index 000000000..08e39c236
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/missing_indicator.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import illustration from 'flavours/glitch/images/elephant_ui_disappointed.svg';
+import classNames from 'classnames';
+import { Helmet } from 'react-helmet';
+
+const MissingIndicator = ({ fullPage }) => (
+  <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}>
+    <div className='regeneration-indicator__figure'>
+      <img src={illustration} alt='' />
+    </div>
+
+    <div className='regeneration-indicator__label'>
+      <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
+      <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
+    </div>
+
+    <Helmet>
+      <meta name='robots' content='noindex' />
+    </Helmet>
+  </div>
+);
+
+MissingIndicator.propTypes = {
+  fullPage: PropTypes.bool,
+};
+
+export default MissingIndicator;
diff --git a/app/javascript/flavours/glitch/components/modal_root.jsx b/app/javascript/flavours/glitch/components/modal_root.jsx
new file mode 100644
index 000000000..5a5563e87
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/modal_root.jsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import 'wicg-inert';
+import { createBrowserHistory } from 'history';
+import { multiply } from 'color-blend';
+
+export default class ModalRoot extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    children: PropTypes.node,
+    onClose: PropTypes.func.isRequired,
+    backgroundColor: PropTypes.shape({
+      r: PropTypes.number,
+      g: PropTypes.number,
+      b: PropTypes.number,
+    }),
+    noEsc: PropTypes.bool,
+    ignoreFocus: PropTypes.bool,
+  };
+
+  activeElement = this.props.children ? document.activeElement : null;
+
+  handleKeyUp = (e) => {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!this.props.children && !this.props.noEsc) {
+      this.props.onClose();
+    }
+  };
+
+  handleKeyDown = (e) => {
+    if (e.key === 'Tab') {
+      const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
+      const index = focusable.indexOf(e.target);
+
+      let element;
+
+      if (e.shiftKey) {
+        element = focusable[index - 1] || focusable[focusable.length - 1];
+      } else {
+        element = focusable[index + 1] || focusable[0];
+      }
+
+      if (element) {
+        element.focus();
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    }
+  };
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+    window.addEventListener('keydown', this.handleKeyDown, false);
+    this.history = this.context.router ? this.context.router.history : createBrowserHistory();
+
+    if (this.props.children) {
+      this._handleModalOpen();
+    }
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (!!nextProps.children && !this.props.children) {
+      this.activeElement = document.activeElement;
+
+      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (!this.props.children && !!prevProps.children) {
+      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+
+      // Because of the wicg-inert polyfill, the activeElement may not be
+      // immediately selectable, we have to wait for observers to run, as
+      // described in https://github.com/WICG/inert#performance-and-gotchas
+      Promise.resolve().then(() => {
+        if (!this.props.ignoreFocus) {
+          this.activeElement.focus({ preventScroll: true });
+        }
+        this.activeElement = null;
+      }).catch(console.error);
+
+      this._handleModalClose();
+    }
+    if (this.props.children && !prevProps.children) {
+      this._handleModalOpen();
+    }
+    if (this.props.children) {
+      this._ensureHistoryBuffer();
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+    window.removeEventListener('keydown', this.handleKeyDown);
+  }
+
+  _handleModalOpen () {
+    this._modalHistoryKey = Date.now();
+    this.unlistenHistory = this.history.listen((_, action) => {
+      if (action === 'POP') {
+        this.props.onClose();
+      }
+    });
+  }
+
+  _handleModalClose () {
+    this.unlistenHistory();
+
+    const { state } = this.history.location;
+    if (state && state.mastodonModalKey === this._modalHistoryKey) {
+      this.history.goBack();
+    }
+  }
+
+  _ensureHistoryBuffer () {
+    const { pathname, state } = this.history.location;
+    if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
+      this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
+    }
+  }
+
+  getSiblings = () => {
+    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+  };
+
+  setRef = ref => {
+    this.node = ref;
+  };
+
+  render () {
+    const { children, onClose } = this.props;
+    const visible = !!children;
+
+    if (!visible) {
+      return (
+        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
+      );
+    }
+
+    let backgroundColor = null;
+
+    if (this.props.backgroundColor) {
+      backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 });
+    }
+
+    return (
+      <div className='modal-root' ref={this.setRef}>
+        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
+          <div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} />
+          <div role='dialog' className='modal-root__container'>{children}</div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/navigation_portal.jsx b/app/javascript/flavours/glitch/components/navigation_portal.jsx
new file mode 100644
index 000000000..9e8494179
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/navigation_portal.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Switch, Route, withRouter } from 'react-router-dom';
+import { showTrends } from 'flavours/glitch/initial_state';
+import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
+import AccountNavigation from 'flavours/glitch/features/account/navigation';
+
+const DefaultNavigation = () => (
+  <>
+    {showTrends && (
+      <>
+        <div className='flex-spacer' />
+        <Trends />
+      </>
+    )}
+  </>
+);
+
+class NavigationPortal extends React.PureComponent {
+
+  render () {
+    return (
+      <Switch>
+        <Route path='/@:acct' exact component={AccountNavigation} />
+        <Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
+        <Route path='/@:acct/with_replies' exact component={AccountNavigation} />
+        <Route path='/@:acct/followers' exact component={AccountNavigation} />
+        <Route path='/@:acct/following' exact component={AccountNavigation} />
+        <Route path='/@:acct/media' exact component={AccountNavigation} />
+        <Route component={DefaultNavigation} />
+      </Switch>
+    );
+  }
+
+}
+
+export default withRouter(NavigationPortal);
diff --git a/app/javascript/flavours/glitch/components/not_signed_in_indicator.jsx b/app/javascript/flavours/glitch/components/not_signed_in_indicator.jsx
new file mode 100644
index 000000000..b440c6be2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/not_signed_in_indicator.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const NotSignedInIndicator = () => (
+  <div className='scrollable scrollable--flex'>
+    <div className='empty-column-indicator'>
+      <FormattedMessage id='not_signed_in_indicator.not_signed_in' defaultMessage='You need to sign in to access this resource.' />
+    </div>
+  </div>
+);
+
+export default NotSignedInIndicator;
diff --git a/app/javascript/flavours/glitch/components/notification_purge_buttons.jsx b/app/javascript/flavours/glitch/components/notification_purge_buttons.jsx
new file mode 100644
index 000000000..1d807bc23
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/notification_purge_buttons.jsx
@@ -0,0 +1,60 @@
+/**
+ * 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';
+import Icon from 'flavours/glitch/components/icon';
+
+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' },
+});
+
+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}>
+          <Icon id='trash' /><br />{intl.formatMessage(messages.btnApply)}
+        </button>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(NotificationPurgeButtons);
diff --git a/app/javascript/flavours/glitch/components/permalink.jsx b/app/javascript/flavours/glitch/components/permalink.jsx
new file mode 100644
index 000000000..b09b17eeb
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/permalink.jsx
@@ -0,0 +1,51 @@
+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,
+    onInterceptClick: PropTypes.func,
+  };
+
+  handleClick = (e) => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      if (this.props.onInterceptClick && this.props.onInterceptClick()) {
+        e.preventDefault();
+        return;
+      }
+
+      if (this.context.router) {
+        e.preventDefault();
+        let state = { ...this.context.router.history.location.state };
+        state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+        this.context.router.history.push(this.props.to, state);
+      }
+    }
+  };
+
+  render () {
+    const {
+      children,
+      className,
+      href,
+      to,
+      onInterceptClick,
+      ...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/picture_in_picture_placeholder.jsx b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.jsx
new file mode 100644
index 000000000..961d3dead
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+import { FormattedMessage } from 'react-intl';
+
+class PictureInPicturePlaceholder extends React.PureComponent {
+
+  static propTypes = {
+    width: PropTypes.number,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  state = {
+    width: this.props.width,
+    height: this.props.width && (this.props.width / (16/9)),
+  };
+
+  handleClick = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  };
+
+  setRef = c => {
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  };
+
+  _setDimensions () {
+    const width  = this.node.offsetWidth;
+    const height = width / (16/9);
+
+    this.setState({ width, height });
+  }
+
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  render () {
+    const { height } = this.state;
+
+    return (
+      <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}>
+        <Icon id='window-restore' />
+        <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
+      </div>
+    );
+  }
+
+}
+
+export default connect()(PictureInPicturePlaceholder);
diff --git a/app/javascript/flavours/glitch/components/poll.jsx b/app/javascript/flavours/glitch/components/poll.jsx
new file mode 100644
index 000000000..2ccc1761e
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/poll.jsx
@@ -0,0 +1,237 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Motion from 'flavours/glitch/features/ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import escapeTextContentForBrowser from 'escape-html';
+import emojify from 'flavours/glitch/features/emoji/emoji';
+import RelativeTimestamp from './relative_timestamp';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  closed: {
+    id: 'poll.closed',
+    defaultMessage: 'Closed',
+  },
+  voted: {
+    id: 'poll.voted',
+    defaultMessage: 'You voted for this answer',
+  },
+  votes: {
+    id: 'poll.votes',
+    defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
+  },
+});
+
+const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
+  obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+  return obj;
+}, {});
+
+class Poll extends ImmutablePureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    poll: ImmutablePropTypes.map,
+    lang: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    disabled: PropTypes.bool,
+    refresh: PropTypes.func,
+    onVote: PropTypes.func,
+  };
+
+  state = {
+    selected: {},
+    expired: null,
+  };
+
+  static getDerivedStateFromProps (props, state) {
+    const { poll, intl } = props;
+    const expires_at = poll.get('expires_at');
+    const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
+    return (expired === state.expired) ? null : { expired };
+  }
+
+  componentDidMount () {
+    this._setupTimer();
+  }
+
+  componentDidUpdate () {
+    this._setupTimer();
+  }
+
+  componentWillUnmount () {
+    clearTimeout(this._timer);
+  }
+
+  _setupTimer () {
+    const { poll, intl } = this.props;
+    clearTimeout(this._timer);
+    if (!this.state.expired) {
+      const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
+      this._timer = setTimeout(() => {
+        this.setState({ expired: true });
+      }, delay);
+    }
+  }
+
+  _toggleOption = value => {
+    if (this.props.poll.get('multiple')) {
+      const tmp = { ...this.state.selected };
+      if (tmp[value]) {
+        delete tmp[value];
+      } else {
+        tmp[value] = true;
+      }
+      this.setState({ selected: tmp });
+    } else {
+      const tmp = {};
+      tmp[value] = true;
+      this.setState({ selected: tmp });
+    }
+  };
+
+  handleOptionChange = ({ target: { value } }) => {
+    this._toggleOption(value);
+  };
+
+  handleOptionKeyPress = (e) => {
+    if (e.key === 'Enter' || e.key === ' ') {
+      this._toggleOption(e.target.getAttribute('data-index'));
+      e.stopPropagation();
+      e.preventDefault();
+    }
+  };
+
+  handleVote = () => {
+    if (this.props.disabled) {
+      return;
+    }
+
+    this.props.onVote(Object.keys(this.state.selected));
+  };
+
+  handleRefresh = () => {
+    if (this.props.disabled) {
+      return;
+    }
+
+    this.props.refresh();
+  };
+
+  renderOption (option, optionIndex, showResults) {
+    const { poll, lang, disabled, intl } = this.props;
+    const pollVotesCount  = poll.get('voters_count') || poll.get('votes_count');
+    const percent         = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
+    const leading         = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
+    const active          = !!this.state.selected[`${optionIndex}`];
+    const voted           = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
+
+    let titleEmojified = option.get('title_emojified');
+    if (!titleEmojified) {
+      const emojiMap = makeEmojiMap(poll);
+      titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
+    }
+
+    return (
+      <li key={option.get('title')}>
+        <label className={classNames('poll__option', { selectable: !showResults })}>
+          <input
+            name='vote-options'
+            type={poll.get('multiple') ? 'checkbox' : 'radio'}
+            value={optionIndex}
+            checked={active}
+            onChange={this.handleOptionChange}
+            disabled={disabled}
+          />
+
+          {!showResults && (
+            <span
+              className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
+              tabIndex='0'
+              role={poll.get('multiple') ? 'checkbox' : 'radio'}
+              onKeyPress={this.handleOptionKeyPress}
+              aria-checked={active}
+              aria-label={option.get('title')}
+              lang={lang}
+              data-index={optionIndex}
+            />
+          )}
+          {showResults && (
+            <span
+              className='poll__number'
+              title={intl.formatMessage(messages.votes, {
+                votes: option.get('votes_count'),
+              })}
+            >
+              {Math.round(percent)}%
+            </span>
+          )}
+
+          <span
+            className='poll__option__text translate'
+            lang={lang}
+            dangerouslySetInnerHTML={{ __html: titleEmojified }}
+          />
+
+          {!!voted && <span className='poll__voted'>
+            <Icon id='check' className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
+          </span>}
+        </label>
+
+        {showResults && (
+          <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
+            {({ width }) =>
+              <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
+            }
+          </Motion>
+        )}
+      </li>
+    );
+  }
+
+  render () {
+    const { poll, intl } = this.props;
+    const { expired } = this.state;
+
+    if (!poll) {
+      return null;
+    }
+
+    const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
+    const showResults   = poll.get('voted') || expired;
+    const disabled      = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
+
+    let votesCount = null;
+
+    if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
+      votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
+    } else {
+      votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
+    }
+
+    return (
+      <div className='poll'>
+        <ul>
+          {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
+        </ul>
+
+        <div className='poll__footer'>
+          {!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
+          {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
+          {votesCount}
+          {poll.get('expires_at') && <span> · {timeRemaining}</span>}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Poll);
diff --git a/app/javascript/flavours/glitch/components/radio_button.jsx b/app/javascript/flavours/glitch/components/radio_button.jsx
new file mode 100644
index 000000000..0496fa286
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/radio_button.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class RadioButton extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    checked: PropTypes.bool,
+    name: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    label: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { name, value, checked, onChange, label } = this.props;
+
+    return (
+      <label className='radio-button'>
+        <input
+          name={name}
+          type='radio'
+          value={value}
+          checked={checked}
+          onChange={onChange}
+        />
+
+        <span className={classNames('radio-button__input', { checked })} />
+
+        <span>{label}</span>
+      </label>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/regeneration_indicator.jsx b/app/javascript/flavours/glitch/components/regeneration_indicator.jsx
new file mode 100644
index 000000000..68ce09df9
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/regeneration_indicator.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import illustration from 'flavours/glitch/images/elephant_ui_working.svg';
+
+const RegenerationIndicator = () => (
+  <div className='regeneration-indicator'>
+    <div className='regeneration-indicator__figure'>
+      <img src={illustration} alt='' />
+    </div>
+
+    <div className='regeneration-indicator__label'>
+      <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading&hellip;' />
+      <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
+    </div>
+  </div>
+);
+
+export default RegenerationIndicator;
diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.jsx b/app/javascript/flavours/glitch/components/relative_timestamp.jsx
new file mode 100644
index 000000000..e6c3e0880
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/relative_timestamp.jsx
@@ -0,0 +1,200 @@
+import React from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const messages = defineMessages({
+  today: { id: 'relative_time.today', defaultMessage: 'today' },
+  just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+  just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' },
+  seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+  seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' },
+  minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+  minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' },
+  hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+  hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' },
+  days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+  days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' },
+  moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
+  seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
+  minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
+  hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
+  days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
+});
+
+const dateFormatOptions = {
+  hour12: false,
+  year: 'numeric',
+  month: 'short',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+};
+
+const shortDateFormatOptions = {
+  month: 'short',
+  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;
+  }
+};
+
+export const timeAgoString = (intl, date, now, year, timeGiven, short) => {
+  const delta = now - date.getTime();
+
+  let relativeTime;
+
+  if (delta < DAY && !timeGiven) {
+    relativeTime = intl.formatMessage(messages.today);
+  } else if (delta < 10 * SECOND) {
+    relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full);
+  } else if (delta < 7 * DAY) {
+    if (delta < MINUTE) {
+      relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) });
+    } else if (delta < HOUR) {
+      relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) });
+    } else if (delta < DAY) {
+      relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) });
+    } else {
+      relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) });
+    }
+  } else if (date.getFullYear() === year) {
+    relativeTime = intl.formatDate(date, shortDateFormatOptions);
+  } else {
+    relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' });
+  }
+
+  return relativeTime;
+};
+
+const timeRemainingString = (intl, date, now, timeGiven = true) => {
+  const delta = date.getTime() - now;
+
+  let relativeTime;
+
+  if (delta < DAY && !timeGiven) {
+    relativeTime = intl.formatMessage(messages.today);
+  } else if (delta < 10 * SECOND) {
+    relativeTime = intl.formatMessage(messages.moments_remaining);
+  } else if (delta < MINUTE) {
+    relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
+  } else if (delta < HOUR) {
+    relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
+  } else if (delta < DAY) {
+    relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
+  } else {
+    relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
+  }
+
+  return relativeTime;
+};
+
+class RelativeTimestamp extends React.Component {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    timestamp: PropTypes.string.isRequired,
+    year: PropTypes.number.isRequired,
+    futureDate: PropTypes.bool,
+    short: PropTypes.bool,
+  };
+
+  state = {
+    now: this.props.intl.now(),
+  };
+
+  static defaultProps = {
+    year: (new Date()).getFullYear(),
+    short: true,
+  };
+
+  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, year, futureDate, short } = this.props;
+
+    const timeGiven    = timestamp.includes('T');
+    const date         = new Date(timestamp);
+    const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
+
+    return (
+      <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
+        {relativeTime}
+      </time>
+    );
+  }
+
+}
+
+export default injectIntl(RelativeTimestamp);
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.jsx b/app/javascript/flavours/glitch/components/scrollable_list.jsx
new file mode 100644
index 000000000..fc7dc989d
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/scrollable_list.jsx
@@ -0,0 +1,355 @@
+import React, { PureComponent } from 'react';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
+import LoadMore from './load_more';
+import LoadPending from './load_pending';
+import IntersectionObserverWrapper from 'flavours/glitch/features/ui/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
+import LoadingIndicator from './loading_indicator';
+import { connect } from 'react-redux';
+
+const MOUSE_IDLE_DELAY = 300;
+
+const mapStateToProps = (state, { scrollKey }) => {
+  return {
+    preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
+  };
+};
+
+class ScrollableList extends PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    scrollKey: PropTypes.string.isRequired,
+    onLoadMore: PropTypes.func,
+    onLoadPending: PropTypes.func,
+    onScrollToTop: PropTypes.func,
+    onScroll: PropTypes.func,
+    trackScroll: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    showLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    numPending: PropTypes.number,
+    prepend: PropTypes.node,
+    append: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
+    emptyMessage: PropTypes.node,
+    children: PropTypes.node,
+    bindToDocument: PropTypes.bool,
+    preventScroll: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  state = {
+    fullscreen: null,
+    cachedMediaWidth: 300,
+  };
+
+  intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+  handleScroll = throttle(() => {
+    if (this.node) {
+      const scrollTop = this.getScrollTop();
+      const scrollHeight = this.getScrollHeight();
+      const clientHeight = this.getClientHeight();
+      const offset = scrollHeight - scrollTop - clientHeight;
+
+      if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) {
+        this.props.onLoadMore();
+      }
+
+      if (scrollTop < 100 && this.props.onScrollToTop) {
+        this.props.onScrollToTop();
+      } else if (this.props.onScroll) {
+        this.props.onScroll();
+      }
+
+      if (!this.lastScrollWasSynthetic) {
+        // If the last scroll wasn't caused by setScrollTop(), assume it was
+        // intentional and cancel any pending scroll reset on mouse idle
+        this.scrollToTopOnMouseIdle = false;
+      }
+      this.lastScrollWasSynthetic = false;
+    }
+  }, 150, {
+    trailing: true,
+  });
+
+  mouseIdleTimer = null;
+  mouseMovedRecently = false;
+  lastScrollWasSynthetic = false;
+  scrollToTopOnMouseIdle = false;
+
+  setScrollTop = newScrollTop => {
+    if (this.getScrollTop() !== newScrollTop) {
+      this.lastScrollWasSynthetic = true;
+
+      if (this.props.bindToDocument) {
+        document.scrollingElement.scrollTop = newScrollTop;
+      } else {
+        this.node.scrollTop = newScrollTop;
+      }
+    }
+  };
+
+  clearMouseIdleTimer = () => {
+    if (this.mouseIdleTimer === null) {
+      return;
+    }
+    clearTimeout(this.mouseIdleTimer);
+    this.mouseIdleTimer = null;
+  };
+
+  handleMouseMove = throttle(() => {
+    // As long as the mouse keeps moving, clear and restart the idle timer.
+    this.clearMouseIdleTimer();
+    this.mouseIdleTimer =
+      setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
+
+    if (!this.mouseMovedRecently && this.getScrollTop() === 0) {
+      // Only set if we just started moving and are scrolled to the top.
+      this.scrollToTopOnMouseIdle = true;
+    }
+    // Save setting this flag for last, so we can do the comparison above.
+    this.mouseMovedRecently = true;
+  }, MOUSE_IDLE_DELAY / 2);
+
+  handleWheel = throttle(() => {
+    this.scrollToTopOnMouseIdle = false;
+  }, 150, {
+    trailing: true,
+  });
+
+  handleMouseIdle = () => {
+    if (this.scrollToTopOnMouseIdle && !this.props.preventScroll) {
+      this.setScrollTop(0);
+    }
+    this.mouseMovedRecently = false;
+    this.scrollToTopOnMouseIdle = false;
+  };
+
+  componentDidMount () {
+    this.attachScrollListener();
+    this.attachIntersectionObserver();
+    attachFullscreenListener(this.onFullScreenChange);
+
+    // Handle initial scroll position
+    this.handleScroll();
+  }
+
+  getScrollPosition = () => {
+    if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
+      return { height: this.getScrollHeight(), top: this.getScrollTop() };
+    } else {
+      return null;
+    }
+  };
+
+  getScrollTop = () => {
+    return this.props.bindToDocument ? document.scrollingElement.scrollTop : this.node.scrollTop;
+  };
+
+  getScrollHeight = () => {
+    return this.props.bindToDocument ? document.scrollingElement.scrollHeight : this.node.scrollHeight;
+  };
+
+  getClientHeight = () => {
+    return this.props.bindToDocument ? document.scrollingElement.clientHeight : this.node.clientHeight;
+  };
+
+  updateScrollBottom = (snapshot) => {
+    const newScrollTop = this.getScrollHeight() - snapshot;
+
+    this.setScrollTop(newScrollTop);
+  };
+
+  cacheMediaWidth = (width) => {
+    if (width && this.state.cachedMediaWidth != width) this.setState({ cachedMediaWidth: width });
+  };
+
+  getSnapshotBeforeUpdate (prevProps, prevState) {
+    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);
+    const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
+
+    if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) {
+      return this.getScrollHeight() - this.getScrollTop();
+    } else {
+      return null;
+    }
+  }
+
+  componentDidUpdate (prevProps, prevState, snapshot) {
+    // 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 (snapshot !== null) {
+      this.updateScrollBottom(snapshot);
+    }
+  }
+
+  componentWillUnmount () {
+    this.clearMouseIdleTimer();
+    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 () {
+    if (this.props.bindToDocument) {
+      document.addEventListener('scroll', this.handleScroll);
+      document.addEventListener('wheel', this.handleWheel);
+    } else {
+      this.node.addEventListener('scroll', this.handleScroll);
+      this.node.addEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  detachScrollListener () {
+    if (this.props.bindToDocument) {
+      document.removeEventListener('scroll', this.handleScroll);
+      document.removeEventListener('wheel', this.handleWheel);
+    } else {
+      this.node.removeEventListener('scroll', this.handleScroll);
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  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.onLoadMore();
+  };
+
+  handleLoadPending = e => {
+    e.preventDefault();
+    this.props.onLoadPending();
+    // Prevent the weird scroll-jumping behavior, as we explicitly don't want to
+    // scroll to top, and we know the scroll height is going to change
+    this.scrollToTopOnMouseIdle = false;
+    this.lastScrollWasSynthetic = false;
+    this.clearMouseIdleTimer();
+    this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
+    this.mouseMovedRecently = true;
+  };
+
+  render () {
+    const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
+    const { fullscreen } = this.state;
+    const childrenCount = React.Children.count(children);
+
+    const loadMore     = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
+    const loadPending  = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null;
+    let scrollableArea = null;
+
+    if (showLoading) {
+      scrollableArea = (
+        <div className='scrollable scrollable--flex' ref={this.setRef}>
+          <div role='feed' className='item-list'>
+            {prepend}
+          </div>
+
+          <div className='scrollable__append'>
+            <LoadingIndicator />
+          </div>
+        </div>
+      );
+    } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
+      scrollableArea = (
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
+          <div role='feed' className='item-list'>
+            {prepend}
+
+            {loadPending}
+
+            {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}
+              >
+                {React.cloneElement(child, {
+                  getScrollPosition: this.getScrollPosition,
+                  updateScrollBottom: this.updateScrollBottom,
+                  cachedMediaWidth: this.state.cachedMediaWidth,
+                  cacheMediaWidth: this.cacheMediaWidth,
+                })}
+              </IntersectionObserverArticleContainer>
+            ))}
+
+            {loadMore}
+
+            {!hasMore && append}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}>
+          {alwaysPrepend && prepend}
+
+          <div className='empty-column-indicator'>
+            {emptyMessage}
+          </div>
+        </div>
+      );
+    }
+
+    if (trackScroll) {
+      return (
+        <ScrollContainer scrollKey={scrollKey}>
+          {scrollableArea}
+        </ScrollContainer>
+      );
+    } else {
+      return scrollableArea;
+    }
+  }
+
+}
+
+export default connect(mapStateToProps, null, null, { forwardRef: true })(ScrollableList);
diff --git a/app/javascript/flavours/glitch/components/server_banner.jsx b/app/javascript/flavours/glitch/components/server_banner.jsx
new file mode 100644
index 000000000..ba84064a8
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/server_banner.jsx
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { fetchServer } from 'flavours/glitch/actions/server';
+import ShortNumber from 'flavours/glitch/components/short_number';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import Account from 'flavours/glitch/containers/account_container';
+import { domain } from 'flavours/glitch/initial_state';
+import Image from 'flavours/glitch/components/image';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+  aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
+});
+
+const mapStateToProps = state => ({
+  server: state.getIn(['server', 'server']),
+});
+
+class ServerBanner extends React.PureComponent {
+
+  static propTypes = {
+    server: PropTypes.object,
+    dispatch: PropTypes.func,
+    intl: PropTypes.object,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchServer());
+  }
+
+  render () {
+    const { server, intl } = this.props;
+    const isLoading = server.get('isLoading');
+
+    return (
+      <div className='server-banner'>
+        <div className='server-banner__introduction'>
+          <FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
+        </div>
+
+        <Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
+
+        <div className='server-banner__description'>
+          {isLoading ? (
+            <>
+              <Skeleton width='100%' />
+              <br />
+              <Skeleton width='100%' />
+              <br />
+              <Skeleton width='70%' />
+            </>
+          ) : server.get('description')}
+        </div>
+
+        <div className='server-banner__meta'>
+          <div className='server-banner__meta__column'>
+            <h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
+
+            <Account id={server.getIn(['contact', 'account', 'id'])} size={36} />
+          </div>
+
+          <div className='server-banner__meta__column'>
+            <h4><FormattedMessage id='server_banner.server_stats' defaultMessage='Server stats:' /></h4>
+
+            {isLoading ? (
+              <>
+                <strong className='server-banner__number'><Skeleton width='10ch' /></strong>
+                <br />
+                <span className='server-banner__number-label'><Skeleton width='5ch' /></span>
+              </>
+            ) : (
+              <>
+                <strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong>
+                <br />
+                <span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span>
+              </>
+            )}
+          </div>
+        </div>
+
+        <hr className='spacer' />
+
+        <Link className='button button--block button-secondary' to='/about'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></Link>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(ServerBanner));
diff --git a/app/javascript/flavours/glitch/components/setting_text.jsx b/app/javascript/flavours/glitch/components/setting_text.jsx
new file mode 100644
index 000000000..3a21a0601
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/setting_text.jsx
@@ -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/short_number.jsx b/app/javascript/flavours/glitch/components/short_number.jsx
new file mode 100644
index 000000000..535c17727
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/short_number.jsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+// @ts-check
+
+/**
+ * @callback ShortNumberRenderer
+ * @param {JSX.Element} displayNumber Number to display
+ * @param {number} pluralReady Number used for pluralization
+ * @returns {JSX.Element} Final render of number
+ */
+
+/**
+ * @typedef {object} ShortNumberProps
+ * @property {number} value Number to display in short variant
+ * @property {ShortNumberRenderer} [renderer]
+ * Custom renderer for numbers, provided as a prop. If another renderer
+ * passed as a child of this component, this prop won't be used.
+ * @property {ShortNumberRenderer} [children]
+ * Custom renderer for numbers, provided as a child. If another renderer
+ * passed as a prop of this component, this one will be used instead.
+ */
+
+/**
+ * Component that renders short big number to a shorter version
+ *
+ * @param {ShortNumberProps} param0 Props for the component
+ * @returns {JSX.Element} Rendered number
+ */
+function ShortNumber({ value, renderer, children }) {
+  const shortNumber = toShortNumber(value);
+  const [, division] = shortNumber;
+
+  // eslint-disable-next-line eqeqeq
+  if (children != null && renderer != null) {
+    console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
+  }
+
+  // eslint-disable-next-line eqeqeq
+  const customRenderer = children != null ? children : renderer;
+
+  const displayNumber = <ShortNumberCounter value={shortNumber} />;
+
+  // eslint-disable-next-line eqeqeq
+  return customRenderer != null
+    ? customRenderer(displayNumber, pluralReady(value, division))
+    : displayNumber;
+}
+
+ShortNumber.propTypes = {
+  value: PropTypes.number.isRequired,
+  renderer: PropTypes.func,
+  children: PropTypes.func,
+};
+
+/**
+ * @typedef {object} ShortNumberCounterProps
+ * @property {import('../utils/number').ShortNumber} value Short number
+ */
+
+/**
+ * Renders short number into corresponding localizable react fragment
+ *
+ * @param {ShortNumberCounterProps} param0 Props for the component
+ * @returns {JSX.Element} FormattedMessage ready to be embedded in code
+ */
+function ShortNumberCounter({ value }) {
+  const [rawNumber, unit, maxFractionDigits = 0] = value;
+
+  const count = (
+    <FormattedNumber
+      value={rawNumber}
+      maximumFractionDigits={maxFractionDigits}
+    />
+  );
+
+  let values = { count, rawNumber };
+
+  switch (unit) {
+  case DECIMAL_UNITS.THOUSAND: {
+    return (
+      <FormattedMessage
+        id='units.short.thousand'
+        defaultMessage='{count}K'
+        values={values}
+      />
+    );
+  }
+  case DECIMAL_UNITS.MILLION: {
+    return (
+      <FormattedMessage
+        id='units.short.million'
+        defaultMessage='{count}M'
+        values={values}
+      />
+    );
+  }
+  case DECIMAL_UNITS.BILLION: {
+    return (
+      <FormattedMessage
+        id='units.short.billion'
+        defaultMessage='{count}B'
+        values={values}
+      />
+    );
+  }
+  // Not sure if we should go farther - @Sasha-Sorokin
+  default: return count;
+  }
+}
+
+ShortNumberCounter.propTypes = {
+  value: PropTypes.arrayOf(PropTypes.number),
+};
+
+export default React.memo(ShortNumber);
diff --git a/app/javascript/flavours/glitch/components/skeleton.jsx b/app/javascript/flavours/glitch/components/skeleton.jsx
new file mode 100644
index 000000000..6a17ffb26
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/skeleton.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
+
+Skeleton.propTypes = {
+  width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+  height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+};
+
+export default Skeleton;
diff --git a/app/javascript/flavours/glitch/components/spoilers.jsx b/app/javascript/flavours/glitch/components/spoilers.jsx
new file mode 100644
index 000000000..75e4ec3a1
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/spoilers.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default
+class Spoilers extends React.PureComponent {
+
+  static propTypes = {
+    spoilerText: PropTypes.string,
+    children: PropTypes.node,
+  };
+
+  state = {
+    hidden: true,
+  };
+
+  handleSpoilerClick = () => {
+    this.setState({ hidden: !this.state.hidden });
+  };
+
+  render () {
+    const { spoilerText, children } = this.props;
+    const { hidden } = this.state;
+
+    const toggleText = hidden ?
+      (<FormattedMessage
+        id='status.show_more'
+        defaultMessage='Show more'
+        key='0'
+      />) :
+      (<FormattedMessage
+        id='status.show_less'
+        defaultMessage='Show less'
+        key='0'
+      />);
+
+    return ([
+      <p className='spoiler__text'>
+        {spoilerText}
+        {' '}
+        <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
+          {toggleText}
+        </button>
+      </p>,
+      <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
+        {children}
+      </div>,
+    ]);
+  }
+
+}
+
diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx
new file mode 100644
index 000000000..fa90c98d0
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status.jsx
@@ -0,0 +1,833 @@
+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 StatusIcons from './status_icons';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import AttachmentList from './attachment_list';
+import Card from '../features/status/components/card';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
+import classNames from 'classnames';
+import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
+import PollContainer from 'flavours/glitch/containers/poll_container';
+import { displayMedia } from 'flavours/glitch/initial_state';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
+
+// 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 const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => {
+  const displayName = status.getIn(['account', 'display_name']);
+
+  const values = [
+    displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
+    status.get('spoiler_text') && !expanded ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
+    intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
+    status.getIn(['account', 'acct']),
+  ];
+
+  if (rebloggedByText) {
+    values.push(rebloggedByText);
+  }
+
+  return values.join(', ');
+};
+
+export const defaultMediaVisibility = (status, settings) => {
+  if (!status) {
+    return undefined;
+  }
+
+  if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+    status = status.get('reblog');
+  }
+
+  if (settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text')) {
+    return true;
+  }
+
+  return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
+};
+
+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,
+    onBookmark: PropTypes.func,
+    onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
+    onMention: PropTypes.func,
+    onPin: PropTypes.func,
+    onOpenMedia: PropTypes.func,
+    onOpenVideo: PropTypes.func,
+    onBlock: PropTypes.func,
+    onAddFilter: PropTypes.func,
+    onEmbed: PropTypes.func,
+    onHeightChange: PropTypes.func,
+    onToggleHidden: PropTypes.func,
+    onTranslate: PropTypes.func,
+    onInteractionModal: PropTypes.func,
+    muted: PropTypes.bool,
+    hidden: PropTypes.bool,
+    unread: PropTypes.bool,
+    prepend: PropTypes.string,
+    withDismiss: PropTypes.bool,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
+    getScrollPosition: PropTypes.func,
+    updateScrollBottom: PropTypes.func,
+    expanded: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    cacheMediaWidth: PropTypes.func,
+    cachedMediaWidth: PropTypes.number,
+    onClick: PropTypes.func,
+    scrollKey: PropTypes.string,
+    deployPictureInPicture: PropTypes.func,
+    settings: ImmutablePropTypes.map.isRequired,
+    pictureInPicture: ImmutablePropTypes.contains({
+      inUse: PropTypes.bool,
+      available: PropTypes.bool,
+    }),
+  };
+
+  state = {
+    isCollapsed: false,
+    autoCollapsed: false,
+    isExpanded: undefined,
+    showMedia: undefined,
+    statusId: undefined,
+    revealBehindCW: undefined,
+    showCard: false,
+    forceFilter: undefined,
+  };
+
+  // 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',
+    'muted',
+    'notification',
+    'hidden',
+    'expanded',
+    'unread',
+    'pictureInPicture',
+  ];
+
+  updateOnStates = [
+    'isExpanded',
+    'isCollapsed',
+    'showMedia',
+    'forceFilter',
+  ];
+
+  //  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
+  //  `getderivedStateFromProps()`.
+
+  //  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.
+  static getDerivedStateFromProps(nextProps, prevState) {
+    let update = {};
+    let updated = false;
+
+    // Make sure the state mirrors props we track…
+    if (nextProps.expanded !== prevState.expandedProp) {
+      update.expandedProp = nextProps.expanded;
+      updated = true;
+    }
+    if (nextProps.status?.get('hidden') !== prevState.statusPropHidden) {
+      update.statusPropHidden = nextProps.status?.get('hidden');
+      updated = true;
+    }
+
+    // Update state based on new props
+    if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
+      if (prevState.isCollapsed) {
+        update.isCollapsed = false;
+        updated = true;
+      }
+    }
+
+    // Handle uncollapsing toots when the shared CW state is expanded
+    if (nextProps.settings.getIn(['content_warnings', 'shared_state']) &&
+      nextProps.status?.get('spoiler_text')?.length && nextProps.status?.get('hidden') === false &&
+      prevState.statusPropHidden !== false && prevState.isCollapsed
+    ) {
+      update.isCollapsed = false;
+      updated = true;
+    }
+
+    // The “expanded” prop is used to one-off change the local state.
+    // It's used in the thread view when unfolding/re-folding all CWs at once.
+    if (nextProps.expanded !== prevState.expandedProp &&
+      nextProps.expanded !== undefined
+    ) {
+      update.isExpanded = nextProps.expanded;
+      if (nextProps.expanded) update.isCollapsed = false;
+      updated = true;
+    }
+
+    if (prevState.isExpanded === undefined && update.isExpanded === undefined) {
+      update.isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
+      updated = true;
+    }
+
+    if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
+      update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
+      update.statusId = nextProps.status.get('id');
+      updated = true;
+    }
+
+    if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) {
+      update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']);
+      if (update.revealBehindCW) {
+        update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
+      }
+      updated = true;
+    }
+
+    return updated ? update : 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 `setCollapsed()` 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 the user set value (default 400 without media, or 610px 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;
+
+    // Prevent a crash when node is undefined. Not completely sure why this
+    // happens, might be because status === null.
+    if (node === undefined) return;
+
+    const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
+
+    // Don't autocollapse if CW state is shared and status is explicitly revealed,
+    // as it could cause surprising changes when receiving notifications
+    if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return;
+
+    let autoCollapseHeight = parseInt(autoCollapseSettings.get('height'));
+    if (status.get('media_attachments').size && !muted) {
+      autoCollapseHeight += 210;
+    }
+
+    if (collapse ||
+      autoCollapseSettings.get('all') ||
+      (autoCollapseSettings.get('notifications') && muted) ||
+      (autoCollapseSettings.get('lengthy') && node.clientHeight > autoCollapseHeight) ||
+      (autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') ||
+      (autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) ||
+      (autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0)
+    ) {
+      this.setCollapsed(true);
+      // Hack to fix timeline jumps on second rendering when auto-collapsing
+      this.setState({ autoCollapsed: true });
+    }
+
+    // Hack to fix timeline jumps when a preview card is fetched
+    this.setState({
+      showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'),
+    });
+  }
+
+  //  Hack to fix timeline jumps on second rendering when auto-collapsing
+  //  or on subsequent rendering when a preview card has been fetched
+  getSnapshotBeforeUpdate (prevProps, prevState) {
+    if (!this.props.getScrollPosition) return null;
+
+    const { muted, hidden, status, settings } = this.props;
+
+    const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards');
+    if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) {
+      if (doShowCard) this.setState({ showCard: true });
+      if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
+      return this.props.getScrollPosition();
+    } else {
+      return null;
+    }
+  }
+
+  componentDidUpdate (prevProps, prevState, snapshot) {
+    if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
+      this.props.updateScrollBottom(snapshot.height - snapshot.top);
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.node && this.props.getScrollPosition) {
+      const position = this.props.getScrollPosition();
+      if (position !== null && this.node.offsetTop < position.top) {
+        requestAnimationFrame(() => {
+          this.props.updateScrollBottom(position.height - position.top);
+        });
+      }
+    }
+  }
+
+  //  `setCollapsed()` sets the value of `isCollapsed` in our state, that is,
+  //  whether the toot is collapsed or not.
+
+  //  `setCollapsed()` automatically checks for us whether toot collapsing
+  //  is enabled, so we don't have to.
+  setCollapsed = (value) => {
+    if (this.props.settings.getIn(['collapsed', 'enabled'])) {
+      if (value) {
+        this.setExpansion(false);
+      }
+      this.setState({ isCollapsed: value });
+    } else {
+      this.setState({ isCollapsed: false });
+    }
+  };
+
+  setExpansion = (value) => {
+    if (this.props.settings.getIn(['content_warnings', 'shared_state']) && this.props.status.get('hidden') === value) {
+      this.props.onToggleHidden(this.props.status);
+    }
+
+    this.setState({ isExpanded: value });
+    if (value) {
+      this.setCollapsed(false);
+    }
+  };
+
+  //  `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 { isCollapsed } = this.state;
+    if (!router) return;
+
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
+      if (isCollapsed) this.setCollapsed(false);
+      else if (e.shiftKey) {
+        this.setCollapsed(true);
+        document.getSelection().removeAllRanges();
+      } else if (this.props.onClick) {
+        this.props.onClick();
+        return;
+      } else {
+        if (destination === undefined) {
+          destination = `/@${
+            status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
+          }/${
+            status.getIn(['reblog', 'id'], status.get('id'))
+          }`;
+        }
+        let state = { ...router.history.location.state };
+        state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+        router.history.push(destination, state);
+      }
+      e.preventDefault();
+    }
+  };
+
+  handleToggleMediaVisibility = () => {
+    this.setState({ showMedia: !this.state.showMedia });
+  };
+
+  handleExpandedToggle = () => {
+    if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
+      this.props.onToggleHidden(this.props.status);
+    } else if (this.props.status.get('spoiler_text')) {
+      this.setExpansion(!this.state.isExpanded);
+    }
+  };
+
+  handleOpenVideo = (options) => {
+    const { status } = this.props;
+    this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
+  };
+
+  handleOpenMedia = (media, index) => {
+    this.props.onOpenMedia(this.props.status.get('id'), media, index);
+  };
+
+  handleHotkeyOpenMedia = e => {
+    const { status, onOpenMedia, onOpenVideo } = this.props;
+    const statusId = status.get('id');
+
+    e.preventDefault();
+
+    if (status.get('media_attachments').size > 0) {
+      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 });
+      } else {
+        onOpenMedia(statusId, status.get('media_attachments'), 0);
+      }
+    }
+  };
+
+  handleDeployPictureInPicture = (type, mediaProps) => {
+    const { deployPictureInPicture, status } = this.props;
+
+    deployPictureInPicture(status, type, mediaProps);
+  };
+
+  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);
+  };
+
+  handleHotkeyBookmark = e => {
+    this.props.onBookmark(this.props.status, e);
+  };
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.props.onMention(this.props.status.get('account'), this.context.router.history);
+  };
+
+  handleHotkeyOpen = () => {
+    let state = { ...this.context.router.history.location.state };
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    const status = this.props.status;
+    this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`, state);
+  };
+
+  handleHotkeyOpenProfile = () => {
+    let state = { ...this.context.router.history.location.state };
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
+  };
+
+  handleHotkeyMoveUp = e => {
+    this.props.onMoveUp(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
+  };
+
+  handleHotkeyMoveDown = e => {
+    this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
+  };
+
+  handleHotkeyCollapse = e => {
+    if (!this.props.settings.getIn(['collapsed', 'enabled']))
+      return;
+
+    this.setCollapsed(!this.state.isCollapsed);
+  };
+
+  handleHotkeyToggleSensitive = () => {
+    this.handleToggleMediaVisibility();
+  };
+
+  handleUnfilterClick = e => {
+    this.setState({ forceFilter: false });
+    e.preventDefault();
+  };
+
+  handleFilterClick = () => {
+    this.setState({ forceFilter: true });
+  };
+
+  handleRef = c => {
+    this.node = c;
+  };
+
+  handleTranslate = () => {
+    this.props.onTranslate(this.props.status);
+  };
+
+  renderLoadingMediaGallery () {
+    return <div className='media-gallery' style={{ height: '110px' }} />;
+  }
+
+  renderLoadingVideoPlayer () {
+    return <div className='video-player' style={{ height: '110px' }} />;
+  }
+
+  renderLoadingAudioPlayer () {
+    return <div className='audio-player' style={{ height: '110px' }} />;
+  }
+
+  render () {
+    const {
+      handleRef,
+      parseClick,
+      setExpansion,
+      setCollapsed,
+    } = this;
+    const { router } = this.context;
+    const {
+      intl,
+      status,
+      account,
+      settings,
+      collapsed,
+      muted,
+      intersectionObserverWrapper,
+      onOpenVideo,
+      onOpenMedia,
+      notification,
+      hidden,
+      unread,
+      featured,
+      pictureInPicture,
+      ...other
+    } = this.props;
+    const { isCollapsed, forceFilter } = this.state;
+    let background = null;
+    let attachments = null;
+
+    //  Depending on user settings, some media are considered as parts of the
+    //  contents (affected by CW) while other will be displayed outside of the
+    //  CW.
+    let contentMedia = [];
+    let contentMediaIcons = [];
+    let extraMedia = [];
+    let extraMediaIcons = [];
+    let media = contentMedia;
+    let mediaIcons = contentMediaIcons;
+
+    if (settings.getIn(['content_warnings', 'media_outside'])) {
+      media = extraMedia;
+      mediaIcons = extraMediaIcons;
+    }
+
+    if (status === null) {
+      return null;
+    }
+
+    const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
+
+    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,
+      bookmark: this.handleHotkeyBookmark,
+      toggleCollapse: this.handleHotkeyCollapse,
+      toggleSensitive: this.handleHotkeyToggleSensitive,
+      openMedia: this.handleHotkeyOpenMedia,
+    };
+
+    if (hidden) {
+      return (
+        <HotKeys handlers={handlers}>
+          <div ref={this.handleRef} className='status focusable' tabIndex='0'>
+            <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
+            <span>{status.get('content')}</span>
+          </div>
+        </HotKeys>
+      );
+    }
+
+    const matchedFilters = status.get('matched_filters');
+    if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
+      const minHandlers = this.props.muted ? {} : {
+        moveUp: this.handleHotkeyMoveUp,
+        moveDown: this.handleHotkeyMoveDown,
+      };
+
+      return (
+        <HotKeys handlers={minHandlers}>
+          <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
+            <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
+            {' '}
+            <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
+              <FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
+            </button>
+          </div>
+        </HotKeys>
+      );
+    }
+
+    //  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.
+    //  If a media file is of unknwon type or if the status is muted
+    //  (notification), we show a list of links instead of embedded media.
+
+    //  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 (pictureInPicture.get('inUse')) {
+      media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
+      mediaIcons.push('video-camera');
+    } else if (attachments.size > 0) {
+      if (muted || attachments.some(item => item.get('type') === 'unknown')) {
+        media.push(
+          <AttachmentList
+            compact
+            media={status.get('media_attachments')}
+          />,
+        );
+      } else if (attachments.getIn([0, 'type']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media.push(
+          <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
+            {Component => (
+              <Component
+                src={attachment.get('url')}
+                alt={attachment.get('description')}
+                lang={status.get('language')}
+                poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+                backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
+                foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
+                accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
+                duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+                width={this.props.cachedMediaWidth}
+                height={110}
+                cacheWidth={this.props.cacheMediaWidth}
+                deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
+                sensitive={status.get('sensitive')}
+                blurhash={attachment.get('blurhash')}
+                visible={this.state.showMedia}
+                onToggleVisibility={this.handleToggleMediaVisibility}
+              />
+            )}
+          </Bundle>,
+        );
+        mediaIcons.push('music');
+      } else if (attachments.getIn([0, 'type']) === 'video') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media.push(
+          <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
+            {Component => (<Component
+              preview={attachment.get('preview_url')}
+              frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
+              blurhash={attachment.get('blurhash')}
+              src={attachment.get('url')}
+              alt={attachment.get('description')}
+              lang={status.get('language')}
+              inline
+              sensitive={status.get('sensitive')}
+              letterbox={settings.getIn(['media', 'letterbox'])}
+              fullwidth={settings.getIn(['media', 'fullwidth'])}
+              preventPlayback={isCollapsed || !isExpanded}
+              onOpenVideo={this.handleOpenVideo}
+              width={this.props.cachedMediaWidth}
+              cacheWidth={this.props.cacheMediaWidth}
+              deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
+              visible={this.state.showMedia}
+              onToggleVisibility={this.handleToggleMediaVisibility}
+            />)}
+          </Bundle>,
+        );
+        mediaIcons.push('video-camera');
+      } else {  //  Media type is 'image' or 'gifv'
+        media.push(
+          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
+            {Component => (
+              <Component
+                media={attachments}
+                lang={status.get('language')}
+                sensitive={status.get('sensitive')}
+                letterbox={settings.getIn(['media', 'letterbox'])}
+                fullwidth={settings.getIn(['media', 'fullwidth'])}
+                hidden={isCollapsed || !isExpanded}
+                onOpenMedia={this.handleOpenMedia}
+                cacheWidth={this.props.cacheMediaWidth}
+                defaultWidth={this.props.cachedMediaWidth}
+                visible={this.state.showMedia}
+                onToggleVisibility={this.handleToggleMediaVisibility}
+              />
+            )}
+          </Bundle>,
+        );
+        mediaIcons.push('picture-o');
+      }
+
+      if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
+        background = attachments.getIn([0, 'preview_url']);
+      }
+    } else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) {
+      media.push(
+        <Card
+          onOpenMedia={this.handleOpenMedia}
+          card={status.get('card')}
+          compact
+          cacheWidth={this.props.cacheMediaWidth}
+          defaultWidth={this.props.cachedMediaWidth}
+          sensitive={status.get('sensitive')}
+        />,
+      );
+      mediaIcons.push('link');
+    }
+
+    if (status.get('poll')) {
+      contentMedia.push(<PollContainer pollId={status.get('poll')} lang={status.get('language')} />);
+      contentMediaIcons.push('tasks');
+    }
+
+    //  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'])}`,
+    };
+
+    let prepend;
+
+    if (this.props.prepend && account) {
+      const notifKind = {
+        favourite: 'favourited',
+        reblog: 'boosted',
+        reblogged_by: 'boosted',
+        status: 'posted',
+      }[this.props.prepend];
+
+      selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
+
+      prepend = (
+        <StatusPrepend
+          type={this.props.prepend}
+          account={account}
+          parseClick={parseClick}
+          notificationId={this.props.notificationId}
+        />
+      );
+    }
+
+    let rebloggedByText;
+
+    if (this.props.prepend === 'reblog') {
+      rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
+    }
+
+    const computedClass = classNames('status', `status-${status.get('visibility')}`, {
+      collapsed: isCollapsed,
+      'has-background': isCollapsed && background,
+      'status__wrapper-reply': !!status.get('in_reply_to_id'),
+      unread,
+      muted,
+    }, 'focusable');
+
+    return (
+      <HotKeys handlers={handlers}>
+        <div
+          className={computedClass}
+          style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
+          {...selectorAttribs}
+          ref={handleRef}
+          tabIndex='0'
+          data-featured={featured ? 'true' : null}
+          aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
+        >
+          {!muted && prepend}
+          <header className='status__info'>
+            <span>
+              {muted && prepend}
+              {!muted || !isCollapsed ? (
+                <StatusHeader
+                  status={status}
+                  friend={account}
+                  collapsed={isCollapsed}
+                  parseClick={parseClick}
+                />
+              ) : null}
+            </span>
+            <StatusIcons
+              status={status}
+              mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
+              collapsible={settings.getIn(['collapsed', 'enabled'])}
+              collapsed={isCollapsed}
+              setCollapsed={setCollapsed}
+              settings={settings.get('status_icons')}
+            />
+          </header>
+          <StatusContent
+            status={status}
+            media={contentMedia}
+            extraMedia={extraMedia}
+            mediaIcons={contentMediaIcons}
+            expanded={isExpanded}
+            onExpandedToggle={this.handleExpandedToggle}
+            onTranslate={this.handleTranslate}
+            parseClick={parseClick}
+            disabled={!router}
+            tagLinks={settings.get('tag_misleading_links')}
+            rewriteMentions={settings.get('rewrite_mentions')}
+          />
+
+          {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
+            <StatusActionBar
+              status={status}
+              account={status.get('account')}
+              showReplyCount={settings.get('show_reply_count')}
+              onFilter={matchedFilters ? this.handleFilterClick : null}
+              {...other}
+            />
+          ) : null}
+          {notification ? (
+            <NotificationOverlayContainer
+              notification={notification}
+            />
+          ) : null}
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
+
+export default injectIntl(Status);
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.jsx b/app/javascript/flavours/glitch/components/status_action_bar.jsx
new file mode 100644
index 000000000..091d0b24b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_action_bar.jsx
@@ -0,0 +1,342 @@
+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/initial_state';
+import RelativeTimestamp from './relative_timestamp';
+import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
+import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+  edit: { id: 'status.edit', defaultMessage: 'Edit' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
+  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' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+  cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
+  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' },
+  admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
+  admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
+  copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
+  hide: { id: 'status.hide', defaultMessage: 'Hide post' },
+  edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
+  filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
+  openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
+});
+
+class StatusActionBar extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReply: PropTypes.func,
+    onFavourite: PropTypes.func,
+    onReblog: PropTypes.func,
+    onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
+    onMention: PropTypes.func,
+    onMute: PropTypes.func,
+    onBlock: PropTypes.func,
+    onReport: PropTypes.func,
+    onEmbed: PropTypes.func,
+    onMuteConversation: PropTypes.func,
+    onPin: PropTypes.func,
+    onBookmark: PropTypes.func,
+    onFilter: PropTypes.func,
+    onAddFilter: PropTypes.func,
+    onInteractionModal: PropTypes.func,
+    withDismiss: PropTypes.bool,
+    withCounters: PropTypes.bool,
+    showReplyCount: PropTypes.bool,
+    scrollKey: PropTypes.string,
+    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',
+    'showReplyCount',
+    'withCounters',
+    'withDismiss',
+  ];
+
+  handleReplyClick = () => {
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      this.props.onReply(this.props.status, this.context.router.history);
+    } else {
+      this.props.onInteractionModal('reply', this.props.status);
+    }
+  };
+
+  handleShareClick = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  };
+
+  handleFavouriteClick = (e) => {
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      this.props.onFavourite(this.props.status, e);
+    } else {
+      this.props.onInteractionModal('favourite', this.props.status);
+    }
+  };
+
+  handleReblogClick = e => {
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      this.props.onReblog(this.props.status, e);
+    } else {
+      this.props.onInteractionModal('reblog', this.props.status);
+    }
+  };
+
+  handleBookmarkClick = (e) => {
+    this.props.onBookmark(this.props.status, e);
+  };
+
+  handleDeleteClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history);
+  };
+
+  handleRedraftClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history, true);
+  };
+
+  handleEditClick = () => {
+    this.props.onEdit(this.props.status, this.context.router.history);
+  };
+
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  };
+
+  handleMentionClick = () => {
+    this.props.onMention(this.props.status.get('account'), this.context.router.history);
+  };
+
+  handleDirectClick = () => {
+    this.props.onDirect(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);
+  };
+
+  handleOpen = () => {
+    let state = { ...this.context.router.history.location.state };
+    if (state.mastodonModalKey) {
+      this.context.router.history.replace(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
+    } else {
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, state);
+    }
+  };
+
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  };
+
+  handleReport = () => {
+    this.props.onReport(this.props.status);
+  };
+
+  handleConversationMuteClick = () => {
+    this.props.onMuteConversation(this.props.status);
+  };
+
+  handleCopy = () => {
+    const url = this.props.status.get('url');
+    navigator.clipboard.writeText(url);
+  };
+
+  handleHideClick = () => {
+    this.props.onFilter();
+  };
+
+  handleFilterClick = () => {
+    this.props.onAddFilter(this.props.status);
+  };
+
+  render () {
+    const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
+    const { permissions } = this.context.identity;
+
+    const anonymousAccess    = !me;
+    const mutingConversation = status.get('muted');
+    const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
+    const writtenByMe        = status.getIn(['account', 'id']) === me;
+    const isRemote           = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
+
+    let menu = [];
+    let reblogIcon = 'retweet';
+    let replyIcon;
+    let replyTitle;
+
+    menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+    if (publicStatus && isRemote) {
+      menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+    }
+
+    menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
+    menu.push(null);
+
+    if (writtenByMe && pinnableStatus) {
+      menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      menu.push(null);
+    }
+
+    if (writtenByMe || withDismiss) {
+      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+      menu.push(null);
+    }
+
+    if (writtenByMe) {
+      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
+      menu.push(null);
+
+      if (!this.props.onFilter) {
+        menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
+        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 (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+        menu.push(null);
+        if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+          if (accountAdminLink !== undefined) {
+            menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
+          }
+          if (statusAdminLink !== undefined) {
+            menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
+          }
+        }
+        if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+          const domain = status.getIn(['account', 'acct']).split('@')[1];
+          menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+        }
+      }
+    }
+
+    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) && publicStatus && (
+      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
+    );
+
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let reblogTitle = '';
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
+
+    const filterButton = this.props.onFilter && (
+      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
+    );
+
+    return (
+      <div className='status__action-bar'>
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={replyIcon}
+          onClick={this.handleReplyClick}
+          counter={showReplyCount ? status.get('replies_count') : undefined}
+          obfuscateCount
+        />
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
+        {shareButton}
+        <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
+
+        {filterButton}
+
+        <div className='status__action-bar-dropdown'>
+          <DropdownMenuContainer
+            scrollKey={scrollKey}
+            disabled={anonymousAccess}
+            status={status}
+            items={menu}
+            icon='ellipsis-h'
+            size={18}
+            direction='right'
+            ariaLabel={intl.formatMessage(messages.more)}
+          />
+        </div>
+
+        <div className='status__action-bar-spacer' />
+        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
+          <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
+        </a>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(StatusActionBar);
diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx
new file mode 100644
index 000000000..34742c81b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_content.jsx
@@ -0,0 +1,470 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import Permalink from './permalink';
+import { connect } from 'react-redux';
+import classnames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
+import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
+
+const textMatchesTarget = (text, origin, host) => {
+  return (text === origin || text === host
+          || text.startsWith(origin + '/') || text.startsWith(host + '/')
+          || 'www.' + text === host || ('www.' + text).startsWith(host + '/'));
+};
+
+const isLinkMisleading = (link) => {
+  let linkTextParts = [];
+
+  // Reconstruct visible text, as we do not have much control over how links
+  // from remote software look, and we can't rely on `innerText` because the
+  // `invisible` class does not set `display` to `none`.
+
+  const walk = (node) => {
+    switch (node.nodeType) {
+    case Node.TEXT_NODE:
+      linkTextParts.push(node.textContent);
+      break;
+    case Node.ELEMENT_NODE:
+      if (node.classList.contains('invisible')) return;
+      const children = node.childNodes;
+      for (let i = 0; i < children.length; i++) {
+        walk(children[i]);
+      }
+      break;
+    }
+  };
+
+  walk(link);
+
+  const linkText = linkTextParts.join('');
+  const targetURL = new URL(link.href);
+
+  if (targetURL.protocol === 'magnet:') {
+    return !linkText.startsWith('magnet:');
+  }
+
+  if (targetURL.protocol === 'xmpp:') {
+    return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href);
+  }
+
+  // The following may not work with international domain names
+  if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) {
+    return false;
+  }
+
+  // The link hasn't been recognized, maybe it features an international domain name
+  const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
+  const host = targetURL.host.replace(targetURL.hostname, hostname);
+  const origin = targetURL.origin.replace(targetURL.host, host);
+  const text = linkText.normalize('NFKC');
+  return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
+};
+
+class TranslateButton extends React.PureComponent {
+
+  static propTypes = {
+    translation: ImmutablePropTypes.map,
+    onClick: PropTypes.func,
+  };
+
+  render () {
+    const { translation, onClick } = this.props;
+
+    if (translation) {
+      const language     = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
+      const languageName = language ? language[2] : translation.get('detected_source_language');
+      const provider     = translation.get('provider');
+
+      return (
+        <div className='translate-button'>
+          <div className='translate-button__meta'>
+            <FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
+          </div>
+
+          <button className='link-button' onClick={onClick}>
+            <FormattedMessage id='status.show_original' defaultMessage='Show original' />
+          </button>
+        </div>
+      );
+    }
+
+    return (
+      <button className='status__content__read-more-button' onClick={onClick}>
+        <FormattedMessage id='status.translate' defaultMessage='Translate' />
+      </button>
+    );
+  }
+
+}
+
+const mapStateToProps = state => ({
+  languages: state.getIn(['server', 'translationLanguages', 'items']),
+});
+
+class StatusContent extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    expanded: PropTypes.bool,
+    collapsed: PropTypes.bool,
+    onExpandedToggle: PropTypes.func,
+    onTranslate: PropTypes.func,
+    media: PropTypes.node,
+    extraMedia: PropTypes.node,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
+    parseClick: PropTypes.func,
+    disabled: PropTypes.bool,
+    onUpdate: PropTypes.func,
+    tagLinks: PropTypes.bool,
+    rewriteMentions: PropTypes.string,
+    languages: ImmutablePropTypes.map,
+    intl: PropTypes.object,
+  };
+
+  static defaultProps = {
+    tagLinks: true,
+    rewriteMentions: 'no',
+  };
+
+  state = {
+    hidden: true,
+  };
+
+  _updateStatusLinks () {
+    const node = this.contentsNode;
+    const { tagLinks, rewriteMentions } = this.props;
+
+    if (!node) {
+      return;
+    }
+
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link = links[i];
+      if (link.classList.contains('status-link')) {
+        continue;
+      }
+      link.classList.add('status-link');
+
+      let mention = this.props.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'));
+        if (rewriteMentions !== 'no') {
+          while (link.firstChild) link.removeChild(link.firstChild);
+          link.appendChild(document.createTextNode('@'));
+          const acctSpan = document.createElement('span');
+          acctSpan.textContent = rewriteMentions === 'acct' ? mention.get('acct') : mention.get('username');
+          link.appendChild(acctSpan);
+        }
+      } 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.classList.add('unhandled-link');
+
+        link.setAttribute('target', '_blank');
+        link.setAttribute('rel', 'noopener nofollow noreferrer');
+
+        try {
+          if (tagLinks && isLinkMisleading(link)) {
+            // Add a tag besides the link to display its origin
+
+            const url = new URL(link.href);
+            const tag = document.createElement('span');
+            tag.classList.add('link-origin-tag');
+            switch (url.protocol) {
+            case 'xmpp:':
+              tag.textContent = `[${url.href}]`;
+              break;
+            case 'magnet:':
+              tag.textContent = '(magnet)';
+              break;
+            default:
+              tag.textContent = `[${url.host}]`;
+            }
+            link.insertAdjacentText('beforeend', ' ');
+            link.insertAdjacentElement('beforeend', tag);
+          }
+        } catch (e) {
+          // The URL is invalid, remove the href just to be safe
+          if (tagLinks && e instanceof TypeError) link.removeAttribute('href');
+        }
+      }
+    }
+  }
+
+  handleMouseEnter = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-original');
+    }
+  };
+
+  handleMouseLeave = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-static');
+    }
+  };
+
+  componentDidMount () {
+    this._updateStatusLinks();
+  }
+
+  componentDidUpdate () {
+    this._updateStatusLinks();
+    if (this.props.onUpdate) this.props.onUpdate();
+  }
+
+  onLinkClick = (e) => {
+    if (this.props.collapsed) {
+      if (this.props.parseClick) this.props.parseClick(e);
+    }
+  };
+
+  onMentionClick = (mention, e) => {
+    if (this.props.parseClick) {
+      this.props.parseClick(e, `/@${mention.get('acct')}`);
+    }
+  };
+
+  onHashtagClick = (hashtag, e) => {
+    hashtag = hashtag.replace(/^#/, '');
+
+    if (this.props.parseClick) {
+      this.props.parseClick(e, `/tags/${hashtag}`);
+    }
+  };
+
+  handleMouseDown = (e) => {
+    this.startXY = [e.clientX, e.clientY];
+  };
+
+  handleMouseUp = (e) => {
+    const { parseClick, disabled } = this.props;
+
+    if (disabled || !this.startXY) {
+      return;
+    }
+
+    const [ startX, startY ] = this.startXY;
+    const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+    let element = e.target;
+    while (element !== e.currentTarget) {
+      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName) || element.getAttribute('role') === 'button') {
+        return;
+      }
+      element = element.parentNode;
+    }
+
+    if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
+      parseClick(e);
+    }
+
+    this.startXY = null;
+  };
+
+  handleSpoilerClick = (e) => {
+    e.preventDefault();
+
+    if (this.props.onExpandedToggle) {
+      this.props.onExpandedToggle();
+    } else {
+      this.setState({ hidden: !this.state.hidden });
+    }
+  };
+
+  handleTranslate = () => {
+    this.props.onTranslate();
+  };
+
+  setContentsRef = (c) => {
+    this.contentsNode = c;
+  };
+
+  render () {
+    const {
+      status,
+      media,
+      extraMedia,
+      mediaIcons,
+      parseClick,
+      disabled,
+      tagLinks,
+      rewriteMentions,
+      intl,
+    } = this.props;
+
+    const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
+    const contentLocale = intl.locale.replace(/[_-].*/, '');
+    const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
+    const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && targetLanguages?.includes(contentLocale);
+
+    const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
+    const spoilerContent = { __html: status.get('spoilerHtml') };
+    const lang = status.get('translation') ? intl.locale : status.get('language');
+    const classNames = classnames('status__content', {
+      'status__content--with-action': parseClick && !disabled,
+      'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+    });
+
+    const translateButton = renderTranslate && (
+      <TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} />
+    );
+
+    if (status.get('spoiler_text').length > 0) {
+      let mentionsPlaceholder = '';
+
+      const mentionLinks = status.get('mentions').map(item => (
+        <Permalink
+          to={`/@${item.get('acct')}`}
+          href={item.get('url')}
+          key={item.get('id')}
+          className='mention'
+        >
+          @<span>{item.get('username')}</span>
+        </Permalink>
+      )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+      let toggleText = null;
+      if (hidden) {
+        toggleText = [
+          <FormattedMessage
+            id='status.show_more'
+            defaultMessage='Show more'
+            key='0'
+          />,
+        ];
+        if (mediaIcons) {
+          mediaIcons.forEach((mediaIcon, idx) => {
+            toggleText.push(
+              <Icon
+                fixedWidth
+                className='status__content__spoiler-icon'
+                id={mediaIcon}
+                aria-hidden='true'
+                key={`icon-${idx}`}
+              />,
+            );
+          });
+        }
+      } else {
+        toggleText = (
+          <FormattedMessage
+            id='status.show_less'
+            defaultMessage='Show less'
+            key='0'
+          />
+        );
+      }
+
+      if (hidden) {
+        mentionsPlaceholder = <div>{mentionLinks}</div>;
+      }
+
+      return (
+        <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+          <p
+            style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
+          >
+            <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={lang} />
+            {' '}
+            <button type='button' className='status__content__spoiler-link' onClick={this.handleSpoilerClick} aria-expanded={!hidden}>
+              {toggleText}
+            </button>
+          </p>
+
+          {mentionsPlaceholder}
+
+          <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
+            <div
+              ref={this.setContentsRef}
+              key={`contents-${tagLinks}`}
+              tabIndex={!hidden ? 0 : null}
+              dangerouslySetInnerHTML={content}
+              className='status__content__text translate'
+              onMouseEnter={this.handleMouseEnter}
+              onMouseLeave={this.handleMouseLeave}
+              lang={lang}
+            />
+            {!hidden && translateButton}
+            {media}
+          </div>
+
+          {extraMedia}
+        </div>
+      );
+    } else if (parseClick) {
+      return (
+        <div
+          className={classNames}
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
+          tabIndex='0'
+        >
+          <div
+            ref={this.setContentsRef}
+            key={`contents-${tagLinks}-${rewriteMentions}`}
+            dangerouslySetInnerHTML={content}
+            className='status__content__text translate'
+            tabIndex='0'
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            lang={lang}
+          />
+          {translateButton}
+          {media}
+          {extraMedia}
+        </div>
+      );
+    } else {
+      return (
+        <div
+          className='status__content'
+          tabIndex='0'
+        >
+          <div
+            ref={this.setContentsRef}
+            key={`contents-${tagLinks}`}
+            className='status__content__text translate'
+            dangerouslySetInnerHTML={content}
+            tabIndex='0'
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            lang={lang}
+          />
+          {translateButton}
+          {media}
+          {extraMedia}
+        </div>
+      );
+    }
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(StatusContent));
diff --git a/app/javascript/flavours/glitch/components/status_header.jsx b/app/javascript/flavours/glitch/components/status_header.jsx
new file mode 100644
index 000000000..21d8b4212
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_header.jsx
@@ -0,0 +1,71 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Mastodon imports.
+import Avatar from './avatar';
+import AvatarOverlay from './avatar_overlay';
+import AvatarComposite from './avatar_composite';
+import DisplayName from './display_name';
+
+export default class StatusHeader extends React.PureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    friend: ImmutablePropTypes.map,
+    parseClick: PropTypes.func.isRequired,
+  };
+
+  //  Handles clicks on account name/image
+  handleClick = (acct, e) => {
+    const { parseClick } = this.props;
+    parseClick(e, `/@${acct}`);
+  };
+
+  handleAccountClick = (e) => {
+    const { status } = this.props;
+    this.handleClick(status.getIn(['account', 'acct']), e);
+  };
+
+  //  Rendering.
+  render () {
+    const {
+      status,
+      friend,
+    } = this.props;
+
+    const account = status.get('account');
+
+    let statusAvatar;
+    if (friend === undefined || friend === null) {
+      statusAvatar = <Avatar account={account} size={48} />;
+    } else {
+      statusAvatar = <AvatarOverlay account={account} friend={friend} />;
+    }
+
+    return (
+      <div className='status__info__account'>
+        <a
+          href={account.get('url')}
+          target='_blank'
+          className='status__avatar'
+          onClick={this.handleAccountClick}
+          rel='noopener noreferrer'
+        >
+          {statusAvatar}
+        </a>
+        <a
+          href={account.get('url')}
+          target='_blank'
+          className='status__display-name'
+          onClick={this.handleAccountClick}
+          rel='noopener noreferrer'
+        >
+          <DisplayName account={account} />
+        </a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_icons.jsx b/app/javascript/flavours/glitch/components/status_icons.jsx
new file mode 100644
index 000000000..3baff2206
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_icons.jsx
@@ -0,0 +1,146 @@
+//  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 IconButton from './icon_button';
+import VisibilityIcon from './status_visibility_icon';
+import Icon from 'flavours/glitch/components/icon';
+import { languages } from 'flavours/glitch/initial_state';
+
+//  Messages for use with internationalization stuff.
+const messages = defineMessages({
+  collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
+  uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
+  inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' },
+  previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' },
+  pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' },
+  poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' },
+  video: { id: 'status.has_video', defaultMessage: 'Features attached videos' },
+  audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' },
+  localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' },
+});
+
+const LanguageIcon = ({ language }) => {
+  if (!languages) return null;
+
+  const lang = languages.find((lang) => lang[0] === language);
+  if (!lang) return null;
+
+  return (
+    <span className='text-icon' title={`${lang[2]} (${lang[1]})`} aria-hidden='true'>
+      {lang[0].toUpperCase()}
+    </span>
+  );
+};
+
+LanguageIcon.propTypes = {
+  language: PropTypes.string.isRequired,
+};
+
+class StatusIcons extends React.PureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
+    collapsible: PropTypes.bool,
+    collapsed: PropTypes.bool,
+    setCollapsed: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    settings: ImmutablePropTypes.map.isRequired,
+  };
+
+  //  Handles clicks on collapsed button
+  handleCollapsedClick = (e) => {
+    const { collapsed, setCollapsed } = this.props;
+    if (e.button === 0) {
+      setCollapsed(!collapsed);
+      e.preventDefault();
+    }
+  };
+
+  mediaIconTitleText (mediaIcon) {
+    const { intl } = this.props;
+
+    switch (mediaIcon) {
+    case 'link':
+      return intl.formatMessage(messages.previewCard);
+    case 'picture-o':
+      return intl.formatMessage(messages.pictures);
+    case 'tasks':
+      return intl.formatMessage(messages.poll);
+    case 'video-camera':
+      return intl.formatMessage(messages.video);
+    case 'music':
+      return intl.formatMessage(messages.audio);
+    }
+  }
+
+  renderIcon (mediaIcon) {
+    return (
+      <Icon
+        fixedWidth
+        className='status__media-icon'
+        key={`media-icon--${mediaIcon}`}
+        id={mediaIcon}
+        aria-hidden='true'
+        title={this.mediaIconTitleText(mediaIcon)}
+      />
+    );
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      status,
+      mediaIcons,
+      collapsible,
+      collapsed,
+      settings,
+      intl,
+    } = this.props;
+
+    return (
+      <div className='status__info__icons'>
+        {settings.get('language') && status.get('language') && <LanguageIcon language={status.get('language')} />}
+        {settings.get('reply') && status.get('in_reply_to_id', null) !== null ? (
+          <Icon
+            className='status__reply-icon'
+            fixedWidth
+            id='comment'
+            aria-hidden='true'
+            title={intl.formatMessage(messages.inReplyTo)}
+          />
+        ) : null}
+        {settings.get('local_only') && status.get('local_only') &&
+          <Icon
+            fixedWidth
+            id='home'
+            aria-hidden='true'
+            title={intl.formatMessage(messages.localOnly)}
+          />}
+        {settings.get('media') && !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon))}
+        {settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />}
+        {collapsible && (
+          <IconButton
+            className='status__collapse-button'
+            animate
+            active={collapsed}
+            title={
+              collapsed ?
+                intl.formatMessage(messages.uncollapse) :
+                intl.formatMessage(messages.collapse)
+            }
+            icon='angle-double-up'
+            onClick={this.handleCollapsedClick}
+          />
+        )}
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(StatusIcons);
diff --git a/app/javascript/flavours/glitch/components/status_list.jsx b/app/javascript/flavours/glitch/components/status_list.jsx
new file mode 100644
index 000000000..a9c06f693
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_list.jsx
@@ -0,0 +1,131 @@
+import { debounce } from 'lodash';
+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 LoadGap from './load_gap';
+import ScrollableList from './scrollable_list';
+import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator';
+
+export default class StatusList extends ImmutablePureComponent {
+
+  static propTypes = {
+    scrollKey: PropTypes.string.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    featuredStatusIds: ImmutablePropTypes.list,
+    onLoadMore: PropTypes.func,
+    onScrollToTop: PropTypes.func,
+    onScroll: PropTypes.func,
+    trackScroll: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    isPartial: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    prepend: PropTypes.node,
+    emptyMessage: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
+    withCounters: PropTypes.bool,
+    timelineId: PropTypes.string.isRequired,
+    regex: PropTypes.string,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  getFeaturedStatusCount = () => {
+    return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
+  };
+
+  getCurrentStatusIndex = (id, featured) => {
+    if (featured) {
+      return this.props.featuredStatusIds.indexOf(id);
+    } else {
+      return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
+    }
+  };
+
+  handleMoveUp = (id, featured) => {
+    const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
+    this._selectChild(elementIndex, true);
+  };
+
+  handleMoveDown = (id, featured) => {
+    const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
+    this._selectChild(elementIndex, false);
+  };
+
+  handleLoadOlder = debounce(() => {
+    this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
+  }, 300, { leading: true });
+
+  _selectChild (index, align_top) {
+    const container = this.node.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  render () {
+    const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other }  = this.props;
+    const { isLoading, isPartial } = other;
+
+    if (isPartial) {
+      return <RegenerationIndicator />;
+    }
+
+    let scrollableContent = (isLoading || statusIds.size > 0) ? (
+      statusIds.map((statusId, index) => statusId === null ? (
+        <LoadGap
+          key={'gap:' + statusIds.get(index + 1)}
+          disabled={isLoading}
+          maxId={index > 0 ? statusIds.get(index - 1) : null}
+          onClick={onLoadMore}
+        />
+      ) : (
+        <StatusContainer
+          key={statusId}
+          id={statusId}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+          contextType={timelineId}
+          scrollKey={this.props.scrollKey}
+          withCounters={this.props.withCounters}
+        />
+      ))
+    ) : null;
+
+    if (scrollableContent && featuredStatusIds) {
+      scrollableContent = featuredStatusIds.map(statusId => (
+        <StatusContainer
+          key={`f-${statusId}`}
+          id={statusId}
+          featured
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+          contextType={timelineId}
+          scrollKey={this.props.scrollKey}
+          withCounters={this.props.withCounters}
+        />
+      )).concat(scrollableContent);
+    }
+
+    return (
+      <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
+        {scrollableContent}
+      </ScrollableList>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx
new file mode 100644
index 000000000..8c4343b04
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_prepend.jsx
@@ -0,0 +1,144 @@
+//  Package imports  //
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+import { me } from 'flavours/glitch/initial_state';
+
+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, `/@${account.get('acct')}`);
+  };
+
+  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 'featured':
+      return (
+        <FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
+      );
+    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 }}
+        />
+      );
+    case 'status':
+      return (
+        <FormattedMessage
+          id='notification.status'
+          defaultMessage='{name} just posted'
+          values={{ name: link }}
+        />
+      );
+    case 'poll':
+      if (me === account.get('id')) {
+        return (
+          <FormattedMessage
+            id='notification.own_poll'
+            defaultMessage='Your poll has ended'
+          />
+        );
+      } else {
+        return (
+          <FormattedMessage
+            id='notification.poll'
+            defaultMessage='A poll you have voted in has ended'
+          />
+        );
+      }
+    case 'update':
+      return (
+        <FormattedMessage
+          id='notification.update'
+          defaultMessage='{name} edited a post'
+          values={{ name: link }}
+        />
+      );
+    }
+    return null;
+  };
+
+  render () {
+    const { Message } = this;
+    const { type } = this.props;
+
+    let iconId;
+
+    switch(type) {
+    case 'favourite':
+      iconId = 'star';
+      break;
+    case 'featured':
+      iconId = 'thumb-tack';
+      break;
+    case 'poll':
+      iconId = 'tasks';
+      break;
+    case 'reblog':
+    case 'reblogged_by':
+      iconId = 'retweet';
+      break;
+    case 'status':
+      iconId = 'bell';
+      break;
+    case 'update':
+      iconId = 'pencil';
+      break;
+    }
+
+    return !type ? null : (
+      <aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
+        <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
+          <Icon
+            className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
+            id={iconId}
+          />
+        </div>
+        <Message />
+      </aside>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status_visibility_icon.jsx b/app/javascript/flavours/glitch/components/status_visibility_icon.jsx
new file mode 100644
index 000000000..fcedfbfd6
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/status_visibility_icon.jsx
@@ -0,0 +1,52 @@
+//  Package imports  //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'flavours/glitch/components/icon';
+
+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: 'Mentioned people only' },
+});
+
+class VisibilityIcon extends ImmutablePureComponent {
+
+  static propTypes = {
+    visibility: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    withLabel: PropTypes.bool,
+  };
+
+  render() {
+    const { withLabel, visibility, intl } = this.props;
+
+    const visibilityIcon = {
+      public: 'globe',
+      unlisted: 'unlock',
+      private: 'lock',
+      direct: 'envelope',
+    }[visibility];
+
+    const label = intl.formatMessage(messages[visibility]);
+
+    const icon = (<Icon
+      className='status__visibility-icon'
+      fixedWidth
+      id={visibilityIcon}
+      title={label}
+      aria-hidden='true'
+    />);
+
+    if (withLabel) {
+      return (<span style={{ whiteSpace: 'nowrap' }}>{icon} {label}</span>);
+    } else {
+      return icon;
+    }
+  }
+
+}
+
+export default injectIntl(VisibilityIcon);
diff --git a/app/javascript/flavours/glitch/components/timeline_hint.jsx b/app/javascript/flavours/glitch/components/timeline_hint.jsx
new file mode 100644
index 000000000..fb55a62cc
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/timeline_hint.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const TimelineHint = ({ resource, url }) => (
+  <div className='timeline-hint'>
+    <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
+    <br />
+    <a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
+  </div>
+);
+
+TimelineHint.propTypes = {
+  resource: PropTypes.node.isRequired,
+  url: PropTypes.string.isRequired,
+};
+
+export default TimelineHint;
diff --git a/app/javascript/flavours/glitch/containers/account_container.jsx b/app/javascript/flavours/glitch/containers/account_container.jsx
new file mode 100644
index 000000000..5b57d730f
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/account_container.jsx
@@ -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/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/admin_component.jsx b/app/javascript/flavours/glitch/containers/admin_component.jsx
new file mode 100644
index 000000000..64dabac8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/admin_component.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class AdminComponent extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    children: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { locale, children } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        {children}
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/compose_container.jsx b/app/javascript/flavours/glitch/containers/compose_container.jsx
new file mode 100644
index 000000000..1e49b89a0
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/compose_container.jsx
@@ -0,0 +1,41 @@
+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/initial_state';
+import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+  store.dispatch(hydrateStore(initialState));
+}
+
+store.dispatch(fetchCustomEmojis());
+
+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/domain_container.jsx b/app/javascript/flavours/glitch/containers/domain_container.jsx
new file mode 100644
index 000000000..e92e102ab
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/domain_container.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { blockDomain, unblockDomain } from '../actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Domain from '../components/domain';
+import { openModal } from '../actions/modal';
+
+const messages = defineMessages({
+  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
+});
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = (state, { }) => ({
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onBlockDomain (domain) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockDomainConfirm),
+      onConfirm: () => dispatch(blockDomain(domain)),
+    }));
+  },
+
+  onUnblockDomain (domain) {
+    dispatch(unblockDomain(domain));
+  },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain));
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..43ce8ca63
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
@@ -0,0 +1,27 @@
+import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import { connect } from 'react-redux';
+import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
+import { isUserTouching } from '../is_mobile';
+
+const mapStateToProps = state => ({
+  openDropdownId: state.getIn(['dropdown_menu', 'openId']),
+  openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
+});
+
+const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
+  onOpen(id, onItemClick, keyboard) {
+    dispatch(isUserTouching() ? openModal('ACTIONS', {
+      status,
+      actions: items,
+      onClick: onItemClick,
+    }) : openDropdownMenu(id, keyboard, scrollKey));
+  },
+
+  onClose(id) {
+    dispatch(closeModal('ACTIONS'));
+    dispatch(closeDropdownMenu(id));
+  },
+});
+
+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.jsx b/app/javascript/flavours/glitch/containers/mastodon.jsx
new file mode 100644
index 000000000..dd7623a81
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/mastodon.jsx
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { Provider as ReduxProvider } from 'react-redux';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll-4';
+import configureStore from 'flavours/glitch/store/configureStore';
+import UI from 'flavours/glitch/features/ui';
+import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
+import { hydrateStore } from 'flavours/glitch/actions/store';
+import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
+import { connectUserStream } from 'flavours/glitch/actions/streaming';
+import ErrorBoundary from 'flavours/glitch/components/error_boundary';
+import initialState, { title as siteTitle } from 'flavours/glitch/initial_state';
+import { getLocale } from 'locales';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
+
+export const store = configureStore();
+const hydrateAction = hydrateStore(initialState);
+store.dispatch(hydrateAction);
+
+// check for deprecated local settings
+store.dispatch(checkDeprecatedLocalSettings());
+
+if (initialState.meta.me) {
+  store.dispatch(fetchCustomEmojis());
+}
+
+const createIdentityContext = state => ({
+  signedIn: !!state.meta.me,
+  accountId: state.meta.me,
+  disabledAccountId: state.meta.disabled_account_id,
+  accessToken: state.meta.access_token,
+  permissions: state.role ? state.role.permissions : 0,
+});
+
+export default class Mastodon extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  static childContextTypes = {
+    identity: PropTypes.shape({
+      signedIn: PropTypes.bool.isRequired,
+      accountId: PropTypes.string,
+      disabledAccountId: PropTypes.string,
+      accessToken: PropTypes.string,
+    }).isRequired,
+  };
+
+  identity = createIdentityContext(initialState);
+
+  getChildContext() {
+    return {
+      identity: this.identity,
+    };
+  }
+
+  componentDidMount() {
+    if (this.identity.signedIn) {
+      this.disconnect = store.dispatch(connectUserStream());
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  shouldUpdateScroll (_, { location }) {
+    return !(location.state?.mastodonModalKey);
+  }
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <ReduxProvider store={store}>
+          <ErrorBoundary>
+            <BrowserRouter>
+              <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
+                <Route path='/' component={UI} />
+              </ScrollContext>
+            </BrowserRouter>
+
+            <Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
+          </ErrorBoundary>
+        </ReduxProvider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/media_container.jsx b/app/javascript/flavours/glitch/containers/media_container.jsx
new file mode 100644
index 000000000..37b5484e6
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/media_container.jsx
@@ -0,0 +1,121 @@
+import React, { PureComponent, Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { fromJS } from 'immutable';
+import { getLocale } from 'mastodon/locales';
+import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import Poll from 'flavours/glitch/components/poll';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
+import ModalRoot from 'flavours/glitch/components/modal_root';
+import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
+import Video from 'flavours/glitch/features/video';
+import Card from 'flavours/glitch/features/status/components/card';
+import Audio from 'flavours/glitch/features/audio';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
+
+export default class MediaContainer extends PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    components: PropTypes.object.isRequired,
+  };
+
+  state = {
+    media: null,
+    index: null,
+    time: null,
+    backgroundColor: null,
+    options: null,
+  };
+
+  handleOpenMedia = (media, index) => {
+    document.body.classList.add('with-modals--active');
+    document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+
+    this.setState({ media, index });
+  };
+
+  handleOpenVideo = (options) => {
+    const { components } = this.props;
+    const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
+    const mediaList = fromJS(media);
+
+    document.body.classList.add('with-modals--active');
+    document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+
+    this.setState({ media: mediaList, options });
+  };
+
+  handleCloseMedia = () => {
+    document.body.classList.remove('with-modals--active');
+    document.documentElement.style.marginRight = 0;
+
+    this.setState({
+      media: null,
+      index: null,
+      time: null,
+      backgroundColor: null,
+      options: null,
+    });
+  };
+
+  setBackgroundColor = color => {
+    this.setState({ backgroundColor: color });
+  };
+
+  render () {
+    const { locale, components } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Fragment>
+          {[].map.call(components, (component, i) => {
+            const componentName = component.getAttribute('data-component');
+            const Component = MEDIA_COMPONENTS[componentName];
+            const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));
+
+            Object.assign(props, {
+              ...(media   ? { media:   fromJS(media)   } : {}),
+              ...(card    ? { card:    fromJS(card)    } : {}),
+              ...(poll    ? { poll:    fromJS(poll)    } : {}),
+              ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
+
+              ...(componentName === 'Video' ? {
+                componentIndex: i,
+                onOpenVideo: this.handleOpenVideo,
+              } : {
+                onOpenMedia: this.handleOpenMedia,
+              }),
+            });
+
+            return ReactDOM.createPortal(
+              <Component {...props} key={`media-${i}`} />,
+              component,
+            );
+          })}
+
+          <ModalRoot backgroundColor={this.state.backgroundColor} onClose={this.handleCloseMedia}>
+            {this.state.media && (
+              <MediaModal
+                media={this.state.media}
+                index={this.state.index || 0}
+                currentTime={this.state.options?.startTime}
+                autoPlay={this.state.options?.autoPlay}
+                volume={this.state.options?.defaultVolume}
+                onClose={this.handleCloseMedia}
+                onChangeBackgroundColor={this.setBackgroundColor}
+              />
+            )}
+          </ModalRoot>
+        </Fragment>
+      </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/poll_container.js b/app/javascript/flavours/glitch/containers/poll_container.js
new file mode 100644
index 000000000..345351cc6
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/poll_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+
+import Poll from 'flavours/glitch/components/poll';
+import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
+
+const mapDispatchToProps = (dispatch, { pollId }) => ({
+  refresh: debounce(
+    () => {
+      dispatch(fetchPoll(pollId));
+    },
+    1000,
+    { leading: true },
+  ),
+
+  onVote (choices) {
+    dispatch(vote(pollId, choices));
+  },
+});
+
+const mapStateToProps = (state, { pollId }) => ({
+  poll: state.getIn(['polls', pollId]),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Poll);
diff --git a/app/javascript/flavours/glitch/containers/scroll_container.js b/app/javascript/flavours/glitch/containers/scroll_container.js
new file mode 100644
index 000000000..d21ff6368
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/scroll_container.js
@@ -0,0 +1,18 @@
+import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
+
+// ScrollContainer is used to automatically scroll to the top when pushing a
+// new history state and remembering the scroll position when going back.
+// There are a few things we need to do differently, though.
+const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
+  // If the change is caused by opening a modal, do not scroll to top
+  return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
+};
+
+export default
+class ScrollContainer extends OriginalScrollContainer {
+
+  static defaultProps = {
+    shouldUpdateScroll: defaultShouldUpdateScroll,
+  };
+
+}
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..9873725e4
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -0,0 +1,277 @@
+import { connect } from 'react-redux';
+import Status from 'flavours/glitch/components/status';
+import { List as ImmutableList } from 'immutable';
+import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  reblog,
+  favourite,
+  bookmark,
+  unreblog,
+  unfavourite,
+  unbookmark,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  hideStatus,
+  revealStatus,
+  editStatus,
+  translateStatus,
+  undoStatusTranslation,
+} from 'flavours/glitch/actions/statuses';
+import {
+  initAddFilter,
+} from 'flavours/glitch/actions/filters';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { initBoostModal } from 'flavours/glitch/actions/boosts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/initial_state';
+import { filterEditLink } from 'flavours/glitch/utils/backend_links';
+import { showAlertForError } from '../actions/alerts';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Spoilers from '../components/spoilers';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+  editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
+  editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+  unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
+  author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
+  matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
+  editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+  const getPictureInPicture = makeGetPictureInPicture();
+
+  const mapStateToProps = (state, props) => {
+
+    let status = getStatus(state, props);
+    let reblogStatus = status ? status.get('reblog', null) : null;
+    let account = undefined;
+    let prepend = undefined;
+
+    if (props.featured && status) {
+      account = status.get('account');
+      prepend = 'featured';
+    } else 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,
+      pictureInPicture: getPictureInPicture(state, props),
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
+
+  onReply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+
+      if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  onModalReblog (status, privacy) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      dispatch(reblog(status, privacy));
+    }
+  },
+
+  onReblog (status, e) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
+        dispatch(initBoostModal({ status, onReblog: this.onModalReblog, missingMediaDescription: true }));
+      } else if (e.shiftKey || !boostModal) {
+        this.onModalReblog(status);
+      } else {
+        dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
+      }
+    });
+  },
+
+  onBookmark (status) {
+    if (status.get('bookmarked')) {
+      dispatch(unbookmark(status));
+    } else {
+      dispatch(bookmark(status));
+    }
+  },
+
+  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'),
+      onError: error => dispatch(showAlertForError(error)),
+    }));
+  },
+
+  onDelete (status, history, withRedraft = false) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  },
+
+  onEdit (status, history) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.editMessage),
+          confirm: intl.formatMessage(messages.editConfirm),
+          onConfirm: () => dispatch(editStatus(status.get('id'), history)),
+        }));
+      } else {
+        dispatch(editStatus(status.get('id'), history));
+      }
+    });
+  },
+
+  onTranslate (status) {
+    if (status.get('translation')) {
+      dispatch(undoStatusTranslation(status.get('id')));
+    } else {
+      dispatch(translateStatus(status.get('id')));
+    }
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onOpenMedia (statusId, media, index) {
+    dispatch(openModal('MEDIA', { statusId, media, index }));
+  },
+
+  onOpenVideo (statusId, media, options) {
+    dispatch(openModal('VIDEO', { statusId, media, options }));
+  },
+
+  onBlock (status) {
+    const account = status.get('account');
+    dispatch(initBlockModal(account));
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
+  },
+
+  onAddFilter (status) {
+    dispatch(initAddFilter(status, { contextType }));
+  },
+
+  onMute (account) {
+    dispatch(initMuteModal(account));
+  },
+
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+  onToggleHidden (status) {
+    if (status.get('hidden')) {
+      dispatch(revealStatus(status.get('id')));
+    } else {
+      dispatch(hideStatus(status.get('id')));
+    }
+  },
+
+  deployPictureInPicture (status, type, mediaProps) {
+    dispatch((_, getState) => {
+      if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
+        dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+      }
+    });
+  },
+
+  onInteractionModal (type, status) {
+    dispatch(openModal('INTERACTION', {
+      type,
+      accountId: status.getIn(['account', 'id']),
+      url: status.get('url'),
+    }));
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/flavours/glitch/extra_polyfills.js b/app/javascript/flavours/glitch/extra_polyfills.js
new file mode 100644
index 000000000..e6c69de8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/extra_polyfills.js
@@ -0,0 +1,2 @@
+import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
+import 'requestidlecallback';
diff --git a/app/javascript/flavours/glitch/features/about/index.jsx b/app/javascript/flavours/glitch/features/about/index.jsx
new file mode 100644
index 000000000..f366f734d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/about/index.jsx
@@ -0,0 +1,220 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Column from 'flavours/glitch/components/column';
+import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
+import { Helmet } from 'react-helmet';
+import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
+import Account from 'flavours/glitch/containers/account_container';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import Image from 'flavours/glitch/components/image';
+
+const messages = defineMessages({
+  title: { id: 'column.about', defaultMessage: 'About' },
+  rules: { id: 'about.rules', defaultMessage: 'Server rules' },
+  blocks: { id: 'about.blocks', defaultMessage: 'Moderated servers' },
+  silenced: { id: 'about.domain_blocks.silenced.title', defaultMessage: 'Limited' },
+  silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
+  suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' },
+  suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' },
+});
+
+const severityMessages = {
+  silence: {
+    title: messages.silenced,
+    explanation: messages.silencedExplanation,
+  },
+
+  suspend: {
+    title: messages.suspended,
+    explanation: messages.suspendedExplanation,
+  },
+};
+
+const mapStateToProps = state => ({
+  server: state.getIn(['server', 'server']),
+  extendedDescription: state.getIn(['server', 'extendedDescription']),
+  domainBlocks: state.getIn(['server', 'domainBlocks']),
+});
+
+class Section extends React.PureComponent {
+
+  static propTypes = {
+    title: PropTypes.string,
+    children: PropTypes.node,
+    open: PropTypes.bool,
+    onOpen: PropTypes.func,
+  };
+
+  state = {
+    collapsed: !this.props.open,
+  };
+
+  handleClick = () => {
+    const { onOpen } = this.props;
+    const { collapsed } = this.state;
+
+    this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
+  };
+
+  render () {
+    const { title, children } = this.props;
+    const { collapsed } = this.state;
+
+    return (
+      <div className={classNames('about__section', { active: !collapsed })}>
+        <div className='about__section__title' role='button' tabIndex='0' onClick={this.handleClick}>
+          <Icon id={collapsed ? 'chevron-right' : 'chevron-down'} fixedWidth /> {title}
+        </div>
+
+        {!collapsed && (
+          <div className='about__section__body'>{children}</div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+class About extends React.PureComponent {
+
+  static propTypes = {
+    server: ImmutablePropTypes.map,
+    extendedDescription: ImmutablePropTypes.map,
+    domainBlocks: ImmutablePropTypes.contains({
+      isLoading: PropTypes.bool,
+      isAvailable: PropTypes.bool,
+      items: ImmutablePropTypes.list,
+    }),
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchServer());
+    dispatch(fetchExtendedDescription());
+  }
+
+  handleDomainBlocksOpen = () => {
+    const { dispatch } = this.props;
+    dispatch(fetchDomainBlocks());
+  };
+
+  render () {
+    const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
+    const isLoading = server.get('isLoading');
+
+    return (
+      <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
+        <div className='scrollable about'>
+          <div className='about__header'>
+            <Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
+            <h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
+            <p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
+          </div>
+
+          <div className='about__meta'>
+            <div className='about__meta__column'>
+              <h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
+
+              <Account id={server.getIn(['contact', 'account', 'id'])} size={36} />
+            </div>
+
+            <hr className='about__meta__divider' />
+
+            <div className='about__meta__column'>
+              <h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
+
+              {isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.getIn(['contact', 'email'])}`}>{server.getIn(['contact', 'email'])}</a>}
+            </div>
+          </div>
+
+          <Section open title={intl.formatMessage(messages.title)}>
+            {extendedDescription.get('isLoading') ? (
+              <>
+                <Skeleton width='100%' />
+                <br />
+                <Skeleton width='100%' />
+                <br />
+                <Skeleton width='100%' />
+                <br />
+                <Skeleton width='70%' />
+              </>
+            ) : (extendedDescription.get('content')?.length > 0 ? (
+              <div
+                className='prose'
+                dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
+              />
+            ) : (
+              <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
+            ))}
+          </Section>
+
+          <Section title={intl.formatMessage(messages.rules)}>
+            {!isLoading && (server.get('rules').isEmpty() ? (
+              <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
+            ) : (
+              <ol className='rules-list'>
+                {server.get('rules').map(rule => (
+                  <li key={rule.get('id')}>
+                    <span className='rules-list__text'>{rule.get('text')}</span>
+                  </li>
+                ))}
+              </ol>
+            ))}
+          </Section>
+
+          <Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
+            {domainBlocks.get('isLoading') ? (
+              <>
+                <Skeleton width='100%' />
+                <br />
+                <Skeleton width='70%' />
+              </>
+            ) : (domainBlocks.get('isAvailable') ? (
+              <>
+                <p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
+
+                <div className='about__domain-blocks'>
+                  {domainBlocks.get('items').map(block => (
+                    <div className='about__domain-blocks__domain' key={block.get('domain')}>
+                      <div className='about__domain-blocks__domain__header'>
+                        <h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
+                        <span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
+                      </div>
+
+                      <p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
+                    </div>
+                  ))}
+                </div>
+              </>
+            ) : (
+              <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
+            ))}
+          </Section>
+
+          <LinkFooter />
+
+          <div className='about__footer'>
+            <p><FormattedMessage id='about.fork_disclaimer' defaultMessage='Glitch-soc is free open source software forked from Mastodon.' /></p>
+            <p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.' /></p>
+          </div>
+        </div>
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='all' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(About));
diff --git a/app/javascript/flavours/glitch/features/account/components/account_note.jsx b/app/javascript/flavours/glitch/features/account/components/account_note.jsx
new file mode 100644
index 000000000..5adca87d0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/account_note.jsx
@@ -0,0 +1,105 @@
+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 Icon from 'flavours/glitch/components/icon';
+import Textarea from 'react-textarea-autosize';
+
+const messages = defineMessages({
+  placeholder: { id: 'account_note.glitch_placeholder', defaultMessage: 'No comment provided' },
+});
+
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    isEditing: PropTypes.bool,
+    isSubmitting: PropTypes.bool,
+    accountNote: PropTypes.string,
+    onEditAccountNote: PropTypes.func.isRequired,
+    onCancelAccountNote: PropTypes.func.isRequired,
+    onSaveAccountNote: PropTypes.func.isRequired,
+    onChangeAccountNote: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleChangeAccountNote = (e) => {
+    this.props.onChangeAccountNote(e.target.value);
+  };
+
+  componentWillUnmount () {
+    if (this.props.isEditing) {
+      this.props.onCancelAccountNote();
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.props.onSaveAccountNote();
+    } else if (e.keyCode === 27) {
+      this.props.onCancelAccountNote();
+    }
+  };
+
+  render () {
+    const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
+
+    if (!account || (!accountNote && !isEditing)) {
+      return null;
+    }
+
+    let action_buttons = null;
+    if (isEditing) {
+      action_buttons = (
+        <div className='account__header__account-note__buttons'>
+          <button className='icon-button' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
+            <Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
+          </button>
+          <div className='flex-spacer' />
+          <button className='icon-button' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
+            <Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
+          </button>
+        </div>
+      );
+    } else {
+      action_buttons = (
+        <div className='account__header__account-note__buttons'>
+          <button className='icon-button' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
+            <Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
+          </button>
+        </div>
+      );
+    }
+
+    let note_container = null;
+    if (isEditing) {
+      note_container = (
+        <Textarea
+          className='account__header__account-note__content'
+          disabled={isSubmitting}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          value={accountNote}
+          onChange={this.handleChangeAccountNote}
+          onKeyDown={this.handleKeyDown}
+          autoFocus
+        />
+      );
+    } else {
+      note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
+    }
+
+    return (
+      <div className='account__header__account-note'>
+        <div className='account__header__account-note__header'>
+          <strong><FormattedMessage id='account.account_note_header' defaultMessage='Note' /></strong>
+          {action_buttons}
+        </div>
+        {note_container}
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Header);
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.jsx b/app/javascript/flavours/glitch/features/account/components/action_bar.jsx
new file mode 100644
index 000000000..e32bc0141
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { NavLink } from 'react-router-dom';
+import { injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+import { me, isStaff } from 'flavours/glitch/initial_state';
+import { profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links';
+import Icon from 'flavours/glitch/components/icon';
+
+class ActionBar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  isStatusesPageActive = (match, location) => {
+    if (!match) {
+      return false;
+    }
+    return !location.pathname.match(/\/(followers|following)\/?$/);
+  };
+
+  render () {
+    const { account, intl } = this.props;
+
+    if (account.get('suspended')) {
+      return (
+        <div>
+          <div className='account__disclaimer'>
+            <Icon id='info-circle' fixedWidth /> <FormattedMessage
+              id='account.suspended_disclaimer_full'
+              defaultMessage='This user has been suspended by a moderator.'
+            />
+          </div>
+        </div>
+      );
+    }
+
+    let extraInfo = '';
+
+    if (account.get('acct') !== account.get('username')) {
+      extraInfo = (
+        <div className='account__disclaimer'>
+          <Icon id='info-circle' fixedWidth /> <FormattedMessage
+            id='account.disclaimer_full'
+            defaultMessage="Information below may reflect the user's profile incompletely."
+          />
+          {' '}
+          <a target='_blank' rel='noopener' href={account.get('url')}>
+            <FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' />
+          </a>
+        </div>
+      );
+    }
+
+    return (
+      <div>
+        {extraInfo}
+
+        <div className='account__action-bar'>
+          <div className='account__action-bar-links'>
+            <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}`}>
+              <FormattedMessage id='account.posts' defaultMessage='Posts' />
+              <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
+            </NavLink>
+
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/following`}>
+              <FormattedMessage id='account.follows' defaultMessage='Follows' />
+              <strong><FormattedNumber value={account.get('following_count')} /></strong>
+            </NavLink>
+
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/followers`}>
+              <FormattedMessage id='account.followers' defaultMessage='Followers' />
+              <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong>
+            </NavLink>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/flavours/glitch/features/account/components/featured_tags.jsx b/app/javascript/flavours/glitch/features/account/components/featured_tags.jsx
new file mode 100644
index 000000000..42e0a8d2f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/featured_tags.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Hashtag from 'flavours/glitch/components/hashtag';
+
+const messages = defineMessages({
+  lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
+  empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
+});
+
+class FeaturedTags extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    featuredTags: ImmutablePropTypes.list,
+    tagged: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { account, featuredTags, intl } = this.props;
+
+    if (!account || account.get('suspended') || featuredTags.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='getting-started__trends'>
+        <h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
+
+        {featuredTags.take(3).map(featuredTag => (
+          <Hashtag
+            key={featuredTag.get('name')}
+            name={featuredTag.get('name')}
+            href={featuredTag.get('url')}
+            to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
+            uses={featuredTag.get('statuses_count')}
+            withGraph={false}
+            description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
+          />
+        ))}
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(FeaturedTags);
diff --git a/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx b/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx
new file mode 100644
index 000000000..73c1737a6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class FollowRequestNote extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account, onAuthorize, onReject } = this.props;
+
+    return (
+      <div className='follow-request-banner'>
+        <div className='follow-request-banner__message'>
+          <FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> }} />
+        </div>
+
+        <div className='follow-request-banner__action'>
+          <button type='button' className='button button-tertiary button--confirmation' onClick={onAuthorize}>
+            <Icon id='check' fixedWidth />
+            <FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
+          </button>
+
+          <button type='button' className='button button-tertiary button--destructive' onClick={onReject}>
+            <Icon id='times' fixedWidth />
+            <FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/header.jsx b/app/javascript/flavours/glitch/features/account/components/header.jsx
new file mode 100644
index 000000000..6f918abcf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/header.jsx
@@ -0,0 +1,406 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { autoPlayGif, me, domain } from 'flavours/glitch/initial_state';
+import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Avatar from 'flavours/glitch/components/avatar';
+import Button from 'flavours/glitch/components/button';
+import { NavLink } from 'react-router-dom';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import AccountNoteContainer from '../containers/account_note_container';
+import FollowRequestNoteContainer from '../containers/follow_request_note_container';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
+  account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
+  media: { id: 'account.media', defaultMessage: 'Media' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+  hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
+  showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+  enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
+  disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
+  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
+  unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
+  add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
+  admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
+  add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
+  languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
+  openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
+});
+
+const titleFromAccount = account => {
+  const displayName = account.get('display_name');
+  const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${domain}` : account.get('acct');
+  const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
+
+  return `${prefix} (@${acct})`;
+};
+
+const dateFormatOptions = {
+  month: 'short',
+  day: 'numeric',
+  year: 'numeric',
+  hour12: false,
+  hour: '2-digit',
+  minute: '2-digit',
+};
+
+class Header extends ImmutablePureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    identity_props: ImmutablePropTypes.list,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
+    onNotifyToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onBlockDomain: PropTypes.func.isRequired,
+    onUnblockDomain: PropTypes.func.isRequired,
+    onEndorseToggle: PropTypes.func.isRequired,
+    onAddToList: PropTypes.func.isRequired,
+    onEditAccountNote: PropTypes.func.isRequired,
+    onChangeLanguages: PropTypes.func.isRequired,
+    onInteractionModal: PropTypes.func.isRequired,
+    onOpenAvatar: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    domain: PropTypes.string.isRequired,
+    hidden: PropTypes.bool,
+  };
+
+  openEditProfile = () => {
+    window.open(profileLink, '_blank');
+  };
+
+  handleMouseEnter = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-original');
+    }
+  };
+
+  handleMouseLeave = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-static');
+    }
+  };
+
+  handleAvatarClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.props.onOpenAvatar();
+    }
+  };
+
+  handleShare = () => {
+    const { account } = this.props;
+
+    navigator.share({
+      text: `${titleFromAccount(account)}\n${account.get('note_plain')}`,
+      url: account.get('url'),
+    }).catch((e) => {
+      if (e.name !== 'AbortError') console.error(e);
+    });
+  };
+
+  render () {
+    const { account, hidden, intl, domain } = this.props;
+    const { signedIn, permissions } = this.context.identity;
+
+    if (!account) {
+      return null;
+    }
+
+    const accountNote = account.getIn(['relationship', 'note']);
+
+    const suspended    = account.get('suspended');
+    const isRemote     = account.get('acct') !== account.get('username');
+    const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
+
+    let info        = [];
+    let actionBtn   = '';
+    let bellBtn     = '';
+    let lockedIcon  = '';
+    let menu        = [];
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>);
+    } else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>);
+    }
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>);
+    } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>);
+    }
+
+    if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
+      bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+    }
+
+    if (me !== account.get('id')) {
+      if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
+        actionBtn = '';
+      } else if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
+      } else if (account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
+      }
+    } else if (profileLink) {
+      actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
+    }
+
+    if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
+      actionBtn = '';
+    }
+
+    if (suspended && !account.getIn(['relationship', 'following'])) {
+      actionBtn = '';
+    }
+
+    if (account.get('locked')) {
+      lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />;
+    }
+
+    if (signedIn && account.get('id') !== me && !suspended) {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
+      menu.push(null);
+    }
+
+    if (isRemote) {
+      menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
+      menu.push(null);
+    }
+
+    if ('share' in navigator && !suspended) {
+      menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+      menu.push(null);
+    }
+
+    if (accountNote === null || accountNote === '') {
+      menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
+    }
+
+    if (account.get('id') === me) {
+      if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
+      if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
+      menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
+      menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
+      menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+      menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+      menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+      menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
+    } else if (signedIn) {
+      if (account.getIn(['relationship', 'following'])) {
+        if (!account.getIn(['relationship', 'muting'])) {
+          if (account.getIn(['relationship', 'showing_reblogs'])) {
+            menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+          } else {
+            menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+          }
+
+          menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages });
+          menu.push(null);
+        }
+
+        menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
+        menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
+        menu.push(null);
+      }
+
+      if (account.getIn(['relationship', 'muting'])) {
+        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+      }
+
+      if (account.getIn(['relationship', 'blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+    }
+
+    if (signedIn && isRemote) {
+      menu.push(null);
+
+      if (account.getIn(['relationship', 'domain_blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain });
+      }
+    }
+
+    if (account.get('id') !== me && ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+      menu.push(null);
+      if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) {
+        menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) });
+      }
+      if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+        menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: remoteDomain }), href: `/admin/instances/${remoteDomain}` });
+      }
+    }
+
+    const content          = { __html: account.get('note_emojified') };
+    const displayNameHtml = { __html: account.get('display_name_html') };
+    const fields          = account.get('fields');
+    const isLocal         = account.get('acct').indexOf('@') === -1;
+    const acct            = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
+    const isIndexable     = !account.get('noindex');
+
+    let badge;
+
+    if (account.get('bot')) {
+      badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>);
+    } else if (account.get('group')) {
+      badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>);
+    } else {
+      badge = null;
+    }
+
+    let role = null;
+    if (account.getIn(['roles', 0])) {
+      role = (<div key='role' className={`account-role user-role-${account.getIn(['roles', 0, 'id'])}`}>{account.getIn(['roles', 0, 'name'])}</div>);
+    }
+
+    return (
+      <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
+
+        <div className='account__header__image'>
+          <div className='account__header__info'>
+            {info}
+          </div>
+
+          {!(suspended || hidden) && <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />}
+        </div>
+
+        <div className='account__header__bar'>
+          <div className='account__header__tabs'>
+            <a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
+              <Avatar account={suspended || hidden ? undefined : account} size={90} />
+              {role}
+            </a>
+
+            {!suspended && (
+              <div className='account__header__tabs__buttons'>
+                {!hidden && (
+                  <React.Fragment>
+                    {actionBtn}
+                    {bellBtn}
+                  </React.Fragment>
+                )}
+
+                <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' size={24} direction='right' />
+              </div>
+            )}
+          </div>
+
+          <div className='account__header__tabs__name'>
+            <h1>
+              <span dangerouslySetInnerHTML={displayNameHtml} /> {badge}
+              <small>
+                <span>@{acct}</span> {lockedIcon}
+              </small>
+            </h1>
+          </div>
+
+          {signedIn && <AccountNoteContainer account={account} />}
+
+          {!(suspended || hidden) && (
+            <div className='account__header__extra'>
+              <div className='account__header__bio'>
+                { fields.size > 0 && (
+                  <div className='account__header__fields'>
+                    {fields.map((pair, i) => (
+                      <dl key={i}>
+                        <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+
+                        <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
+                          {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} className='translate' />
+                        </dd>
+                      </dl>
+                    ))}
+                  </div>
+                )}
+
+                {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
+
+                <div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <Helmet>
+          <title>{titleFromAccount(account)}</title>
+          <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
+        </Helmet>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Header);
diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx b/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx
new file mode 100644
index 000000000..62a459fff
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ColumnHeader from '../../../components/column_header';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  profile: { id: 'column_header.profile', defaultMessage: 'Profile' },
+});
+
+class ProfileColumnHeader extends React.PureComponent {
+
+  static propTypes = {
+    onClick: PropTypes.func,
+    multiColumn: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render() {
+    const { onClick, intl, multiColumn } = this.props;
+
+    return (
+      <ColumnHeader
+        icon='user-circle'
+        title={intl.formatMessage(messages.profile)}
+        onClick={onClick}
+        showBackButton
+        multiColumn={multiColumn}
+      />
+    );
+  }
+
+}
+
+export default injectIntl(ProfileColumnHeader);
diff --git a/app/javascript/flavours/glitch/features/account/containers/account_note_container.js b/app/javascript/flavours/glitch/features/account/containers/account_note_container.js
new file mode 100644
index 000000000..f1d007ecb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/containers/account_note_container.js
@@ -0,0 +1,34 @@
+import { connect } from 'react-redux';
+import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'flavours/glitch/actions/account_notes';
+import AccountNote from '../components/account_note';
+
+const mapStateToProps = (state, { account }) => {
+  const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
+
+  return {
+    isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
+    accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
+    isEditing,
+  };
+};
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+
+  onEditAccountNote() {
+    dispatch(initEditAccountNote(account));
+  },
+
+  onSaveAccountNote() {
+    dispatch(submitAccountNote());
+  },
+
+  onCancelAccountNote() {
+    dispatch(cancelAccountNote());
+  },
+
+  onChangeAccountNote(comment) {
+    dispatch(changeAccountNoteComment(comment));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
diff --git a/app/javascript/flavours/glitch/features/account/containers/featured_tags_container.js b/app/javascript/flavours/glitch/features/account/containers/featured_tags_container.js
new file mode 100644
index 000000000..6f0b06941
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/containers/featured_tags_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import FeaturedTags from '../components/featured_tags';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import { List as ImmutableList } from 'immutable';
+
+const mapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  return (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+    featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
+  });
+};
+
+export default connect(mapStateToProps)(FeaturedTags);
diff --git a/app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js b/app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js
new file mode 100644
index 000000000..c6a3afb7e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import FollowRequestNote from '../components/follow_request_note';
+import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts';
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+  onAuthorize () {
+    dispatch(authorizeFollowRequest(account.get('id')));
+  },
+
+  onReject () {
+    dispatch(rejectFollowRequest(account.get('id')));
+  },
+});
+
+export default connect(null, mapDispatchToProps)(FollowRequestNote);
diff --git a/app/javascript/flavours/glitch/features/account/navigation.jsx b/app/javascript/flavours/glitch/features/account/navigation.jsx
new file mode 100644
index 000000000..b8b8e54de
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/navigation.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import FeaturedTags from 'flavours/glitch/features/account/containers/featured_tags_container';
+import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
+
+const mapStateToProps = (state, { match: { params: { acct } } }) => {
+  const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    isLoading: false,
+  };
+};
+
+class AccountNavigation extends React.PureComponent {
+
+  static propTypes = {
+    match: PropTypes.shape({
+      params: PropTypes.shape({
+        acct: PropTypes.string,
+        tagged: PropTypes.string,
+      }).isRequired,
+    }).isRequired,
+
+    accountId: PropTypes.string,
+    isLoading: PropTypes.bool,
+  };
+
+  render () {
+    const { accountId, isLoading, match: { params: { tagged } } } = this.props;
+
+    if (isLoading) {
+      return null;
+    }
+
+    return (
+      <>
+        <div className='flex-spacer' />
+        <FeaturedTags accountId={accountId} tagged={tagged} />
+      </>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(AccountNavigation);
diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx
new file mode 100644
index 000000000..5fd84996b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx
@@ -0,0 +1,149 @@
+import Blurhash from 'flavours/glitch/components/blurhash';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class MediaItem extends ImmutablePureComponent {
+
+  static propTypes = {
+    attachment: ImmutablePropTypes.map.isRequired,
+    displayWidth: PropTypes.number.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
+  };
+
+  state = {
+    visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
+    loaded: false,
+  };
+
+  handleImageLoad = () => {
+    this.setState({ loaded: true });
+  };
+
+  handleMouseEnter = e => {
+    if (this.hoverToPlay()) {
+      e.target.play();
+    }
+  };
+
+  handleMouseLeave = e => {
+    if (this.hoverToPlay()) {
+      e.target.pause();
+      e.target.currentTime = 0;
+    }
+  };
+
+  hoverToPlay () {
+    return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
+  }
+
+  handleClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+
+      if (this.state.visible) {
+        this.props.onOpenMedia(this.props.attachment);
+      } else {
+        this.setState({ visible: true });
+      }
+    }
+  };
+
+  render () {
+    const { attachment, displayWidth } = this.props;
+    const { visible, loaded } = this.state;
+
+    const width  = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
+    const height = width;
+    const status = attachment.get('status');
+    const title  = status.get('spoiler_text') || attachment.get('description');
+
+    let thumbnail, label, icon, content;
+
+    if (!visible) {
+      icon = (
+        <span className='account-gallery__item__icons'>
+          <Icon id='eye-slash' />
+        </span>
+      );
+    } else {
+      if (['audio', 'video'].includes(attachment.get('type'))) {
+        content = (
+          <img
+            src={attachment.get('preview_url') || attachment.getIn(['account', 'avatar_static'])}
+            alt={attachment.get('description')}
+            lang={status.get('language')}
+            onLoad={this.handleImageLoad}
+          />
+        );
+
+        if (attachment.get('type') === 'audio') {
+          label = <Icon id='music' />;
+        } else {
+          label = <Icon id='play' />;
+        }
+      } else if (attachment.get('type') === 'image') {
+        const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
+        const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
+        const x      = ((focusX /  2) + .5) * 100;
+        const y      = ((focusY / -2) + .5) * 100;
+
+        content = (
+          <img
+            src={attachment.get('preview_url')}
+            alt={attachment.get('description')}
+            lang={status.get('language')}
+            style={{ objectPosition: `${x}% ${y}%` }}
+            onLoad={this.handleImageLoad}
+          />
+        );
+      } else if (attachment.get('type') === 'gifv') {
+        content = (
+          <video
+            className='media-gallery__item-gifv-thumbnail'
+            aria-label={attachment.get('description')}
+            title={attachment.get('description')}
+            lang={status.get('language')}
+            role='application'
+            src={attachment.get('url')}
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            autoPlay={autoPlayGif}
+            playsInline
+            loop
+            muted
+          />
+        );
+
+        label = 'GIF';
+      }
+
+      thumbnail = (
+        <div className='media-gallery__gifv'>
+          {content}
+
+          {label && <span className='media-gallery__gifv__label'>{label}</span>}
+        </div>
+      );
+    }
+
+    return (
+      <div className='account-gallery__item' style={{ width, height }}>
+        <a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
+          <Blurhash
+            hash={attachment.get('blurhash')}
+            className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}
+            dummy={!useBlurhash}
+          />
+
+          {visible ? thumbnail : icon}
+        </a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.jsx b/app/javascript/flavours/glitch/features/account_gallery/index.jsx
new file mode 100644
index 000000000..6914bcba7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.jsx
@@ -0,0 +1,226 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
+import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { getAccountGallery } from 'flavours/glitch/selectors';
+import MediaItem from './components/media_item';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
+import LoadMore from 'flavours/glitch/components/load_more';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
+
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    isAccount: !!state.getIn(['accounts', accountId]),
+    attachments: getAccountGallery(state, accountId),
+    isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
+    hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+  };
+};
+
+class LoadMoreMedia extends ImmutablePureComponent {
+
+  static propTypes = {
+    maxId: PropTypes.string,
+    onLoadMore: PropTypes.func.isRequired,
+  };
+
+  handleLoadMore = () => {
+    this.props.onLoadMore(this.props.maxId);
+  };
+
+  render () {
+    return (
+      <LoadMore
+        disabled={this.props.disabled}
+        onClick={this.handleLoadMore}
+      />
+    );
+  }
+
+}
+
+class AccountGallery extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    attachments: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    suspended: PropTypes.bool,
+  };
+
+  state = {
+    width: 323,
+  };
+
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(expandAccountMediaTimeline(accountId));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  handleScrollToBottom = () => {
+    if (this.props.hasMore) {
+      this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
+    }
+  };
+
+  handleScroll = e => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+
+    if (150 > offset && !this.props.isLoading) {
+      this.handleScrollToBottom();
+    }
+  };
+
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
+  };
+
+  handleLoadOlder = e => {
+    e.preventDefault();
+    this.handleScrollToBottom();
+  };
+
+  setColumnRef = c => {
+    this.column = c;
+  };
+
+  handleOpenMedia = attachment => {
+    const { dispatch } = this.props;
+    const statusId = attachment.getIn(['status', 'id']);
+
+    if (attachment.get('type') === 'video') {
+      dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } }));
+    } else if (attachment.get('type') === 'audio') {
+      dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } }));
+    } else {
+      const media = attachment.getIn(['status', 'media_attachments']);
+      const index = media.findIndex(x => x.get('id') === attachment.get('id'));
+
+      dispatch(openModal('MEDIA', { media, index, statusId }));
+    }
+  };
+
+  handleRef = c => {
+    if (c) {
+      this.setState({ width: c.offsetWidth });
+    }
+  };
+
+  render () {
+    const { attachments, isLoading, hasMore, isAccount, multiColumn, suspended } = this.props;
+    const { width } = this.state;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!attachments && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    let loadOlder = null;
+
+    if (hasMore && !(isLoading && attachments.size === 0)) {
+      loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
+    }
+
+    return (
+      <Column ref={this.setColumnRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <ScrollContainer scrollKey='account_gallery'>
+          <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
+            <HeaderContainer accountId={this.props.accountId} />
+
+            {suspended ? (
+              <div className='empty-column-indicator'>
+                <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />
+              </div>
+            ) : (
+              <div role='feed' className='account-gallery__container' ref={this.handleRef}>
+                {attachments.map((attachment, index) => attachment === null ? (
+                  <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
+                ) : (
+                  <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
+                ))}
+
+                {loadOlder}
+              </div>
+            )}
+
+            {isLoading && attachments.size === 0 && (
+              <div className='scrollable__append'>
+                <LoadingIndicator />
+              </div>
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(AccountGallery);
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx b/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx
new file mode 100644
index 000000000..eec065b43
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from 'flavours/glitch/features/account/components/header';
+import ActionBar from 'flavours/glitch/features/account/components/action_bar';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import { NavLink } from 'react-router-dom';
+import MovedNote from './moved_note';
+
+export default class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onBlockDomain: PropTypes.func.isRequired,
+    onUnblockDomain: PropTypes.func.isRequired,
+    onEndorseToggle: PropTypes.func.isRequired,
+    onAddToList: PropTypes.func.isRequired,
+    onChangeLanguages: PropTypes.func.isRequired,
+    onInteractionModal: PropTypes.func.isRequired,
+    onOpenAvatar: PropTypes.func.isRequired,
+    hideTabs: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
+    hidden: PropTypes.bool,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  };
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  };
+
+  handleMention = () => {
+    this.props.onMention(this.props.account, this.context.router.history);
+  };
+
+  handleDirect = () => {
+    this.props.onDirect(this.props.account, this.context.router.history);
+  };
+
+  handleReport = () => {
+    this.props.onReport(this.props.account);
+  };
+
+  handleReblogToggle = () => {
+    this.props.onReblogToggle(this.props.account);
+  };
+
+  handleNotifyToggle = () => {
+    this.props.onNotifyToggle(this.props.account);
+  };
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  };
+
+  handleBlockDomain = () => {
+    const domain = this.props.account.get('acct').split('@')[1];
+
+    if (!domain) return;
+
+    this.props.onBlockDomain(domain);
+  };
+
+  handleUnblockDomain = () => {
+    const domain = this.props.account.get('acct').split('@')[1];
+
+    if (!domain) return;
+
+    this.props.onUnblockDomain(domain);
+  };
+
+  handleEndorseToggle = () => {
+    this.props.onEndorseToggle(this.props.account);
+  };
+
+  handleAddToList = () => {
+    this.props.onAddToList(this.props.account);
+  };
+
+  handleEditAccountNote = () => {
+    this.props.onEditAccountNote(this.props.account);
+  };
+
+  handleChangeLanguages = () => {
+    this.props.onChangeLanguages(this.props.account);
+  };
+
+  handleInteractionModal = () => {
+    this.props.onInteractionModal(this.props.account);
+  };
+
+  handleOpenAvatar = () => {
+    this.props.onOpenAvatar(this.props.account);
+  };
+
+  render () {
+    const { account, hidden, hideTabs } = this.props;
+
+    if (account === null) {
+      return null;
+    }
+
+    return (
+      <div className='account-timeline__header'>
+        {(!hidden && account.get('moved')) && <MovedNote from={account} to={account.get('moved')} />}
+
+        <InnerHeader
+          account={account}
+          onFollow={this.handleFollow}
+          onBlock={this.handleBlock}
+          onMention={this.handleMention}
+          onDirect={this.handleDirect}
+          onReblogToggle={this.handleReblogToggle}
+          onNotifyToggle={this.handleNotifyToggle}
+          onReport={this.handleReport}
+          onMute={this.handleMute}
+          onBlockDomain={this.handleBlockDomain}
+          onUnblockDomain={this.handleUnblockDomain}
+          onEndorseToggle={this.handleEndorseToggle}
+          onAddToList={this.handleAddToList}
+          onEditAccountNote={this.handleEditAccountNote}
+          onChangeLanguages={this.handleChangeLanguages}
+          onInteractionModal={this.handleInteractionModal}
+          onOpenAvatar={this.handleOpenAvatar}
+          domain={this.props.domain}
+          hidden={hidden}
+        />
+
+        <ActionBar
+          account={account}
+        />
+
+        {!(hideTabs || hidden) && (
+          <div className='account__section-headline'>
+            <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts with replies' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.jsx b/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.jsx
new file mode 100644
index 000000000..c622b7607
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { revealAccount } from 'flavours/glitch/actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+import { domain } from 'flavours/glitch/initial_state';
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+
+  reveal () {
+    dispatch(revealAccount(accountId));
+  },
+
+});
+
+class LimitedAccountHint extends React.PureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    reveal: PropTypes.func,
+  };
+
+  render () {
+    const { reveal } = this.props;
+
+    return (
+      <div className='limited-account-hint'>
+        <p><FormattedMessage id='limited_account_hint.title' defaultMessage='This profile has been hidden by the moderators of {domain}.' values={{ domain }} /></p>
+        <Button onClick={reveal}><FormattedMessage id='limited_account_hint.action' defaultMessage='Show profile anyway' /></Button>
+      </div>
+    );
+  }
+
+}
+
+export default connect(() => {}, mapDispatchToProps)(LimitedAccountHint);
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.jsx b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.jsx
new file mode 100644
index 000000000..40bdc4034
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AvatarOverlay from '../../../components/avatar_overlay';
+import DisplayName from '../../../components/display_name';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class MovedNote extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    from: ImmutablePropTypes.map.isRequired,
+    to: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleAccountClick = e => {
+    if (e.button === 0) {
+      e.preventDefault();
+      let state = { ...this.context.router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/@${this.props.to.get('acct')}`, state);
+    }
+
+    e.stopPropagation();
+  };
+
+  render () {
+    const { from, to } = this.props;
+    const displayNameHtml = { __html: from.get('display_name_html') };
+
+    return (
+      <div className='account__moved-note'>
+        <div className='account__moved-note__message'>
+          <div className='account__moved-note__icon-wrapper'><Icon id='suitcase' className='account__moved-note__icon' fixedWidth /></div>
+          <FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} />
+        </div>
+
+        <a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
+          <DisplayName account={to} />
+        </a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx
new file mode 100644
index 000000000..3ec47cf2f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx
@@ -0,0 +1,173 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount, getAccountHidden } from 'flavours/glitch/selectors';
+import Header from '../components/header';
+import {
+  followAccount,
+  unfollowAccount,
+  unblockAccount,
+  unmuteAccount,
+  pinAccount,
+  unpinAccount,
+} from 'flavours/glitch/actions/accounts';
+import {
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks';
+import { initEditAccountNote } from 'flavours/glitch/actions/account_notes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { unfollowModal } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+    domain: state.getIn(['meta', 'domain']),
+    hidden: getAccountHidden(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following'])) {
+      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 if (account.getIn(['relationship', 'requested'])) {
+      if (unfollowModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unfollowAccount(account.get('id')));
+      }
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onInteractionModal (account) {
+    dispatch(openModal('INTERACTION', {
+      type: 'follow',
+      accountId: account.get('id'),
+      url: account.get('url'),
+    }));
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(initBlockModal(account));
+    }
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onReblogToggle (account) {
+    if (account.getIn(['relationship', 'showing_reblogs'])) {
+      dispatch(followAccount(account.get('id'), { reblogs: false }));
+    } else {
+      dispatch(followAccount(account.get('id'), { reblogs: true }));
+    }
+  },
+
+  onEndorseToggle (account) {
+    if (account.getIn(['relationship', 'endorsed'])) {
+      dispatch(unpinAccount(account.get('id')));
+    } else {
+      dispatch(pinAccount(account.get('id')));
+    }
+  },
+
+  onNotifyToggle (account) {
+    if (account.getIn(['relationship', 'notifying'])) {
+      dispatch(followAccount(account.get('id'), { notify: false }));
+    } else {
+      dispatch(followAccount(account.get('id'), { notify: true }));
+    }
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+  onEditAccountNote (account) {
+    dispatch(initEditAccountNote(account));
+  },
+
+  onBlockDomain (domain) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockDomainConfirm),
+      onConfirm: () => dispatch(blockDomain(domain)),
+    }));
+  },
+
+  onUnblockDomain (domain) {
+    dispatch(unblockDomain(domain));
+  },
+
+  onAddToList (account) {
+    dispatch(openModal('LIST_ADDER', {
+      accountId: account.get('id'),
+    }));
+  },
+
+  onChangeLanguages (account) {
+    dispatch(openModal('SUBSCRIBED_LANGUAGES', {
+      accountId: account.get('id'),
+    }));
+  },
+
+  onOpenAvatar (account) {
+    dispatch(openModal('IMAGE', {
+      src: account.get('avatar'),
+      alt: account.get('acct'),
+    }));
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.jsx b/app/javascript/flavours/glitch/features/account_timeline/index.jsx
new file mode 100644
index 000000000..38361b1ca
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.jsx
@@ -0,0 +1,210 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
+import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
+import StatusList from '../../components/status_list';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import HeaderContainer from './containers/header_container';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import { List as ImmutableList } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
+import LimitedAccountHint from './components/limited_account_hint';
+import { getAccountHidden } from 'flavours/glitch/selectors';
+import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
+import { fetchFeaturedTags } from '../../actions/featured_tags';
+
+const emptyList = ImmutableList();
+
+const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
+  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+  if (accountId === null) {
+    return {
+      isLoading: false,
+      isAccount: false,
+      statusIds: emptyList,
+    };
+  } else if (!accountId) {
+    return {
+      isLoading: true,
+      statusIds: emptyList,
+    };
+  }
+
+  const path = withReplies ? `${accountId}:with_replies` : `${accountId}${tagged ? `:${tagged}` : ''}`;
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
+    featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], ImmutableList()),
+    isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
+    hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+    hidden: getAccountHidden(state, accountId),
+  };
+};
+
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older posts' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
+class AccountTimeline extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+      tagged: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list,
+    featuredStatusIds: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    withReplies: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    suspended: PropTypes.bool,
+    hidden: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  _load () {
+    const { accountId, withReplies, params: { tagged }, dispatch } = this.props;
+
+    dispatch(fetchAccount(accountId));
+
+    if (!withReplies) {
+      dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
+    }
+
+    dispatch(fetchFeaturedTags(accountId));
+    dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { params: { acct, tagged }, accountId, withReplies, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    } else if (prevProps.params.tagged !== tagged) {
+      if (!withReplies) {
+        dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
+      }
+      dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
+    }
+  }
+
+  componentWillReceiveProps (nextProps) {
+    const { dispatch } = this.props;
+
+    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
+      dispatch(fetchAccount(nextProps.params.accountId));
+
+      if (!nextProps.withReplies) {
+        dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
+      }
+
+      dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged }));
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  render () {
+    const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
+
+    if (isLoading && statusIds.isEmpty()) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    } else if (!isLoading && !isAccount) {
+      return (
+        <Column>
+          <ColumnBackButton multiColumn={multiColumn} />
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    let emptyMessage;
+
+    const forceEmptyState = suspended || hidden;
+
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (hidden) {
+      emptyMessage = <LimitedAccountHint accountId={accountId} />;
+    } else if (remote && statusIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
+
+    return (
+      <Column ref={this.setRef} name='account'>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <StatusList
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
+          alwaysPrepend
+          append={remoteMessage}
+          scrollKey='account_timeline'
+          statusIds={forceEmptyState ? emptyList : statusIds}
+          featuredStatusIds={featuredStatusIds}
+          isLoading={isLoading}
+          hasMore={!forceEmptyState && hasMore}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+          timelineId='account'
+        />
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(AccountTimeline);
diff --git a/app/javascript/flavours/glitch/features/audio/index.jsx b/app/javascript/flavours/glitch/features/audio/index.jsx
new file mode 100644
index 000000000..fd7229cc5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/audio/index.jsx
@@ -0,0 +1,578 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { formatTime, getPointerPosition, fileNameFromURL } from 'flavours/glitch/features/video';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { throttle, debounce } from 'lodash';
+import Visualizer from './visualizer';
+import { displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
+import Blurhash from 'flavours/glitch/components/blurhash';
+import { is } from 'immutable';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+  download: { id: 'video.download', defaultMessage: 'Download file' },
+  hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
+});
+
+const TICK_SIZE = 10;
+const PADDING   = 180;
+
+class Audio extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    lang: PropTypes.string,
+    poster: PropTypes.string,
+    duration: PropTypes.number,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    sensitive: PropTypes.bool,
+    editable: PropTypes.bool,
+    fullscreen: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    blurhash: PropTypes.string,
+    cacheWidth: PropTypes.func,
+    visible: PropTypes.bool,
+    onToggleVisibility: PropTypes.func,
+    backgroundColor: PropTypes.string,
+    foregroundColor: PropTypes.string,
+    accentColor: PropTypes.string,
+    currentTime: PropTypes.number,
+    autoPlay: PropTypes.bool,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
+    deployPictureInPicture: PropTypes.func,
+  };
+
+  state = {
+    width: this.props.width,
+    currentTime: 0,
+    buffer: 0,
+    duration: null,
+    paused: true,
+    muted: false,
+    volume: 1,
+    dragging: false,
+    revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
+  };
+
+  constructor (props) {
+    super(props);
+    this.visualizer = new Visualizer(TICK_SIZE);
+  }
+
+  setPlayerRef = c => {
+    this.player = c;
+
+    if (this.player) {
+      this._setDimensions();
+    }
+  };
+
+  _pack() {
+    return {
+      src: this.props.src,
+      volume: this.state.volume,
+      muted: this.state.muted,
+      currentTime: this.audio.currentTime,
+      poster: this.props.poster,
+      backgroundColor: this.props.backgroundColor,
+      foregroundColor: this.props.foregroundColor,
+      accentColor: this.props.accentColor,
+      sensitive: this.props.sensitive,
+      visible: this.props.visible,
+    };
+  }
+
+  _setDimensions () {
+    const width  = this.player.offsetWidth;
+    const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
+
+    if (width && width != this.state.containerWidth) {
+      if (this.props.cacheWidth) {
+        this.props.cacheWidth(width);
+      }
+
+      this.setState({ width, height });
+    }
+  }
+
+  setSeekRef = c => {
+    this.seek = c;
+  };
+
+  setVolumeRef = c => {
+    this.volume = c;
+  };
+
+  setAudioRef = c => {
+    this.audio = c;
+
+    if (this.audio) {
+      this.audio.volume = 1;
+      this.audio.muted = false;
+    }
+  };
+
+  setCanvasRef = c => {
+    this.canvas = c;
+
+    this.visualizer.setCanvas(c);
+  };
+
+  componentDidMount () {
+    window.addEventListener('scroll', this.handleScroll);
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    if (this.player) {
+      this._setDimensions();
+    }
+
+    if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
+      this._clear();
+      this._draw();
+    }
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+      this.setState({ revealed: nextProps.visible });
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('scroll', this.handleScroll);
+    window.removeEventListener('resize', this.handleResize);
+
+    if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('audio', this._pack());
+    }
+  }
+
+  togglePlay = () => {
+    if (!this.audioContext) {
+      this._initAudioContext();
+    }
+
+    if (this.state.paused) {
+      this.setState({ paused: false }, () => this.audio.play());
+    } else {
+      this.setState({ paused: true }, () => this.audio.pause());
+    }
+  };
+
+  handleResize = debounce(() => {
+    if (this.player) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  handlePlay = () => {
+    this.setState({ paused: false });
+
+    if (this.audioContext && this.audioContext.state === 'suspended') {
+      this.audioContext.resume();
+    }
+
+    this._renderCanvas();
+  };
+
+  handlePause = () => {
+    this.setState({ paused: true });
+
+    if (this.audioContext) {
+      this.audioContext.suspend();
+    }
+  };
+
+  handleProgress = () => {
+    const lastTimeRange = this.audio.buffered.length - 1;
+
+    if (lastTimeRange > -1) {
+      this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
+    }
+  };
+
+  toggleMute = () => {
+    const muted = !this.state.muted;
+
+    this.setState({ muted }, () => {
+      if (this.gainNode) {
+        this.gainNode.gain.value = muted ? 0 : this.state.volume;
+      }
+    });
+  };
+
+  toggleReveal = () => {
+    if (this.props.onToggleVisibility) {
+      this.props.onToggleVisibility();
+    } else {
+      this.setState({ revealed: !this.state.revealed });
+    }
+  };
+
+  handleVolumeMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+    this.handleMouseVolSlide(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  };
+
+  handleVolumeMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+  };
+
+  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.audio.pause();
+    this.handleMouseMove(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  };
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove, true);
+    document.removeEventListener('mouseup', this.handleMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseMove, true);
+    document.removeEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: false });
+    this.audio.play();
+  };
+
+  handleMouseMove = throttle(e => {
+    const { x } = getPointerPosition(this.seek, e);
+    const currentTime = this.audio.duration * x;
+
+    if (!isNaN(currentTime)) {
+      this.setState({ currentTime }, () => {
+        this.audio.currentTime = currentTime;
+      });
+    }
+  }, 15);
+
+  handleTimeUpdate = () => {
+    this.setState({
+      currentTime: this.audio.currentTime,
+      duration: this.audio.duration,
+    });
+  };
+
+  handleMouseVolSlide = throttle(e => {
+    const { x } = getPointerPosition(this.volume, e);
+
+    if(!isNaN(x)) {
+      this.setState({ volume: x }, () => {
+        if (this.gainNode) {
+          this.gainNode.gain.value = this.state.muted ? 0 : x;
+        }
+      });
+    }
+  }, 15);
+
+  handleScroll = throttle(() => {
+    if (!this.canvas || !this.audio) {
+      return;
+    }
+
+    const { top, height } = this.canvas.getBoundingClientRect();
+    const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+    if (!this.state.paused && !inView) {
+      this.audio.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('audio', this._pack());
+      }
+
+      this.setState({ paused: true });
+    }
+  }, 150, { trailing: true });
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  };
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  };
+
+  handleLoadedData = () => {
+    const { autoPlay, currentTime } = this.props;
+
+    if (currentTime) {
+      this.audio.currentTime = currentTime;
+    }
+
+    if (autoPlay) {
+      this.togglePlay();
+    }
+  };
+
+  _initAudioContext () {
+    const AudioContext = window.AudioContext || window.webkitAudioContext;
+    const context      = new AudioContext();
+    const source       = context.createMediaElementSource(this.audio);
+    const gainNode     = context.createGain();
+
+    gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
+
+    this.visualizer.setAudioContext(context, source);
+    source.connect(gainNode);
+    gainNode.connect(context.destination);
+
+    this.audioContext = context;
+    this.gainNode = gainNode;
+  }
+
+  handleDownload = () => {
+    fetch(this.props.src).then(res => res.blob()).then(blob => {
+      const element   = document.createElement('a');
+      const objectURL = URL.createObjectURL(blob);
+
+      element.setAttribute('href', objectURL);
+      element.setAttribute('download', fileNameFromURL(this.props.src));
+
+      document.body.appendChild(element);
+      element.click();
+      document.body.removeChild(element);
+
+      URL.revokeObjectURL(objectURL);
+    }).catch(err => {
+      console.error(err);
+    });
+  };
+
+  _renderCanvas () {
+    requestAnimationFrame(() => {
+      if (!this.audio) return;
+
+      this.handleTimeUpdate();
+      this._clear();
+      this._draw();
+
+      if (!this.state.paused) {
+        this._renderCanvas();
+      }
+    });
+  }
+
+  _clear() {
+    this.visualizer.clear(this.state.width, this.state.height);
+  }
+
+  _draw() {
+    this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
+  }
+
+  _getRadius () {
+    return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
+  }
+
+  _getScaleCoefficient () {
+    return (this.state.height || this.props.height) / 982;
+  }
+
+  _getCX() {
+    return Math.floor(this.state.width / 2);
+  }
+
+  _getCY() {
+    return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
+  }
+
+  _getAccentColor () {
+    return this.props.accentColor || '#ffffff';
+  }
+
+  _getBackgroundColor () {
+    return this.props.backgroundColor || '#000000';
+  }
+
+  _getForegroundColor () {
+    return this.props.foregroundColor || '#ffffff';
+  }
+
+  seekBy (time) {
+    const currentTime = this.audio.currentTime + time;
+
+    if (!isNaN(currentTime)) {
+      this.setState({ currentTime }, () => {
+        this.audio.currentTime = currentTime;
+      });
+    }
+  }
+
+  handleAudioKeyDown = e => {
+    // On the audio element or the seek bar, we can safely use the space bar
+    // for playback control because there are no buttons to press
+
+    if (e.key === ' ') {
+      e.preventDefault();
+      e.stopPropagation();
+      this.togglePlay();
+    }
+  };
+
+  handleKeyDown = e => {
+    switch(e.key) {
+    case 'k':
+      e.preventDefault();
+      e.stopPropagation();
+      this.togglePlay();
+      break;
+    case 'm':
+      e.preventDefault();
+      e.stopPropagation();
+      this.toggleMute();
+      break;
+    case 'j':
+      e.preventDefault();
+      e.stopPropagation();
+      this.seekBy(-10);
+      break;
+    case 'l':
+      e.preventDefault();
+      e.stopPropagation();
+      this.seekBy(10);
+      break;
+    }
+  };
+
+  render () {
+    const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props;
+    const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
+    const progress = Math.min((currentTime / duration) * 100, 100);
+
+    let warning;
+    if (sensitive) {
+      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
+    } else {
+      warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
+    }
+
+    return (
+      <div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
+
+        <Blurhash
+          hash={blurhash}
+          className={classNames('media-gallery__preview', {
+            'media-gallery__preview--hidden': revealed,
+          })}
+          dummy={!useBlurhash}
+        />
+
+        {(revealed || editable) && <audio
+          src={src}
+          ref={this.setAudioRef}
+          preload={autoPlay ? 'auto' : 'none'}
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onProgress={this.handleProgress}
+          onLoadedData={this.handleLoadedData}
+          crossOrigin='anonymous'
+        />}
+
+        <canvas
+          role='button'
+          tabIndex='0'
+          className='audio-player__canvas'
+          width={this.state.width}
+          height={this.state.height}
+          style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
+          ref={this.setCanvasRef}
+          onClick={this.togglePlay}
+          onKeyDown={this.handleAudioKeyDown}
+          title={alt}
+          aria-label={alt}
+          lang={lang}
+        />
+
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
+          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
+            <span className='spoiler-button__overlay__label'>{warning}</span>
+          </button>
+        </div>
+
+        {(revealed || editable) && <img
+          src={this.props.poster}
+          alt=''
+          width={(this._getRadius() - TICK_SIZE) * 2}
+          height={(this._getRadius() - TICK_SIZE) * 2}
+          style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
+        />}
+
+        <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}%`, backgroundColor: this._getAccentColor() }} />
+
+          <span
+            className={classNames('video-player__seek__handle', { active: dragging })}
+            tabIndex='0'
+            style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
+            onKeyDown={this.handleAudioKeyDown}
+          />
+        </div>
+
+        <div className='video-player__controls active'>
+          <div className='video-player__buttons-bar'>
+            <div className='video-player__buttons left'>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+
+              <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
+                <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
+
+                <span
+                  className='video-player__volume__handle'
+                  tabIndex='0'
+                  style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
+                />
+              </div>
+
+              <span className='video-player__time'>
+                <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
+                <span className='video-player__time-sep'>/</span>
+                <span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
+              </span>
+            </div>
+
+            <div className='video-player__buttons right'>
+              {!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
+              <a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
+                <Icon id={'download'} fixedWidth />
+              </a>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Audio);
diff --git a/app/javascript/flavours/glitch/features/audio/visualizer.js b/app/javascript/flavours/glitch/features/audio/visualizer.js
new file mode 100644
index 000000000..77d5b5a65
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/audio/visualizer.js
@@ -0,0 +1,136 @@
+/*
+Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+const hex2rgba = (hex, alpha = 1) => {
+  const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
+  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+};
+
+export default class Visualizer {
+
+  constructor (tickSize) {
+    this.tickSize = tickSize;
+  }
+
+  setCanvas(canvas) {
+    this.canvas = canvas;
+    if (canvas) {
+      this.context = canvas.getContext('2d');
+    }
+  }
+
+  setAudioContext(context, source) {
+    const analyser = context.createAnalyser();
+
+    analyser.smoothingTimeConstant = 0.6;
+    analyser.fftSize = 2048;
+
+    source.connect(analyser);
+
+    this.analyser = analyser;
+  }
+
+  getTickPoints (count) {
+    const coords = [];
+
+    for(let i = 0; i < count; i++) {
+      const rad = Math.PI * 2 * i / count;
+      coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
+    }
+
+    return coords;
+  }
+
+  drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
+    const dx1 = Math.ceil(cx + x1);
+    const dy1 = Math.ceil(cy + y1);
+    const dx2 = Math.ceil(cx + x2);
+    const dy2 = Math.ceil(cy + y2);
+
+    const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
+
+    const lastColor = hex2rgba(mainColor, 0);
+
+    gradient.addColorStop(0, mainColor);
+    gradient.addColorStop(0.6, mainColor);
+    gradient.addColorStop(1, lastColor);
+
+    this.context.beginPath();
+    this.context.strokeStyle = gradient;
+    this.context.lineWidth = 2;
+    this.context.moveTo(dx1, dy1);
+    this.context.lineTo(dx2, dy2);
+    this.context.stroke();
+  }
+
+  getTicks (count, size, radius, scaleCoefficient) {
+    const ticks = this.getTickPoints(count);
+    const lesser = 200;
+    const m = [];
+    const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
+    const frequencyData = new Uint8Array(bufferLength);
+    const allScales = [];
+
+    if (this.analyser) {
+      this.analyser.getByteFrequencyData(frequencyData);
+    }
+
+    ticks.forEach((tick, i) => {
+      const coef = 1 - i / (ticks.length * 2.5);
+
+      let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
+
+      if (delta < 0) {
+        delta = 0;
+      }
+
+      const k = radius / (radius - (size + delta));
+
+      const x1 = tick.x * (radius - size);
+      const y1 = tick.y * (radius - size);
+      const x2 = x1 * k;
+      const y2 = y1 * k;
+
+      m.push({ x1, y1, x2, y2 });
+
+      if (i < 20) {
+        let scale = delta / (200 * scaleCoefficient);
+        scale = scale < 1 ? 1 : scale;
+        allScales.push(scale);
+      }
+    });
+
+    const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
+
+    return m.map(({ x1, y1, x2, y2 }) => ({
+      x1: x1,
+      y1: y1,
+      x2: x2 * scale,
+      y2: y2 * scale,
+    }));
+  }
+
+  clear (width, height) {
+    this.context.clearRect(0, 0, width, height);
+  }
+
+  draw (cx, cy, color, radius, coefficient) {
+    this.context.save();
+
+    const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
+
+    ticks.forEach(tick => {
+      this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
+    });
+
+    this.context.restore();
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/blocks/index.jsx b/app/javascript/flavours/glitch/features/blocks/index.jsx
new file mode 100644
index 000000000..461dac2ec
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/blocks/index.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import ScrollableList from '../../components/scrollable_list';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import { fetchBlocks, expandBlocks } from 'flavours/glitch/actions/blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'blocks', 'items']),
+  hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
+  isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
+});
+
+class Blocks extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchBlocks());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandBlocks());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
+
+    return (
+      <Column name='blocks' bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollableList
+          scrollKey='blocks'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} defaultAction='block' />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Blocks));
diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx
new file mode 100644
index 000000000..90d8fd0ef
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx
@@ -0,0 +1,108 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import StatusList from 'flavours/glitch/components/status_list';
+import Column from 'flavours/glitch/features/ui/components/column';
+
+const messages = defineMessages({
+  heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
+  isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
+  hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
+});
+
+class Bookmarks extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchBookmarkedStatuses());
+  }
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('BOOKMARKS', {}));
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandBookmarkedStatuses());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+    const pinned = !!columnId;
+
+    const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='bookmarks'>
+        <ColumnHeader
+          icon='bookmark'
+          title={intl.formatMessage(messages.heading)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+        />
+
+        <StatusList
+          trackScroll={!pinned}
+          statusIds={statusIds}
+          scrollKey={`bookmarked_statuses-${columnId}`}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        />
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.heading)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Bookmarks));
diff --git a/app/javascript/flavours/glitch/features/closed_registrations_modal/index.jsx b/app/javascript/flavours/glitch/features/closed_registrations_modal/index.jsx
new file mode 100644
index 000000000..1f17ea9cf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/closed_registrations_modal/index.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { domain } from 'flavours/glitch/initial_state';
+import { fetchServer } from 'flavours/glitch/actions/server';
+
+const mapStateToProps = state => ({
+  message: state.getIn(['server', 'server', 'registrations', 'message']),
+});
+
+class ClosedRegistrationsModal extends ImmutablePureComponent {
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchServer());
+  }
+
+  render () {
+    let closedRegistrationsMessage;
+
+    if (this.props.message) {
+      closedRegistrationsMessage = (
+        <p
+          className='prose'
+          dangerouslySetInnerHTML={{ __html: this.props.message }}
+        />
+      );
+    } else {
+      closedRegistrationsMessage = (
+        <p className='prose'>
+          <FormattedMessage
+            id='closed_registrations_modal.description'
+            defaultMessage='Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.'
+            values={{ domain: <strong>{domain}</strong> }}
+          />
+        </p>
+      );
+    }
+
+    return (
+      <div className='modal-root__modal interaction-modal'>
+        <div className='interaction-modal__lead'>
+          <h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3>
+          <p>
+            <FormattedMessage
+              id='closed_registrations_modal.preamble'
+              defaultMessage='Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!'
+            />
+          </p>
+        </div>
+
+        <div className='interaction-modal__choices'>
+          <div className='interaction-modal__choices__choice'>
+            <h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
+            {closedRegistrationsMessage}
+          </div>
+
+          <div className='interaction-modal__choices__choice'>
+            <h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
+            <p className='prose'>
+              <FormattedMessage
+                id='closed_registrations.other_server_instructions'
+                defaultMessage='Since Mastodon is decentralized, you can create an account on another server and still interact with this one.'
+              />
+            </p>
+            <a href='https://joinmastodon.org/servers' className='button button--block'><FormattedMessage id='closed_registrations_modal.find_another_server' defaultMessage='Find another server' /></a>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(ClosedRegistrationsModal);
diff --git a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..0ea874e95
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingText from 'flavours/glitch/components/setting_text';
+import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
+
+const messages = defineMessages({
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+  settings: { id: 'home.settings', defaultMessage: 'Column settings' },
+});
+
+class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+  };
+
+  render () {
+    const { settings, onChange, intl } = this.props;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
+        </div>
+
+        <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
+
+        <div className='column-settings__row'>
+          <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ColumnSettings);
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..eac1c4bba
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeColumnParams } from 'flavours/glitch/actions/columns';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+
+  return {
+    settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'community']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { columnId }) => {
+  return {
+    onChange (key, checked) {
+      if (columnId) {
+        dispatch(changeColumnParams(columnId, key, checked));
+      } else {
+        dispatch(changeSetting(['community', ...key], checked));
+      }
+    },
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.jsx b/app/javascript/flavours/glitch/features/community_timeline/index.jsx
new file mode 100644
index 000000000..8f3e10fe9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/index.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
+import { Helmet } from 'react-helmet';
+import { domain } from 'flavours/glitch/initial_state';
+import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local timeline' },
+});
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+  const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']);
+  const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'community', 'regex', 'body']);
+  const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
+
+  return {
+    hasUnread: !!timelineState && timelineState.get('unread') > 0,
+    onlyMedia,
+    regex,
+  };
+};
+
+class CommunityTimeline extends React.PureComponent {
+
+  static defaultProps = {
+    onlyMedia: false,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    onlyMedia: PropTypes.bool,
+    regex: PropTypes.string,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch, onlyMedia } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('COMMUNITY', { other: { onlyMedia } }));
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  componentDidMount () {
+    const { dispatch, onlyMedia } = this.props;
+    const { signedIn } = this.context.identity;
+
+    dispatch(expandCommunityTimeline({ onlyMedia }));
+
+    if (signedIn) {
+      this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { signedIn } = this.context.identity;
+
+    if (prevProps.onlyMedia !== this.props.onlyMedia) {
+      const { dispatch, onlyMedia } = this.props;
+
+      if (this.disconnect) {
+        this.disconnect();
+      }
+
+      dispatch(expandCommunityTimeline({ onlyMedia }));
+
+      if (signedIn) {
+        this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMore = maxId => {
+    const { dispatch, onlyMedia } = this.props;
+
+    dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
+  };
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='local' bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='users'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer columnId={columnId} />
+        </ColumnHeader>
+
+        <DismissableBanner id='community_timeline'>
+          <FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} />
+        </DismissableBanner>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`community_timeline-${columnId}`}
+          timelineId={`community${onlyMedia ? ':media' : ''}`}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
+          bindToDocument={!multiColumn}
+          regex={this.props.regex}
+        />
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(CommunityTimeline));
diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx
new file mode 100644
index 000000000..af1f02efc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { preferencesLink, profileLink } from 'flavours/glitch/utils/backend_links';
+
+const messages = defineMessages({
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
+  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+  bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+});
+
+class ActionBar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleLogout = () => {
+    this.props.onLogout();
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    let menu = [];
+
+    menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
+    menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
+    menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
+    menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
+    menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
+    menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+    menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+    menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+    menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
+    menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
+
+    return (
+      <div className='compose__action-bar'>
+        <div className='compose__action-bar-dropdown'>
+          <DropdownMenuContainer items={menu} icon='ellipsis-v' size={18} direction='right' />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx
new file mode 100644
index 000000000..fb9bb5035
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='account small' title={account.get('acct')}>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div>
+        <DisplayName account={account} inline />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.jsx b/app/javascript/flavours/glitch/features/compose/components/character_counter.jsx
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+export default class CharacterCounter extends React.PureComponent {
+
+  static propTypes = {
+    text: PropTypes.string.isRequired,
+    max: PropTypes.number.isRequired,
+  };
+
+  checkRemainingText (diff) {
+    if (diff < 0) {
+      return <span className='character-counter character-counter--over'>{diff}</span>;
+    }
+
+    return <span className='character-counter'>{diff}</span>;
+  }
+
+  render () {
+    const diff = this.props.max - length(this.props.text);
+    return this.checkRemainingText(diff);
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx
new file mode 100644
index 000000000..973a17a1a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx
@@ -0,0 +1,392 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import AutosuggestInput from '../../../components/autosuggest_input';
+import { defineMessages, injectIntl } from 'react-intl';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
+import PollFormContainer from '../containers/poll_form_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'flavours/glitch/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { countableText } from '../util/counter';
+import OptionsContainer from '../containers/options_container';
+import Publisher from './publisher';
+import TextareaIcons from './textarea_icons';
+import { maxChars } from 'flavours/glitch/initial_state';
+import CharacterCounter from './character_counter';
+import { length } from 'stringz';
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  missingDescriptionMessage: {
+    id: 'confirmations.missing_media_description.message',
+    defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
+  },
+  missingDescriptionConfirm: {
+    id: 'confirmations.missing_media_description.confirm',
+    defaultMessage: 'Send anyway',
+  },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+});
+
+class ComposeForm extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    text: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    spoiler: PropTypes.bool,
+    privacy: PropTypes.string,
+    spoilerText: PropTypes.string,
+    focusDate: PropTypes.instanceOf(Date),
+    caretPosition: PropTypes.number,
+    preselectDate: PropTypes.instanceOf(Date),
+    isSubmitting: PropTypes.bool,
+    isChangingUpload: PropTypes.bool,
+    isEditing: PropTypes.bool,
+    isUploading: PropTypes.bool,
+    onChange: PropTypes.func,
+    onSubmit: PropTypes.func,
+    onClearSuggestions: PropTypes.func,
+    onFetchSuggestions: PropTypes.func,
+    onSuggestionSelected: PropTypes.func,
+    onChangeSpoilerText: PropTypes.func,
+    onPaste: PropTypes.func,
+    onPickEmoji: PropTypes.func,
+    showSearch: PropTypes.bool,
+    anyMedia: PropTypes.bool,
+    isInReply: PropTypes.bool,
+    singleColumn: PropTypes.bool,
+    lang: PropTypes.string,
+
+    advancedOptions: ImmutablePropTypes.map,
+    layout: PropTypes.string,
+    media: ImmutablePropTypes.list,
+    sideArm: PropTypes.string,
+    sensitive: PropTypes.bool,
+    spoilersAlwaysOn: PropTypes.bool,
+    mediaDescriptionConfirmation: PropTypes.bool,
+    preselectOnReply: PropTypes.bool,
+    onChangeSpoilerness: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onPaste: PropTypes.func,
+    onMediaDescriptionConfirm: PropTypes.func,
+  };
+
+  static defaultProps = {
+    showSearch: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  };
+
+  getFulltextForCharacterCounting = () => {
+    return [
+      this.props.spoiler? this.props.spoilerText: '',
+      countableText(this.props.text),
+      this.props.advancedOptions && this.props.advancedOptions.get('do_not_federate') ? ' 👁️' : '',
+    ].join('');
+  };
+
+  canSubmit = () => {
+    const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
+    const fulltext = this.getFulltextForCharacterCounting();
+
+    return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (!fulltext.trim().length && !anyMedia));
+  };
+
+  handleSubmit = (overriddenVisibility = null) => {
+    const {
+      onSubmit,
+      media,
+      mediaDescriptionConfirmation,
+      onMediaDescriptionConfirm,
+      onChangeVisibility,
+    } = this.props;
+
+    if (this.props.text !== this.textarea.value) {
+      // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
+      // Update the state to match the current text
+      this.props.onChange(this.textarea.value);
+    }
+
+    if (!this.canSubmit()) {
+      return;
+    }
+
+    // Submit unless there are media with missing descriptions
+    if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
+      const firstWithoutDescription = media.find(item => !item.get('description'));
+      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'), overriddenVisibility);
+    } else if (onSubmit) {
+      if (onChangeVisibility && overriddenVisibility) {
+        onChangeVisibility(overriddenVisibility);
+      }
+      onSubmit(this.context.router ? this.context.router.history : null);
+    }
+  };
+
+  //  Changes the text value of the spoiler.
+  handleChangeSpoiler = ({ target: { value } }) => {
+    const { onChangeSpoilerText } = this.props;
+    if (onChangeSpoilerText) {
+      onChangeSpoilerText(value);
+    }
+  };
+
+  setRef = c => {
+    this.composeForm = c;
+  };
+
+  //  Inserts an emoji at the caret.
+  handleEmojiPick = (data) => {
+    const { textarea: { selectionStart } } = this;
+    const { onPickEmoji } = this.props;
+    if (onPickEmoji) {
+      onPickEmoji(selectionStart, data);
+    }
+  };
+
+  //  Handles the secondary submit button.
+  handleSecondarySubmit = () => {
+    const {
+      sideArm,
+    } = this.props;
+    this.handleSubmit(sideArm === 'none' ? null : sideArm);
+  };
+
+  //  Selects a suggestion from the autofill.
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
+  };
+
+  onSpoilerSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
+  };
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+
+    if (e.keyCode == 13 && e.altKey) {
+      this.handleSecondarySubmit();
+    }
+  };
+
+  //  Sets a reference to the textarea.
+  setAutosuggestTextarea = (textareaComponent) => {
+    if (textareaComponent) {
+      this.textarea = textareaComponent.textarea;
+    }
+  };
+
+  //  Sets a reference to the CW field.
+  handleRefSpoilerText = (spoilerComponent) => {
+    if (spoilerComponent) {
+      this.spoilerText = spoilerComponent.input;
+    }
+  };
+
+  handleFocus = () => {
+    if (this.composeForm && !this.props.singleColumn) {
+      const { left, right } = this.composeForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.composeForm.scrollIntoView();
+      }
+    }
+  };
+
+  componentDidMount () {
+    this._updateFocusAndSelection({ });
+  }
+
+  componentDidUpdate (prevProps) {
+    this._updateFocusAndSelection(prevProps);
+  }
+
+  //  This statement does several things:
+  //  - If we're beginning a reply, and,
+  //      - Replying to zero or one users, places the cursor at the end
+  //        of the textbox.
+  //      - Replying to more than one user, selects any usernames past
+  //        the first; this provides a convenient shortcut to drop
+  //        everyone else from the conversation.
+  _updateFocusAndSelection = (prevProps) => {
+    const {
+      textarea,
+      spoilerText,
+    } = this;
+    const {
+      focusDate,
+      caretPosition,
+      isSubmitting,
+      preselectDate,
+      text,
+      preselectOnReply,
+      singleColumn,
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.focusDate) {
+      switch (true) {
+      case preselectDate !== prevProps.preselectDate && this.props.isInReply && preselectOnReply:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPosition) && caretPosition !== null:
+        selectionStart = selectionEnd = caretPosition;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      if (textarea) {
+        // Because of the wicg-inert polyfill, the activeElement may not be
+        // immediately selectable, we have to wait for observers to run, as
+        // described in https://github.com/WICG/inert#performance-and-gotchas
+        Promise.resolve().then(() => {
+          textarea.setSelectionRange(selectionStart, selectionEnd);
+          textarea.focus();
+          if (!singleColumn) textarea.scrollIntoView();
+        }).catch(console.error);
+      }
+
+    //  Refocuses the textarea after submitting.
+    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    } else if (this.props.spoiler !== prevProps.spoiler) {
+      if (this.props.spoiler) {
+        if (spoilerText) {
+          spoilerText.focus();
+        }
+      } else {
+        if (textarea) {
+          textarea.focus();
+        }
+      }
+    }
+  };
+
+
+  render () {
+    const {
+      handleEmojiPick,
+      handleSecondarySubmit,
+      handleSelect,
+      handleSubmit,
+      handleRefTextarea,
+    } = this;
+    const {
+      advancedOptions,
+      intl,
+      isSubmitting,
+      layout,
+      onChangeSpoilerness,
+      onChangeVisibility,
+      onClearSuggestions,
+      onFetchSuggestions,
+      onPaste,
+      privacy,
+      sensitive,
+      showSearch,
+      sideArm,
+      spoiler,
+      spoilerText,
+      suggestions,
+      spoilersAlwaysOn,
+      isEditing,
+    } = this.props;
+
+    const countText = this.getFulltextForCharacterCounting();
+
+    return (
+      <div className='compose-form'>
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className={`spoiler-input ${spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.spoiler_placeholder)}
+            value={spoilerText}
+            onChange={this.handleChangeSpoiler}
+            onKeyDown={this.handleKeyDown}
+            disabled={!spoiler}
+            ref={this.handleRefSpoilerText}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={onFetchSuggestions}
+            onSuggestionsClearRequested={onClearSuggestions}
+            onSuggestionSelected={this.onSpoilerSuggestionSelected}
+            searchTokens={[':']}
+            id='glitch.composer.spoiler.input'
+            className='spoiler-input__input'
+            lang={this.props.lang}
+            autoFocus={false}
+            spellCheck
+          />
+        </div>
+
+        <AutosuggestTextarea
+          ref={this.setAutosuggestTextarea}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          disabled={isSubmitting}
+          value={this.props.text}
+          onChange={this.handleChange}
+          onKeyDown={this.handleKeyDown}
+          suggestions={this.props.suggestions}
+          onFocus={this.handleFocus}
+          onSuggestionsFetchRequested={onFetchSuggestions}
+          onSuggestionsClearRequested={onClearSuggestions}
+          onSuggestionSelected={this.onSuggestionSelected}
+          onPaste={onPaste}
+          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+          lang={this.props.lang}
+        >
+          <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
+          <TextareaIcons advancedOptions={advancedOptions} />
+          <div className='compose-form__modifiers'>
+            <UploadFormContainer />
+            <PollFormContainer />
+          </div>
+        </AutosuggestTextarea>
+
+        <div className='compose-form__buttons-wrapper'>
+          <OptionsContainer
+            advancedOptions={advancedOptions}
+            disabled={isSubmitting}
+            onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
+            onUpload={onPaste}
+            isEditing={isEditing}
+            sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
+            spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
+          />
+          <div className='character-counter__wrapper'>
+            <CharacterCounter text={countText} max={maxChars} />
+          </div>
+        </div>
+
+        <Publisher
+          countText={countText}
+          disabled={!this.canSubmit()}
+          isEditing={isEditing}
+          onSecondarySubmit={handleSecondarySubmit}
+          onSubmit={handleSubmit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ComposeForm);
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/dropdown.jsx
new file mode 100644
index 000000000..fe4ab36f5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.jsx
@@ -0,0 +1,243 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Overlay from 'react-overlays/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import DropdownMenu from './dropdown_menu';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  static propTypes = {
+    isUserTouching: PropTypes.func,
+    disabled: PropTypes.bool,
+    icon: PropTypes.string,
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.string,
+      name: PropTypes.string.isRequired,
+      text: PropTypes.string,
+    })).isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    title: PropTypes.string,
+    value: PropTypes.string,
+    onChange: PropTypes.func,
+    container: PropTypes.func,
+    renderItemContents: PropTypes.func,
+    closeOnChange: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    closeOnChange: true,
+  };
+
+  state = {
+    open: false,
+    openedViaKeyboard: undefined,
+    placement: 'bottom',
+  };
+
+  //  Toggles opening and closing the dropdown.
+  handleToggle = ({ type }) => {
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    if (this.props.isUserTouching && this.props.isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        const modal = this.handleMakeModal();
+        if (modal && onModalOpen) {
+          onModalOpen(modal);
+        }
+      }
+    } else {
+      if (this.state.open && this.activeElement) {
+        this.activeElement.focus({ preventScroll: true });
+      }
+      this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' });
+    }
+  };
+
+  handleKeyDown = (e) => {
+    switch (e.key) {
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  };
+
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  };
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleMouseDown();
+      break;
+    }
+  };
+
+  handleKeyPress = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleToggle(e);
+      e.stopPropagation();
+      e.preventDefault();
+      break;
+    }
+  };
+
+  handleClose = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
+    }
+    this.setState({ open: false });
+  };
+
+  handleItemClick = (e) => {
+    const {
+      items,
+      onChange,
+      onModalClose,
+      closeOnChange,
+    } = this.props;
+
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+
+    const { name } = items[i];
+
+    e.preventDefault();  //  Prevents focus from changing
+    if (closeOnChange) onModalClose();
+    onChange(name);
+  };
+
+  //  Creates an action modal object.
+  handleMakeModal = () => {
+    const {
+      items,
+      onChange,
+      onModalOpen,
+      onModalClose,
+      value,
+    } = this.props;
+
+    //  Required props.
+    if (!(onChange && onModalOpen && onModalClose && items)) {
+      return null;
+    }
+
+    //  The object.
+    return {
+      renderItemContents: this.props.renderItemContents,
+      onClick: this.handleItemClick,
+      actions: items.map(
+        ({
+          name,
+          ...rest
+        }) => ({
+          ...rest,
+          active: value && name === value,
+          name,
+        }),
+      ),
+    };
+  };
+
+  setTargetRef = c => {
+    this.target = c;
+  };
+
+  findTarget = () => {
+    return this.target;
+  };
+
+  handleOverlayEnter = (state) => {
+    this.setState({ placement: state.placement });
+  };
+
+  //  Rendering.
+  render () {
+    const {
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+      container,
+      renderItemContents,
+      closeOnChange,
+    } = this.props;
+    const { open, placement } = this.state;
+
+    const active = value && items.findIndex(({ name }) => name === value) === (placement === 'bottom' ? 0 : (items.length - 1));
+
+    return (
+      <div
+        className={classNames('privacy-dropdown', placement, { active: open })}
+        onKeyDown={this.handleKeyDown}
+        ref={this.setTargetRef}
+      >
+        <div className={classNames('privacy-dropdown__value', { active })}>
+          <IconButton
+            active={open}
+            className='privacy-dropdown__value-icon'
+            disabled={disabled}
+            icon={icon}
+            inverted
+            onClick={this.handleToggle}
+            onMouseDown={this.handleMouseDown}
+            onKeyDown={this.handleButtonKeyDown}
+            onKeyPress={this.handleKeyPress}
+            size={18}
+            style={{
+              height: null,
+              lineHeight: '27px',
+            }}
+            title={title}
+          />
+        </div>
+
+        <Overlay
+          containerPadding={20}
+          placement={placement}
+          show={open}
+          flip
+          target={this.findTarget}
+          container={container}
+          popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}
+        >
+          {({ props, placement }) => (
+            <div {...props}>
+              <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
+                <DropdownMenu
+                  items={items}
+                  renderItemContents={renderItemContents}
+                  onChange={onChange}
+                  onClose={this.handleClose}
+                  value={value}
+                  openedViaKeyboard={this.state.openedViaKeyboard}
+                  closeOnChange={closeOnChange}
+                />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx
new file mode 100644
index 000000000..1ea0df536
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx
@@ -0,0 +1,199 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/utils/dom_helpers';
+import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  static propTypes = {
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      text: PropTypes.node,
+    })),
+    onChange: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    value: PropTypes.string,
+    renderItemContents: PropTypes.func,
+    openedViaKeyboard: PropTypes.bool,
+    closeOnChange: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    style: {},
+    closeOnChange: true,
+  };
+
+  state = {
+    value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
+  };
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  handleDocumentClick = (e) => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  };
+
+  //  Stores our node in `this.node`.
+  setRef = (node) => {
+    this.node = node;
+  };
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, withPassive);
+    if (this.focusedItem) {
+      this.focusedItem.focus({ preventScroll: true });
+    } else {
+      this.node.firstChild.focus({ preventScroll: true });
+    }
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
+  }
+
+  handleClick = (e) => {
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+
+    const {
+      onChange,
+      onClose,
+      closeOnChange,
+      items,
+    } = this.props;
+
+    const { name } = this.props.items[i];
+    e.preventDefault();  //  Prevents change in focus on click
+    if (closeOnChange) {
+      onClose();
+    }
+    onChange(name);
+  };
+
+  // Handle changes differently whether the dropdown is a list of options or actions
+  handleChange = (name) => {
+    if (this.props.value) {
+      this.props.onChange(name);
+    } else {
+      this.setState({ value: name });
+    }
+  };
+
+  handleKeyDown = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
+    const { items } = this.props;
+    let element = null;
+
+    switch(e.key) {
+    case 'Escape':
+      this.props.onClose();
+      break;
+    case 'Enter':
+    case ' ':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1] || this.node.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1] || this.node.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.node.childNodes[index - 1] || this.node.lastChild;
+      } else {
+        element = this.node.childNodes[index + 1] || this.node.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      this.handleChange(this.props.items[Number(element.getAttribute('data-index'))].name);
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  };
+
+  setFocusRef = c => {
+    this.focusedItem = c;
+  };
+
+  renderItem = (item, i) => {
+    const { name, icon, meta, text } = item;
+
+    const active = (name === (this.props.value || this.state.value));
+
+    const computedClass = classNames('privacy-dropdown__option', { active });
+
+    let contents = this.props.renderItemContents && this.props.renderItemContents(item, i);
+
+    if (!contents) {
+      contents = (
+        <React.Fragment>
+          {icon && <Icon className='icon' fixedWidth id={icon} />}
+
+          <div className='privacy-dropdown__option__content'>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        </React.Fragment>
+      );
+    }
+
+    return (
+      <div
+        className={computedClass}
+        onClick={this.handleClick}
+        onKeyDown={this.handleKeyDown}
+        role='option'
+        tabIndex='0'
+        key={name}
+        data-index={i}
+        ref={active ? this.setFocusRef : null}
+      >
+        {contents}
+      </div>
+    );
+  };
+
+  //  Rendering.
+  render () {
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+    } = this.props;
+
+    //  The result.
+    return (
+      <div style={{ ...style }} role='listbox' ref={this.setRef}>
+        {!!items && items.map((item, i) => this.renderItem(item, i))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx
new file mode 100644
index 000000000..1b8991f00
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx
@@ -0,0 +1,415 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import Overlay from 'react-overlays/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
+import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
+import { assetHost } from 'flavours/glitch/utils/config';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  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' },
+});
+
+let EmojiPicker, Emoji; // load asynchronously
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
+
+const notFoundFn = () => (
+  <div className='emoji-mart-no-results'>
+    <Emoji
+      emoji='sleuth_or_spy'
+      set='twitter'
+      size={32}
+      sheetSize={32}
+      backgroundImageFn={backgroundImageFn}
+    />
+
+    <div className='emoji-mart-no-results-label'>
+      <FormattedMessage id='emoji_button.not_found' defaultMessage='No matching emojis found' />
+    </div>
+  </div>
+);
+
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  };
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  };
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  };
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+class EmojiPickerMenuImpl 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,
+    intl: PropTypes.object.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+    loading: true,
+    frequentlyUsedEmojis: [],
+  };
+
+  state = {
+    modifierOpen: false,
+    readyToFocus: 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);
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      this.setState({ readyToFocus: true });
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
+  }
+
+  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),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  };
+
+  handleClick = (emoji, event) => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+    if (!(event.ctrlKey || event.metaKey)) {
+      this.props.onClose();
+    }
+    this.props.onPick(emoji);
+  };
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  };
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  };
+
+  handleModifierChange = modifier => {
+    this.props.onSkinTone(modifier);
+  };
+
+  render () {
+    const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
+    const title = intl.formatMessage(messages.emoji);
+
+    const { modifierOpen } = this.state;
+
+    const categoriesSort = [
+      'recent',
+      'people',
+      'nature',
+      'foods',
+      'activity',
+      'places',
+      'objects',
+      'symbols',
+      'flags',
+    ];
+
+    categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <EmojiPicker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis)}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
+          showSkinTones={false}
+          backgroundImageFn={backgroundImageFn}
+          notFound={notFoundFn}
+          autoFocus={this.state.readyToFocus}
+          emojiTooltip
+          native={useSystemEmojiFont}
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={skinTone}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
+
+const EmojiPickerMenu = injectIntl(EmojiPickerMenuImpl);
+
+class EmojiPickerDropdown extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    button: PropTypes.node,
+  };
+
+  state = {
+    active: false,
+    loading: false,
+  };
+
+  setRef = (c) => {
+    this.dropdown = c;
+  };
+
+  onShowDropdown = () => {
+    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, active: 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(e);
+      }
+    }
+  };
+
+  handleKeyDown = e => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  };
+
+  setTargetRef = c => {
+    this.target = c;
+  };
+
+  findTarget = () => {
+    return this.target;
+  };
+
+  render () {
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { active, loading } = this.state;
+
+    return (
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
+          {button || <img
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
+            alt='🙂'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />}
+        </div>
+
+        <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
+          {({ props, placement })=> (
+            <div {...props} style={{ ...props.style, width: 299 }}>
+              <div className={`dropdown-animation ${placement}`}>
+                <EmojiPickerMenu
+                  custom_emojis={this.props.custom_emojis}
+                  loading={loading}
+                  onClose={this.onHideDropdown}
+                  onPick={onPickEmoji}
+                  onSkinTone={onSkinTone}
+                  skinTone={skinTone}
+                  frequentlyUsedEmojis={frequentlyUsedEmojis}
+                />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(EmojiPickerDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.jsx b/app/javascript/flavours/glitch/features/compose/components/header.jsx
new file mode 100644
index 000000000..764fcec5e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/header.jsx
@@ -0,0 +1,137 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { conditionalRender } from 'flavours/glitch/utils/react_helpers';
+import { signOutLink } from 'flavours/glitch/utils/backend_links';
+
+//  Messages.
+const messages = defineMessages({
+  community: {
+    defaultMessage: 'Local timeline',
+    id: 'navigation_bar.community_timeline',
+  },
+  home_timeline: {
+    defaultMessage: 'Home',
+    id: 'tabs_bar.home',
+  },
+  logout: {
+    defaultMessage: 'Logout',
+    id: 'navigation_bar.logout',
+  },
+  notifications: {
+    defaultMessage: 'Notifications',
+    id: 'tabs_bar.notifications',
+  },
+  public: {
+    defaultMessage: 'Federated timeline',
+    id: 'navigation_bar.public_timeline',
+  },
+  settings: {
+    defaultMessage: 'App settings',
+    id: 'navigation_bar.app_settings',
+  },
+  start: {
+    defaultMessage: 'Getting started',
+    id: 'getting_started.heading',
+  },
+});
+
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    columns: ImmutablePropTypes.list,
+    unreadNotifications: PropTypes.number,
+    showNotificationsBadge: PropTypes.bool,
+    intl: PropTypes.object,
+    onSettingsClick: PropTypes.func,
+    onLogout: PropTypes.func.isRequired,
+  };
+
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  };
+
+  render () {
+    const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
+
+    //  Only renders the component if the column isn't being shown.
+    const renderForColumn = conditionalRender.bind(null,
+      columnId => !columns || !columns.some(
+        column => column.get('id') === columnId,
+      ),
+    );
+
+    //  The result.
+    return (
+      <nav className='drawer--header'>
+        <Link
+          aria-label={intl.formatMessage(messages.start)}
+          title={intl.formatMessage(messages.start)}
+          to='/getting-started'
+        ><Icon id='asterisk' /></Link>
+        {renderForColumn('HOME', (
+          <Link
+            aria-label={intl.formatMessage(messages.home_timeline)}
+            title={intl.formatMessage(messages.home_timeline)}
+            to='/home'
+          ><Icon id='home' /></Link>
+        ))}
+        {renderForColumn('NOTIFICATIONS', (
+          <Link
+            aria-label={intl.formatMessage(messages.notifications)}
+            title={intl.formatMessage(messages.notifications)}
+            to='/notifications'
+          >
+            <span className='icon-badge-wrapper'>
+              <Icon id='bell' />
+              { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
+            </span>
+          </Link>
+        ))}
+        {renderForColumn('COMMUNITY', (
+          <Link
+            aria-label={intl.formatMessage(messages.community)}
+            title={intl.formatMessage(messages.community)}
+            to='/public/local'
+          ><Icon id='users' /></Link>
+        ))}
+        {renderForColumn('PUBLIC', (
+          <Link
+            aria-label={intl.formatMessage(messages.public)}
+            title={intl.formatMessage(messages.public)}
+            to='/public'
+          ><Icon id='globe' /></Link>
+        ))}
+        <a
+          aria-label={intl.formatMessage(messages.settings)}
+          onClick={onSettingsClick}
+          href='/settings/preferences'
+          title={intl.formatMessage(messages.settings)}
+        ><Icon id='cogs' /></a>
+        <a
+          aria-label={intl.formatMessage(messages.logout)}
+          onClick={this.handleLogoutClick}
+          href={signOutLink}
+          title={intl.formatMessage(messages.logout)}
+        ><Icon id='sign-out' /></a>
+      </nav>
+    );
+  }
+
+}
+
+export default injectIntl(Header);
diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx
new file mode 100644
index 000000000..14f285c3d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx
@@ -0,0 +1,328 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import TextIconButton from './text_icon_button';
+import Overlay from 'react-overlays/Overlay';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
+import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
+import fuzzysort from 'fuzzysort';
+
+const messages = defineMessages({
+  changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
+  search: { id: 'compose.language.search', defaultMessage: 'Search languages...' },
+  clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
+});
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+class LanguageDropdownMenu extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
+    onClose: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+    languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+    intl: PropTypes.object,
+  };
+
+  static defaultProps = {
+    languages: preloadedLanguages,
+  };
+
+  state = {
+    searchValue: '',
+  };
+
+  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);
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  setListRef = c => {
+    this.listNode = c;
+  };
+
+  handleSearchChange = ({ target }) => {
+    this.setState({ searchValue: target.value });
+  };
+
+  search () {
+    const { languages, value, frequentlyUsedLanguages } = this.props;
+    const { searchValue } = this.state;
+
+    if (searchValue === '') {
+      return [...languages].sort((a, b) => {
+        // Push current selection to the top of the list
+
+        if (a[0] === value) {
+          return -1;
+        } else if (b[0] === value) {
+          return 1;
+        } else {
+          // Sort according to frequently used languages
+
+          const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
+          const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
+
+          return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
+        }
+      });
+    }
+
+    return fuzzysort.go(searchValue, languages, {
+      keys: ['0', '1', '2'],
+      limit: 5,
+      threshold: -10000,
+    }).map(result => result.obj);
+  }
+
+  frequentlyUsed () {
+    const { languages, value } = this.props;
+    const current = languages.find(lang => lang[0] === value);
+    const results = [];
+
+    if (current) {
+      results.push(current);
+    }
+
+    return results;
+  }
+
+  handleClick = e => {
+    const value = e.currentTarget.getAttribute('data-index');
+
+    e.preventDefault();
+
+    this.props.onClose();
+    this.props.onChange(value);
+  };
+
+  handleKeyDown = e => {
+    const { onClose } = this.props;
+    const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
+
+    let element = null;
+
+    switch(e.key) {
+    case 'Escape':
+      onClose();
+      break;
+    case 'Enter':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      } else {
+        element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.listNode.firstChild;
+      break;
+    case 'End':
+      element = this.listNode.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  };
+
+  handleSearchKeyDown = e => {
+    const { onChange, onClose } = this.props;
+    const { searchValue } = this.state;
+
+    let element = null;
+
+    switch(e.key) {
+    case 'Tab':
+    case 'ArrowDown':
+      element = this.listNode.firstChild;
+
+      if (element) {
+        element.focus();
+        e.preventDefault();
+        e.stopPropagation();
+      }
+
+      break;
+    case 'Enter':
+      element = this.listNode.firstChild;
+
+      if (element) {
+        onChange(element.getAttribute('data-index'));
+        onClose();
+      }
+      break;
+    case 'Escape':
+      if (searchValue !== '') {
+        e.preventDefault();
+        this.handleClear();
+      }
+
+      break;
+    }
+  };
+
+  handleClear = () => {
+    this.setState({ searchValue: '' });
+  };
+
+  renderItem = lang => {
+    const { value } = this.props;
+
+    return (
+      <div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
+        <span className='language-dropdown__dropdown__results__item__native-name' lang={lang[0]}>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
+      </div>
+    );
+  };
+
+  render () {
+    const { intl } = this.props;
+    const { searchValue } = this.state;
+    const isSearching = searchValue !== '';
+    const results = this.search();
+
+    return (
+      <div ref={this.setRef}>
+        <div className='emoji-mart-search'>
+          <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
+          <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
+        </div>
+
+        <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
+          {results.map(this.renderItem)}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+class LanguageDropdown extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+    frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+  };
+
+  state = {
+    open: false,
+    placement: 'bottom',
+  };
+
+  handleToggle = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
+    }
+
+    this.setState({ open: !this.state.open });
+  };
+
+  handleClose = () => {
+    const { value, onClose } = this.props;
+
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
+    }
+
+    this.setState({ open: false });
+    onClose(value);
+  };
+
+  handleChange = value => {
+    const { onChange } = this.props;
+    onChange(value);
+  };
+
+  setTargetRef = c => {
+    this.target = c;
+  };
+
+  findTarget = () => {
+    return this.target;
+  };
+
+  handleOverlayEnter = (state) => {
+    this.setState({ placement: state.placement });
+  };
+
+  render () {
+    const { value, intl, frequentlyUsedLanguages } = this.props;
+    const { open, placement } = this.state;
+
+    return (
+      <div className={classNames('privacy-dropdown', placement, { active: open })}>
+        <div className='privacy-dropdown__value' ref={this.setTargetRef} >
+          <TextIconButton
+            className='privacy-dropdown__value-icon'
+            label={value && value.toUpperCase()}
+            title={intl.formatMessage(messages.changeLanguage)}
+            active={open}
+            onClick={this.handleToggle}
+          />
+        </div>
+
+        <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
+          {({ props, placement }) => (
+            <div {...props}>
+              <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
+                <LanguageDropdownMenu
+                  value={value}
+                  frequentlyUsedLanguages={frequentlyUsedLanguages}
+                  onClose={this.handleClose}
+                  onChange={this.handleChange}
+                  intl={intl}
+                />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(LanguageDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx
new file mode 100644
index 000000000..1a68f1e12
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ActionBar from './action_bar';
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { profileLink } from 'flavours/glitch/utils/backend_links';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onLogout: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='navigation-bar'>
+        <Permalink className='avatar' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
+          <Avatar account={this.props.account} size={48} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink className='acct' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
+            <strong>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          { profileLink !== undefined && (
+            <a
+              className='edit'
+              href={profileLink}
+            ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+          )}
+        </div>
+
+        <div className='navigation-bar__actions'>
+          <ActionBar account={this.props.account} onLogout={this.props.onLogout} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.jsx b/app/javascript/flavours/glitch/features/compose/components/options.jsx
new file mode 100644
index 000000000..19ead2f21
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/options.jsx
@@ -0,0 +1,323 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import spring from 'react-motion/lib/spring';
+import Toggle from 'react-toggle';
+import { connect } from 'react-redux';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from './text_icon_button';
+import DropdownContainer from '../containers/dropdown_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import LanguageDropdown from '../containers/language_dropdown_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Utils.
+import Motion from '../../ui/util/optional_motion';
+import { pollLimits } from 'flavours/glitch/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  advanced_options_icon_title: {
+    defaultMessage: 'Advanced options',
+    id: 'advanced_options.icon_title',
+  },
+  attach: {
+    defaultMessage: 'Attach...',
+    id: 'compose.attach',
+  },
+  content_type: {
+    defaultMessage: 'Content type',
+    id: 'content-type.change',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  html: {
+    defaultMessage: 'HTML',
+    id: 'compose.content-type.html',
+  },
+  local_only_long: {
+    defaultMessage: 'Do not post to other instances',
+    id: 'advanced_options.local-only.long',
+  },
+  local_only_short: {
+    defaultMessage: 'Local-only',
+    id: 'advanced_options.local-only.short',
+  },
+  markdown: {
+    defaultMessage: 'Markdown',
+    id: 'compose.content-type.markdown',
+  },
+  plain: {
+    defaultMessage: 'Plain text',
+    id: 'compose.content-type.plain',
+  },
+  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',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+  add_poll: {
+    defaultMessage: 'Add a poll',
+    id: 'poll_button.add_poll',
+  },
+  remove_poll: {
+    defaultMessage: 'Remove poll',
+    id: 'poll_button.remove_poll',
+  },
+});
+
+const mapStateToProps = (state, { name }) => ({
+  checked: state.getIn(['compose', 'advanced_options', name]),
+});
+
+class ToggleOptionImpl extends ImmutablePureComponent {
+
+  static propTypes = {
+    name: PropTypes.string.isRequired,
+    checked: PropTypes.bool,
+    onChangeAdvancedOption: PropTypes.func.isRequired,
+  };
+
+  handleChange = () => {
+    this.props.onChangeAdvancedOption(this.props.name);
+  };
+
+  render() {
+    const { meta, text, checked } = this.props;
+
+    return (
+      <React.Fragment>
+        <Toggle checked={checked} onChange={this.handleChange} />
+
+        <div className='privacy-dropdown__option__content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+const ToggleOption = connect(mapStateToProps)(ToggleOptionImpl);
+
+class ComposerOptions extends ImmutablePureComponent {
+
+  static propTypes = {
+    acceptContentTypes: PropTypes.string,
+    advancedOptions: ImmutablePropTypes.map,
+    disabled: PropTypes.bool,
+    allowMedia: PropTypes.bool,
+    hasMedia: PropTypes.bool,
+    allowPoll: PropTypes.bool,
+    hasPoll: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChangeAdvancedOption: PropTypes.func,
+    onChangeContentType: PropTypes.func,
+    onTogglePoll: PropTypes.func,
+    onDoodleOpen: PropTypes.func,
+    onToggleSpoiler: PropTypes.func,
+    onUpload: PropTypes.func,
+    contentType: PropTypes.string,
+    resetFileKey: PropTypes.number,
+    spoiler: PropTypes.bool,
+    showContentTypeChoice: PropTypes.bool,
+    isEditing: PropTypes.bool,
+  };
+
+  //  Handles file selection.
+  handleChangeFiles = ({ target: { files } }) => {
+    const { onUpload } = this.props;
+    if (files.length && onUpload) {
+      onUpload(files);
+    }
+  };
+
+  //  Handles attachment clicks.
+  handleClickAttach = (name) => {
+    const { fileElement } = this;
+    const { onDoodleOpen } = this.props;
+
+    //  We switch over the name of the option.
+    switch (name) {
+    case 'upload':
+      if (fileElement) {
+        fileElement.click();
+      }
+      return;
+    case 'doodle':
+      if (onDoodleOpen) {
+        onDoodleOpen();
+      }
+      return;
+    }
+  };
+
+  //  Handles a ref to the file input.
+  handleRefFileElement = (fileElement) => {
+    this.fileElement = fileElement;
+  };
+
+  renderToggleItemContents = (item) => {
+    const { onChangeAdvancedOption } = this.props;
+    const { name, meta, text } = item;
+
+    return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />;
+  };
+
+  //  Rendering.
+  render () {
+    const {
+      acceptContentTypes,
+      advancedOptions,
+      contentType,
+      disabled,
+      allowMedia,
+      hasMedia,
+      allowPoll,
+      hasPoll,
+      onChangeAdvancedOption,
+      onChangeContentType,
+      onTogglePoll,
+      onToggleSpoiler,
+      resetFileKey,
+      spoiler,
+      showContentTypeChoice,
+      isEditing,
+      intl: { formatMessage },
+    } = this.props;
+
+    const contentTypeItems = {
+      plain: {
+        icon: 'file-text',
+        name: 'text/plain',
+        text: formatMessage(messages.plain),
+      },
+      html: {
+        icon: 'code',
+        name: 'text/html',
+        text: formatMessage(messages.html),
+      },
+      markdown: {
+        icon: 'arrow-circle-down',
+        name: 'text/markdown',
+        text: formatMessage(messages.markdown),
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='compose-form__buttons'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || !allowMedia}
+          key={resetFileKey}
+          onChange={this.handleChangeFiles}
+          ref={this.handleRefFileElement}
+          type='file'
+          multiple
+          style={{ display: 'none' }}
+        />
+        <DropdownContainer
+          disabled={disabled || !allowMedia}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: formatMessage(messages.upload),
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: formatMessage(messages.doodle),
+            },
+          ]}
+          onChange={this.handleClickAttach}
+          title={formatMessage(messages.attach)}
+        />
+        {!!pollLimits && (
+          <IconButton
+            active={hasPoll}
+            disabled={disabled || !allowPoll}
+            icon='tasks'
+            inverted
+            onClick={onTogglePoll}
+            size={18}
+            style={{
+              height: null,
+              lineHeight: null,
+            }}
+            title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
+          />
+        )}
+        <hr />
+        <PrivacyDropdownContainer disabled={disabled || isEditing} />
+        {showContentTypeChoice && (
+          <DropdownContainer
+            disabled={disabled}
+            icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
+            items={[
+              contentTypeItems.plain,
+              contentTypeItems.html,
+              contentTypeItems.markdown,
+            ]}
+            onChange={onChangeContentType}
+            title={formatMessage(messages.content_type)}
+            value={contentType}
+          />
+        )}
+        {onToggleSpoiler && (
+          <TextIconButton
+            active={spoiler}
+            ariaControls='glitch.composer.spoiler.input'
+            label='CW'
+            onClick={onToggleSpoiler}
+            title={formatMessage(messages.spoiler)}
+          />
+        )}
+        <LanguageDropdown />
+        <DropdownContainer
+          disabled={disabled || isEditing}
+          icon='ellipsis-h'
+          items={advancedOptions ? [
+            {
+              meta: formatMessage(messages.local_only_long),
+              name: 'do_not_federate',
+              text: formatMessage(messages.local_only_short),
+            },
+            {
+              meta: formatMessage(messages.threaded_mode_long),
+              name: 'threaded_mode',
+              text: formatMessage(messages.threaded_mode_short),
+            },
+          ] : null}
+          onChange={onChangeAdvancedOption}
+          renderItemContents={this.renderToggleItemContents}
+          title={formatMessage(messages.advanced_options_icon_title)}
+          closeOnChange={false}
+        />
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ComposerOptions);
diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx b/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx
new file mode 100644
index 000000000..cbd53c4d5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx
@@ -0,0 +1,171 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Icon from 'flavours/glitch/components/icon';
+import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
+import classNames from 'classnames';
+import { pollLimits } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
+  add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
+  remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
+  poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
+  single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' },
+  multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' },
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+});
+
+class OptionIntl extends React.PureComponent {
+
+  static propTypes = {
+    title: PropTypes.string.isRequired,
+    lang: PropTypes.string,
+    index: PropTypes.number.isRequired,
+    isPollMultiple: PropTypes.bool,
+    autoFocus: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleOptionTitleChange = e => {
+    this.props.onChange(this.props.index, e.target.value);
+  };
+
+  handleOptionRemove = () => {
+    this.props.onRemove(this.props.index);
+  };
+
+  onSuggestionsClearRequested = () => {
+    this.props.onClearSuggestions();
+  };
+
+  onSuggestionsFetchRequested = (token) => {
+    this.props.onFetchSuggestions(token);
+  };
+
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
+  };
+
+  render () {
+    const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
+
+    return (
+      <li>
+        <label className='poll__option editable'>
+          <span className={classNames('poll__input', { checkbox: isPollMultiple })} />
+
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
+            maxLength={pollLimits.max_option_chars}
+            value={title}
+            lang={lang}
+            spellCheck
+            onChange={this.handleOptionTitleChange}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            searchTokens={[':']}
+            autoFocus={autoFocus}
+          />
+        </label>
+
+        <div className='poll__cancel'>
+          <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} />
+        </div>
+      </li>
+    );
+  }
+
+}
+
+const Option = injectIntl(OptionIntl);
+
+class PollForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    options: ImmutablePropTypes.list,
+    lang: PropTypes.string,
+    expiresIn: PropTypes.number,
+    isMultiple: PropTypes.bool,
+    onChangeOption: PropTypes.func.isRequired,
+    onAddOption: PropTypes.func.isRequired,
+    onRemoveOption: PropTypes.func.isRequired,
+    onChangeSettings: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleAddOption = () => {
+    this.props.onAddOption('');
+  };
+
+  handleSelectDuration = e => {
+    this.props.onChangeSettings(e.target.value, this.props.isMultiple);
+  };
+
+  handleSelectMultiple = e => {
+    this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true');
+  };
+
+  render () {
+    const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
+
+    if (!options) {
+      return null;
+    }
+
+    const autoFocusIndex = options.indexOf('');
+
+    return (
+      <div className='compose-form__poll-wrapper'>
+        <ul>
+          {options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
+          {options.size < pollLimits.max_options && (
+            <label className='poll__text editable'>
+              <span className={classNames('poll__input')} style={{ opacity: 0 }} />
+              <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
+            </label>
+          )}
+        </ul>
+
+        <div className='poll__footer'>
+          <select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}>
+            <option value='false'>{intl.formatMessage(messages.single_choice)}</option>
+            <option value='true'>{intl.formatMessage(messages.multiple_choices)}</option>
+          </select>
+
+          {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+          <select value={expiresIn} onChange={this.handleSelectDuration}>
+            <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+            <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+            <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+            <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+            <option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
+            <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+            <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+            <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+          </select>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(PollForm);
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx
new file mode 100644
index 000000000..4bfbb5b8c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import Dropdown from './dropdown';
+
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Only people I mention' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+});
+
+class PrivacyDropdown extends React.PureComponent {
+
+  static propTypes = {
+    isUserTouching: PropTypes.func,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    value: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    noDirect: PropTypes.bool,
+    container: PropTypes.func,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props;
+
+    //  We predefine our privacy items so that we can easily pick the
+    //  dropdown icon later.
+    const privacyItems = {
+      direct: {
+        icon: 'envelope',
+        meta: formatMessage(messages.direct_long),
+        name: 'direct',
+        text: formatMessage(messages.direct_short),
+      },
+      private: {
+        icon: 'lock',
+        meta: formatMessage(messages.private_long),
+        name: 'private',
+        text: formatMessage(messages.private_short),
+      },
+      public: {
+        icon: 'globe',
+        meta: formatMessage(messages.public_long),
+        name: 'public',
+        text: formatMessage(messages.public_short),
+      },
+      unlisted: {
+        icon: 'unlock',
+        meta: formatMessage(messages.unlisted_long),
+        name: 'unlisted',
+        text: formatMessage(messages.unlisted_short),
+      },
+    };
+
+    const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private];
+
+    if (!noDirect) {
+      items.push(privacyItems.direct);
+    }
+
+    return (
+      <Dropdown
+        disabled={disabled}
+        icon={(privacyItems[value] || {}).icon}
+        items={items}
+        onChange={onChange}
+        isUserTouching={isUserTouching}
+        onModalClose={onModalClose}
+        onModalOpen={onModalOpen}
+        title={formatMessage(messages.change_privacy)}
+        container={container}
+        value={value}
+      />
+    );
+  }
+
+}
+
+export default injectIntl(PrivacyDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.jsx b/app/javascript/flavours/glitch/features/compose/components/publisher.jsx
new file mode 100644
index 000000000..3128303c6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.jsx
@@ -0,0 +1,100 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { length } from 'stringz';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { maxChars } from 'flavours/glitch/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  publish: {
+    defaultMessage: 'Publish',
+    id: 'compose_form.publish',
+  },
+  publishLoud: {
+    defaultMessage: '{publish}!',
+    id: 'compose_form.publish_loud',
+  },
+  saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
+});
+
+class Publisher extends ImmutablePureComponent {
+
+  static propTypes = {
+    countText: PropTypes.string,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onSecondarySubmit: PropTypes.func,
+    onSubmit: PropTypes.func,
+    privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+    sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+    isEditing: PropTypes.bool,
+  };
+
+  handleSubmit = () => {
+    this.props.onSubmit();
+  };
+
+  render () {
+    const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props;
+
+    const diff = maxChars - length(countText || '');
+    const computedClass = classNames('compose-form__publish', {
+      disabled: disabled,
+      over: diff < 0,
+    });
+
+    const privacyIcons = { direct: 'envelope', private: 'lock', public: 'globe', unlisted: 'unlock' };
+
+    let publishText;
+    if (isEditing) {
+      publishText = intl.formatMessage(messages.saveChanges);
+    } else if (privacy === 'private' || privacy === 'direct') {
+      const iconId = privacyIcons[privacy];
+      publishText = (
+        <span>
+          <Icon id={iconId} /> {intl.formatMessage(messages.publish)}
+        </span>
+      );
+    } else {
+      publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+    }
+
+    return (
+      <div className={computedClass}>
+        {sideArm && !isEditing && sideArm !== 'none' ? (
+          <div className='compose-form__publish-button-wrapper'>
+            <Button
+              className='side_arm'
+              disabled={disabled}
+              onClick={onSecondarySubmit}
+              style={{ padding: null }}
+              text={<Icon id={privacyIcons[sideArm]} />}
+              title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+            />
+          </div>
+        ) : null}
+        <div className='compose-form__publish-button-wrapper'>
+          <Button
+            className='primary'
+            text={publishText}
+            title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+            onClick={this.handleSubmit}
+            disabled={disabled}
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Publisher);
diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx
new file mode 100644
index 000000000..179d85ac3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx
@@ -0,0 +1,83 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import IconButton from 'flavours/glitch/components/icon_button';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+
+//  Messages.
+const messages = defineMessages({
+  cancel: {
+    defaultMessage: 'Cancel',
+    id: 'reply_indicator.cancel',
+  },
+});
+
+
+class ReplyIndicator extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+    onCancel: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { onCancel } = this.props;
+    if (onCancel) {
+      onCancel();
+    }
+  };
+
+  //  Rendering.
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const account     = status.get('account');
+    const content     = status.get('content');
+    const attachments = status.get('media_attachments');
+
+    //  The result.
+    return (
+      <article className='reply-indicator'>
+        <header className='reply-indicator__header'>
+          <IconButton
+            className='reply-indicator__cancel'
+            icon='times'
+            onClick={this.handleClick}
+            title={intl.formatMessage(messages.cancel)}
+            inverted
+          />
+          {account && (
+            <AccountContainer
+              id={account}
+              small
+            />
+          )}
+        </header>
+        <div
+          className='reply-indicator__content translate'
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+        />
+        {attachments.size > 0 && (
+          <AttachmentList
+            compact
+            media={attachments}
+          />
+        )}
+      </article>
+    );
+  }
+
+}
+
+export default injectIntl(ReplyIndicator);
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.jsx b/app/javascript/flavours/glitch/features/compose/components/search.jsx
new file mode 100644
index 000000000..d2187b8ae
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search.jsx
@@ -0,0 +1,169 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import {
+  injectIntl,
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import Overlay from 'react-overlays/Overlay';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
+import { searchEnabled } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+  placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  render () {
+    const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
+    return (
+      <div className='search-popout'>
+        <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+        <ul>
+          <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+          <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+          <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+          <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+        </ul>
+
+        {extraInformation}
+      </div>
+    );
+  }
+
+}
+
+//  The component.
+class Search extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    identity: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    submitted: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onShow: PropTypes.func.isRequired,
+    openInRoute: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    singleColumn: PropTypes.bool,
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  setRef = c => {
+    this.searchForm = c;
+  };
+
+  handleChange = (e) => {
+    const { onChange } = this.props;
+    if (onChange) {
+      onChange(e.target.value);
+    }
+  };
+
+  handleClear = (e) => {
+    const {
+      onClear,
+      submitted,
+      value,
+    } = this.props;
+    e.preventDefault();  //  Prevents focus change ??
+    if (onClear && (submitted || value && value.length)) {
+      onClear();
+    }
+  };
+
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  };
+
+  handleFocus = () => {
+    this.setState({ expanded: true });
+    this.props.onShow();
+
+    if (this.searchForm && !this.props.singleColumn) {
+      const { left, right } = this.searchForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.searchForm.scrollIntoView();
+      }
+    }
+  };
+
+  handleKeyUp = (e) => {
+    const { onSubmit } = this.props;
+    switch (e.key) {
+    case 'Enter':
+      onSubmit();
+
+      if (this.props.openInRoute) {
+        this.context.router.history.push('/search');
+      }
+      break;
+    case 'Escape':
+      focusRoot();
+    }
+  };
+
+  findTarget = () => {
+    return this.searchForm;
+  };
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
+    const { signedIn } = this.context.identity;
+    const hasValue = value.length > 0 || submitted;
+
+    return (
+      <div className='search'>
+        <input
+          ref={this.setRef}
+          className='search__input'
+          type='text'
+          placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          value={value || ''}
+          onChange={this.handleChange}
+          onKeyUp={this.handleKeyUp}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <Icon id='search' className={hasValue ? '' : 'active'} />
+          <Icon id='times-circle' className={hasValue ? 'active' : ''} />
+        </div>
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
+          {({ props, placement }) => (
+            <div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}>
+              <div className={`dropdown-animation ${placement}`}>
+                <SearchPopout />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Search);
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.jsx b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx
new file mode 100644
index 000000000..bf009d13a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx
@@ -0,0 +1,142 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
+import Icon from 'flavours/glitch/components/icon';
+import { searchEnabled } from 'flavours/glitch/initial_state';
+import LoadMore from 'flavours/glitch/components/load_more';
+
+const messages = defineMessages({
+  dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
+});
+
+class SearchResults extends ImmutablePureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map.isRequired,
+    suggestions: ImmutablePropTypes.list.isRequired,
+    fetchSuggestions: PropTypes.func.isRequired,
+    expandSearch: PropTypes.func.isRequired,
+    dismissSuggestion: PropTypes.func.isRequired,
+    searchTerm: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    if (this.props.searchTerm === '') {
+      this.props.fetchSuggestions();
+    }
+  }
+
+  componentDidUpdate () {
+    if (this.props.searchTerm === '') {
+      this.props.fetchSuggestions();
+    }
+  }
+
+  handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
+
+  handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
+
+  handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
+
+  render () {
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (searchTerm === '' && !suggestions.isEmpty()) {
+      return (
+        <div className='drawer--results'>
+          <div className='trends'>
+            <div className='trends__header'>
+              <Icon fixedWidth id='user-plus' />
+              <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
+            </div>
+
+            {suggestions && suggestions.map(suggestion => (
+              <AccountContainer
+                key={suggestion.get('account')}
+                id={suggestion.get('account')}
+                actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
+                actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
+                onActionClick={dismissSuggestion}
+              />
+            ))}
+          </div>
+        </div>
+      );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <section className='search-results__section'>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this Mastodon server.' />
+          </div>
+        </section>
+      );
+    }
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <section className='search-results__section'>
+          <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+
+          {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
+
+          {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
+        </section>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <section className='search-results__section'>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
+
+          {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId} />)}
+
+          {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
+        </section>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <section className='search-results__section'>
+          <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+
+          {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+
+          {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
+        </section>
+      );
+    }
+
+    //  The result.
+    return (
+      <div className='drawer--results'>
+        <header className='search-results__header'>
+          <Icon id='search' fixedWidth />
+          <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </header>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(SearchResults);
diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx
new file mode 100644
index 000000000..a35bd4ff5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px',
+  width: `${18 * 1.28571429}px`,
+};
+
+export default class TextIconButton extends React.PureComponent {
+
+  static propTypes = {
+    label: PropTypes.string.isRequired,
+    title: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    ariaControls: PropTypes.string,
+  };
+
+  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.props.onClick}
+        aria-controls={ariaControls}
+        style={iconStyle}
+      >
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx
new file mode 100644
index 000000000..73281fc74
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx
@@ -0,0 +1,61 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Messages.
+const messages = defineMessages({
+  localOnly: {
+    defaultMessage: 'This post is local-only',
+    id: 'advanced_options.local-only.tooltip',
+  },
+  threadedMode: {
+    defaultMessage: 'Threaded mode enabled',
+    id: 'advanced_options.threaded_mode.tooltip',
+  },
+});
+
+//  We use an array of tuples here instead of an object because it
+//  preserves order.
+const iconMap = [
+  ['do_not_federate', 'home', messages.localOnly],
+  ['threaded_mode', 'comments', messages.threadedMode],
+];
+
+class TextareaIcons extends ImmutablePureComponent {
+
+  static propTypes = {
+    advancedOptions: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { advancedOptions, intl } = this.props;
+    return (
+      <div className='compose-form__textarea-icons'>
+        {advancedOptions ? iconMap.map(
+          ([key, icon, message]) => advancedOptions.get(key) ? (
+            <span
+              className='textarea_icon'
+              key={key}
+              title={intl.formatMessage(message)}
+            >
+              <Icon
+                fixedWidth
+                id={icon}
+              />
+            </span>
+          ) : null,
+        ) : null}
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(TextareaIcons);
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.jsx b/app/javascript/flavours/glitch/features/compose/components/upload.jsx
new file mode 100644
index 000000000..63582c636
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
+
+export default class Upload extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onOpenFocalPoint: PropTypes.func.isRequired,
+  };
+
+  handleUndoClick = e => {
+    e.stopPropagation();
+    this.props.onUndo(this.props.media.get('id'));
+  };
+
+  handleFocalPointClick = e => {
+    e.stopPropagation();
+    this.props.onOpenFocalPoint(this.props.media.get('id'));
+  };
+
+  render () {
+    const { media } = this.props;
+
+    if (!media) {
+      return null;
+    }
+
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+    const x = ((focusX /  2) + .5) * 100;
+    const y = ((focusY / -2) + .5) * 100;
+
+    return (
+      <div className='compose-form__upload' tabIndex='0' role='button'>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) => (
+            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className='compose-form__upload__actions'>
+                <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
+              </div>
+
+              {(media.get('description') || '').length === 0 && (
+                <div className='compose-form__upload__warning'>
+                  <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
+                </div>
+              )}
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.jsx b/app/javascript/flavours/glitch/features/compose/components/upload_form.jsx
new file mode 100644
index 000000000..f2e7fe7a2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import UploadContainer from '../containers/upload_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+
+export default class UploadForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    mediaIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { mediaIds } = this.props;
+
+    return (
+      <div className='compose-form__upload-wrapper'>
+        <UploadProgressContainer />
+
+        {mediaIds.size > 0 && (
+          <div className='compose-form__uploads-wrapper'>
+            {mediaIds.map(id => (
+              <UploadContainer id={id} key={id} />
+            ))}
+          </div>
+        )}
+
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx b/app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx
new file mode 100644
index 000000000..39ac31053
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import Icon from 'flavours/glitch/components/icon';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadProgress extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    progress: PropTypes.number,
+    isProcessing: PropTypes.bool,
+  };
+
+  render () {
+    const { active, progress, isProcessing } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    let message;
+
+    if (isProcessing) {
+      message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
+    } else {
+      message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
+    }
+
+    return (
+      <div className='upload-progress'>
+        <div className='upload-progress__icon'>
+          <Icon id='upload' />
+        </div>
+
+        <div className='upload-progress__message'>
+          {message}
+
+          <div className='upload-progress__backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.jsx b/app/javascript/flavours/glitch/features/compose/components/warning.jsx
new file mode 100644
index 000000000..803b7f86a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/warning.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+export default class Warning extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { message } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..0e1c328fe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestAccount from '../components/autosuggest_account';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id),
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..ddcdb367a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -0,0 +1,144 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ComposeForm from '../components/compose_form';
+import {
+  changeCompose,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  openModal,
+} from 'flavours/glitch/actions/modal';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+
+import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
+
+const messages = defineMessages({
+  missingDescriptionMessage: {
+    id: 'confirmations.missing_media_description.message',
+    defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
+  },
+  missingDescriptionConfirm: {
+    id: 'confirmations.missing_media_description.confirm',
+    defaultMessage: 'Send anyway',
+  },
+  missingDescriptionEdit: {
+    id: 'confirmations.missing_media_description.edit',
+    defaultMessage: 'Edit media',
+  },
+});
+
+//  State mapping.
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
+  const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
+  const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
+  let sideArmPrivacy = null;
+  switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
+  case 'copy':
+    sideArmPrivacy = replyPrivacy;
+    break;
+  case 'restrict':
+    sideArmPrivacy = sideArmRestrictedPrivacy;
+    break;
+  }
+  sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
+  return {
+    advancedOptions: state.getIn(['compose', 'advanced_options']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    caretPosition: state.getIn(['compose', 'caretPosition']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isEditing: state.getIn(['compose', 'id']) !== null,
+    isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
+    isUploading: state.getIn(['compose', 'is_uploading']),
+    layout: state.getIn(['local_settings', 'layout']),
+    media: state.getIn(['compose', 'media_attachments']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    privacy: state.getIn(['compose', 'privacy']),
+    sideArm: sideArmPrivacy,
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
+    spoilerText: state.getIn(['compose', 'spoiler_text']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    text: state.getIn(['compose', 'text']),
+    anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
+    spoilersAlwaysOn: spoilersAlwaysOn,
+    mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
+    preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
+    isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
+    lang: state.getIn(['compose', 'language']),
+  };
+}
+
+//  Dispatch mapping.
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onChange(text) {
+    dispatch(changeCompose(text));
+  },
+
+  onSubmit(routerHistory) {
+    dispatch(submitCompose(routerHistory));
+  },
+
+  onClearSuggestions() {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions(token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected(position, token, suggestion, path) {
+    dispatch(selectComposeSuggestion(position, token, suggestion, path));
+  },
+
+  onChangeSpoilerText(text) {
+    dispatch(changeComposeSpoilerText(text));
+  },
+
+  onPaste(files) {
+    dispatch(uploadCompose(files));
+  },
+
+  onPickEmoji(position, emoji) {
+    dispatch(insertEmojiCompose(position, emoji));
+  },
+
+  onChangeSpoilerness() {
+    dispatch(changeComposeSpoilerness());
+  },
+
+  onChangeVisibility(value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
+  onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.missingDescriptionMessage),
+      confirm: intl.formatMessage(messages.missingDescriptionConfirm),
+      onConfirm: () => {
+        if (overriddenVisibility) {
+          dispatch(changeComposeVisibility(overriddenVisibility));
+        }
+        dispatch(submitCompose(routerHistory));
+      },
+      secondary: intl.formatMessage(messages.missingDescriptionEdit),
+      onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })),
+      onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
+    }));
+  },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js
new file mode 100644
index 000000000..3ac16505f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import Dropdown from '../components/dropdown';
+
+const mapDispatchToProps = dispatch => ({
+  isUserTouching,
+  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+  onModalClose: () => dispatch(closeModal()),
+});
+
+export default connect(null, mapDispatchToProps)(Dropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
new file mode 100644
index 000000000..66d51947a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,83 @@
+import { connect } from 'react-redux';
+import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'flavours/glitch/actions/emojis';
+
+const perLine = 8;
+const lines   = 2;
+
+const DEFAULTS = [
+  '+1',
+  'grinning',
+  'kissing_heart',
+  'heart_eyes',
+  'laughing',
+  'stuck_out_tongue_winking_eye',
+  'sweat_smile',
+  'joy',
+  'yum',
+  'disappointed',
+  'thinking_face',
+  'weary',
+  'sob',
+  'sunglasses',
+  'heart',
+  'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+  let emojis = emojiCounters
+    .keySeq()
+    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+    .reverse()
+    .slice(0, perLine * lines)
+    .toArray();
+
+  if (emojis.length < DEFAULTS.length) {
+    let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
+    emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
+  }
+
+  return emojis;
+});
+
+const getCustomEmojis = createSelector([
+  state => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+  const aShort = a.get('shortcode').toLowerCase();
+  const bShort = b.get('shortcode').toLowerCase();
+
+  if (aShort < bShort) {
+    return -1;
+  } else if (aShort > bShort ) {
+    return 1;
+  } else {
+    return 0;
+  }
+}));
+
+const mapStateToProps = state => ({
+  custom_emojis: getCustomEmojis(state),
+  skinTone: state.getIn(['settings', 'skinTone']),
+  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+  onSkinTone: skinTone => {
+    dispatch(changeSetting(['skinTone'], skinTone));
+  },
+
+  onPickEmoji: emoji => {
+    dispatch(useEmoji(emoji));
+
+    if (onPickEmoji) {
+      onPickEmoji(emoji);
+    }
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
new file mode 100644
index 000000000..e1ce19fb0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -0,0 +1,36 @@
+import { openModal } from 'flavours/glitch/actions/modal';
+import { connect }   from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import Header from '../components/header';
+import { logOut } from 'flavours/glitch/utils/log_out';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = state => {
+  return {
+    columns: state.getIn(['settings', 'columns']),
+    unreadNotifications: state.getIn(['notifications', 'unread']),
+    showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onSettingsClick (e) {
+    e.preventDefault();
+    e.stopPropagation();
+    dispatch(openModal('SETTINGS', {}));
+  },
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js
new file mode 100644
index 000000000..828d08cf5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js
@@ -0,0 +1,34 @@
+import { connect } from 'react-redux';
+import LanguageDropdown from '../components/language_dropdown';
+import { changeComposeLanguage } from 'flavours/glitch/actions/compose';
+import { useLanguage } from 'flavours/glitch/actions/languages';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const getFrequentlyUsedLanguages = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()),
+], languageCounters => (
+  languageCounters.keySeq()
+    .sort((a, b) => languageCounters.get(a) - languageCounters.get(b))
+    .reverse()
+    .toArray()
+));
+
+const mapStateToProps = state => ({
+  frequentlyUsedLanguages: getFrequentlyUsedLanguages(state),
+  value: state.getIn(['compose', 'language']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeLanguage(value));
+  },
+
+  onClose (value) {
+    dispatch(useLanguage(value));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..89036adcd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
@@ -0,0 +1,30 @@
+import { connect }   from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import NavigationBar from '../components/navigation_bar';
+import { logOut } from 'flavours/glitch/utils/log_out';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { me } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = state => {
+  return {
+    account: state.getIn(['accounts', me]),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
new file mode 100644
index 000000000..19a90ac8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
@@ -0,0 +1,53 @@
+import { connect } from 'react-redux';
+import Options from '../components/options';
+import {
+  changeComposeAdvancedOption,
+  changeComposeContentType,
+  addPoll,
+  removePoll,
+} from 'flavours/glitch/actions/compose';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const poll = state.getIn(['compose', 'poll']);
+  const media = state.getIn(['compose', 'media_attachments']);
+  const pending_media = state.getIn(['compose', 'pending_media_attachments']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    hasPoll: !!poll,
+    allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
+    hasMedia: media && !!media.size,
+    allowPoll: !(media && !!media.size),
+    showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
+    contentType: state.getIn(['compose', 'content_type']),
+  };
+}
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onChangeAdvancedOption(option, value) {
+    dispatch(changeComposeAdvancedOption(option, value));
+  },
+
+  onChangeContentType(value) {
+    dispatch(changeComposeContentType(value));
+  },
+
+  onTogglePoll() {
+    dispatch((_, getState) => {
+      if (getState().getIn(['compose', 'poll'])) {
+        dispatch(removePoll());
+      } else {
+        dispatch(addPoll());
+      }
+    });
+  },
+
+  onDoodleOpen() {
+    dispatch(openModal('DOODLE', { noEsc: true, noClose: true }));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Options);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
new file mode 100644
index 000000000..14038b3e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
@@ -0,0 +1,52 @@
+import { connect } from 'react-redux';
+import PollForm from '../components/poll_form';
+import {
+  addPollOption,
+  removePollOption,
+  changePollOption,
+  changePollSettings,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+} from 'flavours/glitch/actions/compose';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['compose', 'suggestions']),
+  options: state.getIn(['compose', 'poll', 'options']),
+  lang: state.getIn(['compose', 'language']),
+  expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
+  isMultiple: state.getIn(['compose', 'poll', 'multiple']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onAddOption(title) {
+    dispatch(addPollOption(title));
+  },
+
+  onRemoveOption(index) {
+    dispatch(removePollOption(index));
+  },
+
+  onChangeOption(index, title) {
+    dispatch(changePollOption(index, title));
+  },
+
+  onChangeSettings(expiresIn, isMultiple) {
+    dispatch(changePollSettings(expiresIn, isMultiple));
+  },
+
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected (position, token, accountId, path) {
+    dispatch(selectComposeSuggestion(position, token, accountId, path));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PollForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
new file mode 100644
index 000000000..5591d89c4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from 'flavours/glitch/actions/compose';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['compose', 'privacy']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
+  isUserTouching,
+  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+  onModalClose: () => dispatch(closeModal()),
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..dd6899be4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = state => {
+    let statusId = state.getIn(['compose', 'id'], null);
+    let editing  = true;
+
+    if (statusId === null) {
+      statusId = state.getIn(['compose', 'in_reply_to']);
+      editing  = false;
+    }
+
+    return {
+      status: state.getIn(['statuses', statusId]),
+      editing,
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
new file mode 100644
index 000000000..8f4bfcf08
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch,
+} from 'flavours/glitch/actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeSearch(value));
+  },
+
+  onClear () {
+    dispatch(clearSearch());
+  },
+
+  onSubmit () {
+    dispatch(submitSearch());
+  },
+
+  onShow () {
+    dispatch(showSearch());
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..5c2c1be23
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
+import { expandSearch } from 'flavours/glitch/actions/search';
+
+const mapStateToProps = state => ({
+  results: state.getIn(['search', 'results']),
+  suggestions: state.getIn(['suggestions', 'items']),
+  searchTerm: state.getIn(['search', 'searchTerm']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchSuggestions: () => dispatch(fetchSuggestions()),
+  expandSearch: type => dispatch(expandSearch(type)),
+  dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx
new file mode 100644
index 000000000..9c23d3f47
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  marked: {
+    id: 'compose_form.sensitive.marked',
+    defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
+  },
+  unmarked: {
+    id: 'compose_form.sensitive.unmarked',
+    defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
+  },
+});
+
+const mapStateToProps = state => {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const spoilerText = state.getIn(['compose', 'spoiler_text']);
+  return {
+    active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
+    disabled: state.getIn(['compose', 'spoiler']),
+    mediaCount: state.getIn(['compose', 'media_attachments']).size,
+  };
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    mediaCount: PropTypes.number,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { active, disabled, mediaCount, onClick, intl } = this.props;
+
+    return (
+      <div className='compose-form__sensitive-button'>
+        <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
+          <input
+            name='mark-sensitive'
+            type='checkbox'
+            checked={active}
+            onChange={onClick}
+            disabled={disabled}
+          />
+
+          <span className={classNames('checkbox', { active })} />
+
+          <FormattedMessage
+            id='compose_form.sensitive.hide'
+            defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
+            values={{ count: mediaCount }}
+          />
+        </label>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..2189c870b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, initMediaEditModal, submitCompose } from 'flavours/glitch/actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onUndo: id => {
+    dispatch(undoUploadCompose(id));
+  },
+
+  onOpenFocalPoint: id => {
+    dispatch(initMediaEditModal(id));
+  },
+
+  onSubmit (router) {
+    dispatch(submitCompose(router));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6798bf51
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+
+const mapStateToProps = state => ({
+  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+});
+
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..b18c76a43
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
@@ -0,0 +1,10 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = state => ({
+  active: state.getIn(['compose', 'is_uploading']),
+  progress: state.getIn(['compose', 'progress']),
+  isProcessing: state.getIn(['compose', 'is_processing']),
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx
new file mode 100644
index 000000000..5b48c45e4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Warning from '../components/warning';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { me } from 'flavours/glitch/initial_state';
+import { profileLink, termsLink } from 'flavours/glitch/utils/backend_links';
+
+const buildHashtagRE = () => {
+  try {
+    const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
+    const ALPHA = '\\p{L}\\p{M}';
+    const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
+    return new RegExp(
+      '(?:^|[^\\/\\)\\w])#((' +
+      '[' + WORD + '_]' +
+      '[' + WORD + HASHTAG_SEPARATORS + ']*' +
+      '[' + ALPHA + HASHTAG_SEPARATORS + ']' +
+      '[' + WORD + HASHTAG_SEPARATORS +']*' +
+      '[' + WORD + '_]' +
+      ')|(' +
+      '[' + WORD + '_]*' +
+      '[' + ALPHA + ']' +
+      '[' + WORD + '_]*' +
+      '))', 'iu',
+    );
+  } catch {
+    return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
+  }
+};
+
+const APPROX_HASHTAG_RE = buildHashtagRE();
+
+const mapStateToProps = state => ({
+  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+  hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
+  directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
+});
+
+const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
+  if (needsLockWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+  }
+
+  if (hashtagWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />;
+  }
+
+  if (directMessageWarning) {
+    const message = (
+      <span>
+        <FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> {!!termsLink && <a href={termsLink} target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
+      </span>
+    );
+
+    return <Warning message={message} />;
+  }
+
+  return null;
+};
+
+WarningWrapper.propTypes = {
+  needsLockWarning: PropTypes.bool,
+  hashtagWarning: PropTypes.bool,
+  directMessageWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/flavours/glitch/features/compose/index.jsx b/app/javascript/flavours/glitch/features/compose/index.jsx
new file mode 100644
index 000000000..5547a1210
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/index.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import ComposeFormContainer from './containers/compose_form_container';
+import NavigationContainer from './containers/navigation_container';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { mountCompose, unmountCompose, cycleElefriendCompose } from 'flavours/glitch/actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+import SearchContainer from './containers/search_container';
+import Motion from '../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { me, mascot } from 'flavours/glitch/initial_state';
+import HeaderContainer from './containers/header_container';
+import Column from 'flavours/glitch/components/column';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
+});
+
+const mapStateToProps = (state, ownProps) => ({
+  elefriend: state.getIn(['compose', 'elefriend']),
+  showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onClickElefriend () {
+    dispatch(cycleElefriendCompose());
+  },
+
+  onMount () {
+    dispatch(mountCompose());
+  },
+
+  onUnmount () {
+    dispatch(unmountCompose());
+  },
+});
+
+class Compose extends React.PureComponent {
+
+  static propTypes = {
+    multiColumn: PropTypes.bool,
+    showSearch: PropTypes.bool,
+    elefriend: PropTypes.number,
+    onClickElefriend: PropTypes.func,
+    onMount: PropTypes.func,
+    onUnmount: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    this.props.onMount();
+  }
+
+  componentWillUnmount () {
+    this.props.onUnmount();
+  }
+
+  render () {
+    const {
+      elefriend,
+      intl,
+      multiColumn,
+      onClickElefriend,
+      showSearch,
+    } = this.props;
+    const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
+
+    if (multiColumn) {
+      return (
+        <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
+          <HeaderContainer />
+
+          {multiColumn && <SearchContainer />}
+
+          <div className='drawer__pager'>
+            <div className='drawer__inner'>
+              <NavigationContainer />
+
+              <ComposeFormContainer />
+
+              <div className='drawer__inner__mastodon'>
+                {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
+              </div>
+            </div>
+
+            <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+              {({ x }) => (
+                <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                  <SearchResultsContainer />
+                </div>
+              )}
+            </Motion>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <Column>
+        <NavigationContainer />
+        <ComposeFormContainer />
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Compose));
diff --git a/app/javascript/flavours/glitch/features/compose/util/counter.js b/app/javascript/flavours/glitch/features/compose/util/counter.js
new file mode 100644
index 000000000..ec2431096
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/util/counter.js
@@ -0,0 +1,9 @@
+import { urlRegex } from './url_regex';
+
+const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
+
+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/features/compose/util/url_regex.js b/app/javascript/flavours/glitch/features/compose/util/url_regex.js
new file mode 100644
index 000000000..9c2005c53
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/util/url_regex.js
@@ -0,0 +1,30 @@
+import regexSupplant from 'twitter-text/dist/lib/regexSupplant';
+import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars';
+import validDomain from 'twitter-text/dist/regexp/validDomain';
+import validPortNumber from 'twitter-text/dist/regexp/validPortNumber';
+import validUrlPath from 'twitter-text/dist/regexp/validUrlPath';
+import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars';
+import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars';
+
+// The difference with twitter-text's extractURL is that the protocol isn't
+// optional.
+
+export const urlRegex = regexSupplant(
+  '('                                                          + // $1 URL
+    '(#{validUrlPrecedingChars})'                              + // $2
+    '(https?:\\/\\/)'                                          + // $3 Protocol
+    '(#{validDomain})'                                         + // $4 Domain(s)
+    '(?::(#{validPortNumber}))?'                               + // $5 Port number (optional)
+    '(\\/#{validUrlPath}*)?'                                   + // $6 URL Path
+    '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $7 Query String
+  ')',
+  {
+    validUrlPrecedingChars,
+    validDomain,
+    validPortNumber,
+    validUrlPath,
+    validUrlQueryChars,
+    validUrlQueryEndingChars,
+  },
+  'gi',
+);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..79e98ec6f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.jsx
@@ -0,0 +1,43 @@
+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 '../../../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' },
+});
+
+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 settings={settings} settingPath={['conversations']} onChange={onChange} label={<FormattedMessage id='direct.group_by_conversations' defaultMessage='Group by conversation' />} />
+        </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>
+    );
+  }
+
+}
+
+export default injectIntl(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx
new file mode 100644
index 000000000..05fd68707
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx
@@ -0,0 +1,233 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from 'flavours/glitch/components/status_content';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import AvatarComposite from 'flavours/glitch/components/avatar_composite';
+import Permalink from 'flavours/glitch/components/permalink';
+import IconButton from 'flavours/glitch/components/icon_button';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import { HotKeys } from 'react-hotkeys';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  more: { id: 'status.more', defaultMessage: 'More' },
+  open: { id: 'conversation.open', defaultMessage: 'View conversation' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
+  delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
+  muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+  unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+});
+
+class Conversation extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    conversationId: PropTypes.string.isRequired,
+    accounts: ImmutablePropTypes.list.isRequired,
+    lastStatus: ImmutablePropTypes.map,
+    unread:PropTypes.bool.isRequired,
+    scrollKey: PropTypes.string,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
+    markRead: PropTypes.func.isRequired,
+    delete: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    isExpanded: undefined,
+  };
+
+  parseClick = (e, destination) => {
+    const { router } = this.context;
+    const { lastStatus, unread, markRead } = this.props;
+    if (!router) return;
+
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
+      if (destination === undefined) {
+        if (unread) {
+          markRead();
+        }
+        destination = `/statuses/${lastStatus.get('id')}`;
+      }
+      let state = { ...router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      router.history.push(destination, state);
+      e.preventDefault();
+    }
+  };
+
+  handleMouseEnter = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-original');
+    }
+  };
+
+  handleMouseLeave = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-static');
+    }
+  };
+
+  handleClick = () => {
+    if (!this.context.router) {
+      return;
+    }
+
+    const { lastStatus, unread, markRead } = this.props;
+
+    if (unread) {
+      markRead();
+    }
+
+    this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
+  };
+
+  handleMarkAsRead = () => {
+    this.props.markRead();
+  };
+
+  handleReply = () => {
+    this.props.reply(this.props.lastStatus, this.context.router.history);
+  };
+
+  handleDelete = () => {
+    this.props.delete();
+  };
+
+  handleHotkeyMoveUp = () => {
+    this.props.onMoveUp(this.props.conversationId);
+  };
+
+  handleHotkeyMoveDown = () => {
+    this.props.onMoveDown(this.props.conversationId);
+  };
+
+  handleConversationMute = () => {
+    this.props.onMute(this.props.lastStatus);
+  };
+
+  handleShowMore = () => {
+    this.props.onToggleHidden(this.props.lastStatus);
+
+    if (this.props.lastStatus.get('spoiler_text')) {
+      this.setExpansion(!this.state.isExpanded);
+    }
+  };
+
+  setExpansion = value => {
+    this.setState({ isExpanded: value });
+  };
+
+  render () {
+    const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
+
+    if (lastStatus === null) {
+      return null;
+    }
+
+    const isExpanded = this.props.settings.getIn(['content_warnings', 'shared_state']) ? !lastStatus.get('hidden') : this.state.isExpanded;
+
+    const menu = [
+      { text: intl.formatMessage(messages.open), action: this.handleClick },
+      null,
+    ];
+
+    menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
+
+    if (unread) {
+      menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
+      menu.push(null);
+    }
+
+    menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
+
+    const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
+
+    const handlers = {
+      reply: this.handleReply,
+      open: this.handleClick,
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      toggleHidden: this.handleShowMore,
+    };
+
+    let media = null;
+    if (lastStatus.get('media_attachments').size > 0) {
+      media = <AttachmentList compact media={lastStatus.get('media_attachments')} />;
+    }
+
+    return (
+      <HotKeys handlers={handlers}>
+        <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex='0'>
+          <div className='conversation__avatar' onClick={this.handleClick} role='presentation'>
+            <AvatarComposite accounts={accounts} size={48} />
+          </div>
+
+          <div className='conversation__content'>
+            <div className='conversation__content__info'>
+              <div className='conversation__content__relative-time'>
+                {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
+              </div>
+
+              <div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+                <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
+              </div>
+            </div>
+
+            <StatusContent
+              status={lastStatus}
+              parseClick={this.parseClick}
+              expanded={isExpanded}
+              onExpandedToggle={this.handleShowMore}
+              collapsable
+              media={media}
+            />
+
+            <div className='status__action-bar'>
+              <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} />
+
+              <div className='status__action-bar-dropdown'>
+                <DropdownMenuContainer
+                  scrollKey={scrollKey}
+                  status={lastStatus}
+                  items={menu}
+                  icon='ellipsis-h'
+                  size={18}
+                  direction='right'
+                  title={intl.formatMessage(messages.more)}
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
+
+export default injectIntl(Conversation);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx
new file mode 100644
index 000000000..ae72179e2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ConversationContainer from '../containers/conversation_container';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { debounce } from 'lodash';
+
+export default class ConversationsList extends ImmutablePureComponent {
+
+  static propTypes = {
+    conversations: ImmutablePropTypes.list.isRequired,
+    scrollKey: PropTypes.string.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    onLoadMore: PropTypes.func,
+  };
+
+  getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
+
+  handleMoveUp = id => {
+    const elementIndex = this.getCurrentIndex(id) - 1;
+    this._selectChild(elementIndex, true);
+  };
+
+  handleMoveDown = id => {
+    const elementIndex = this.getCurrentIndex(id) + 1;
+    this._selectChild(elementIndex, false);
+  };
+
+  _selectChild (index, align_top) {
+    const container = this.node.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.conversations.last();
+
+    if (last && last.get('last_status')) {
+      this.props.onLoadMore(last.get('last_status'));
+    }
+  }, 300, { leading: true });
+
+  render () {
+    const { conversations, onLoadMore, ...other } = this.props;
+
+    return (
+      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
+        {conversations.map(item => (
+          <ConversationContainer
+            key={item.get('id')}
+            conversationId={item.get('id')}
+            onMoveUp={this.handleMoveUp}
+            onMoveDown={this.handleMoveDown}
+            scrollKey={this.props.scrollKey}
+          />
+        ))}
+      </ScrollableList>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..6385d30a4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'direct']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (path, checked) {
+    dispatch(changeSetting(['direct', ...path], checked));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
new file mode 100644
index 000000000..f5e5946e3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
@@ -0,0 +1,75 @@
+import { connect } from 'react-redux';
+import Conversation from '../components/conversation';
+import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import { replyCompose } from 'flavours/glitch/actions/compose';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/glitch/actions/statuses';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const mapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  return (state, { conversationId }) => {
+    const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+    const lastStatusId = conversation.get('last_status', null);
+
+    return {
+      accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
+      unread: conversation.get('unread'),
+      lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
+      settings: state.get('local_settings'),
+    };
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
+
+  markRead () {
+    dispatch(markConversationRead(conversationId));
+  },
+
+  reply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  delete () {
+    dispatch(deleteConversation(conversationId));
+  },
+
+  onMute (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+  onToggleHidden (status) {
+    if (status.get('hidden')) {
+      dispatch(revealStatus(status.get('id')));
+    } else {
+      dispatch(hideStatus(status.get('id')));
+    }
+  },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js
new file mode 100644
index 000000000..e10558f3a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import ConversationsList from '../components/conversations_list';
+import { expandConversations } from 'flavours/glitch/actions/conversations';
+
+const mapStateToProps = state => ({
+  conversations: state.getIn(['conversations', 'items']),
+  isLoading: state.getIn(['conversations', 'isLoading'], true),
+  hasMore: state.getIn(['conversations', 'hasMore'], false),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onLoadMore: maxId => dispatch(expandConversations({ maxId })),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.jsx b/app/javascript/flavours/glitch/features/direct_timeline/index.jsx
new file mode 100644
index 000000000..433574c3e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/index.jsx
@@ -0,0 +1,156 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
+import { connectDirectStream } from 'flavours/glitch/actions/streaming';
+import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import ConversationsListContainer from './containers/conversations_list_container';
+
+const messages = defineMessages({
+  title: { id: 'column.direct', defaultMessage: 'Direct messages' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
+  conversationsMode: state.getIn(['settings', 'direct', 'conversations']),
+});
+
+class DirectTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    conversationsMode: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECT', {}));
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  componentDidMount () {
+    const { dispatch, conversationsMode } = this.props;
+
+    dispatch(mountConversations());
+
+    if (conversationsMode) {
+      dispatch(expandConversations());
+    } else {
+      dispatch(expandDirectTimeline());
+    }
+
+    this.disconnect = dispatch(connectDirectStream());
+  }
+
+  componentDidUpdate(prevProps) {
+    const { dispatch, conversationsMode } = this.props;
+
+    if (prevProps.conversationsMode && !conversationsMode) {
+      dispatch(expandDirectTimeline());
+    } else if (!prevProps.conversationsMode && conversationsMode) {
+      dispatch(expandConversations());
+    }
+  }
+
+  componentWillUnmount () {
+    this.props.dispatch(unmountConversations());
+
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMoreTimeline = maxId => {
+    this.props.dispatch(expandDirectTimeline({ maxId }));
+  };
+
+  handleLoadMoreConversations = maxId => {
+    this.props.dispatch(expandConversations({ maxId }));
+  };
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props;
+    const pinned = !!columnId;
+
+    let contents;
+    if (conversationsMode) {
+      contents = (
+        <ConversationsListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          onLoadMore={this.handleLoadMore}
+          prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
+          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
+        />
+      );
+    } else {
+      contents = (
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          onLoadMore={this.handleLoadMoreTimeline}
+          prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
+          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
+        />
+      );
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='envelope'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        {contents}
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(DirectTimeline));
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.jsx b/app/javascript/flavours/glitch/features/directory/components/account_card.jsx
new file mode 100644
index 000000000..663710b06
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.jsx
@@ -0,0 +1,247 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import Permalink from 'flavours/glitch/components/permalink';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/initial_state';
+import ShortNumber from 'flavours/glitch/components/short_number';
+import {
+  followAccount,
+  unfollowAccount,
+  unblockAccount,
+  unmuteAccount,
+} from 'flavours/glitch/actions/accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
+  cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+  unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
+  unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onFollow(account) {
+    if (account.getIn(['relationship', 'following'])) {
+      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 if (account.getIn(['relationship', 'requested'])) {
+      if (unfollowModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+          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')));
+    }
+  },
+
+  onMute(account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    }
+  },
+
+});
+
+class AccountCard extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onDismiss: PropTypes.func,
+  };
+
+  handleMouseEnter = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-original');
+    }
+  };
+
+  handleMouseLeave = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-static');
+    }
+  };
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  };
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  };
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  };
+
+  handleEditProfile = () => {
+    window.open('/settings/profile', '_blank');
+  };
+
+  handleDismiss = (e) => {
+    const { account, onDismiss } = this.props;
+    onDismiss(account.get('id'));
+
+    e.preventDefault();
+    e.stopPropagation();
+  };
+
+  render() {
+    const { account, intl } = this.props;
+
+    let actionBtn;
+
+    if (me !== account.get('id')) {
+      if (!account.get('relationship')) { // Wait until the relationship is loaded
+        actionBtn = '';
+      } else if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = <Button className={classNames('logo-button')} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
+      } else if (account.getIn(['relationship', 'muting'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
+      } else if (account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
+      }
+    } else {
+      actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
+    }
+
+    return (
+      <div className='account-card'>
+        <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
+          <div className='account-card__header'>
+            {this.props.onDismiss && <IconButton className='media-modal__close' title={intl.formatMessage(messages.dismissSuggestion)} icon='times' onClick={this.handleDismiss} size={20} />}
+
+            <img
+              src={
+                autoPlayGif ? account.get('header') : account.get('header_static')
+              }
+              alt=''
+            />
+          </div>
+
+          <div className='account-card__title'>
+            <div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
+            <DisplayName account={account} />
+          </div>
+        </Permalink>
+
+        {account.get('note').length > 0 && (
+          <div
+            className='account-card__bio translate'
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
+          />
+        )}
+
+        <div className='account-card__actions'>
+          <div className='account-card__counters'>
+            <div className='account-card__counters__item'>
+              <ShortNumber value={account.get('statuses_count')} />
+              <small>
+                <FormattedMessage id='account.posts' defaultMessage='Posts' />
+              </small>
+            </div>
+
+            <div className='account-card__counters__item'>
+              {account.get('followers_count') < 0 ? '-' : <ShortNumber value={account.get('followers_count')} />}{' '}
+              <small>
+                <FormattedMessage
+                  id='account.followers'
+                  defaultMessage='Followers'
+                />
+              </small>
+            </div>
+
+            <div className='account-card__counters__item'>
+              <ShortNumber value={account.get('following_count')} />{' '}
+              <small>
+                <FormattedMessage
+                  id='account.following'
+                  defaultMessage='Following'
+                />
+              </small>
+            </div>
+          </div>
+
+          <div className='account-card__actions__button'>
+            {actionBtn}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard));
diff --git a/app/javascript/flavours/glitch/features/directory/index.jsx b/app/javascript/flavours/glitch/features/directory/index.jsx
new file mode 100644
index 000000000..4278a4e71
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/index.jsx
@@ -0,0 +1,178 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns';
+import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory';
+import { List as ImmutableList } from 'immutable';
+import AccountCard from './components/account_card';
+import RadioButton from 'flavours/glitch/components/radio_button';
+import LoadMore from 'flavours/glitch/components/load_more';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+  recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
+  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+  federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
+  isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+class Directory extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
+    params: PropTypes.shape({
+      order: PropTypes.string,
+      local: PropTypes.bool,
+    }),
+  };
+
+  state = {
+    order: null,
+    local: null,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
+    }
+  };
+
+  getParams = (props, state) => ({
+    order: state.order === null ? (props.params.order || 'active') : state.order,
+    local: state.local === null ? (props.params.local || false) : state.local,
+  });
+
+  handleMove = dir => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchDirectory(this.getParams(this.props, this.state)));
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    const { dispatch } = this.props;
+    const paramsOld = this.getParams(prevProps, prevState);
+    const paramsNew = this.getParams(this.props, this.state);
+
+    if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
+      dispatch(fetchDirectory(paramsNew));
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleChangeOrder = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+    } else {
+      this.setState({ order: e.target.value });
+    }
+  };
+
+  handleChangeLocal = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
+    } else {
+      this.setState({ local: e.target.value === '1' });
+    }
+  };
+
+  handleLoadMore = () => {
+    const { dispatch } = this.props;
+    dispatch(expandDirectory(this.getParams(this.props, this.state)));
+  };
+
+  render () {
+    const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
+    const { order, local }  = this.getParams(this.props, this.state);
+    const pinned = !!columnId;
+
+    const scrollableArea = (
+      <div className='scrollable'>
+        <div className='filter-form'>
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
+            <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
+          </div>
+
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
+            <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
+          </div>
+        </div>
+
+        <div className='directory__list'>
+          {isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
+            <AccountCard id={accountId} key={accountId} />
+          ))}
+        </div>
+
+        <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
+      </div>
+    );
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='address-book-o'
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        />
+
+        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Directory));
diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.jsx b/app/javascript/flavours/glitch/features/domain_blocks/index.jsx
new file mode 100644
index 000000000..1ab7c3663
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/domain_blocks/index.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import DomainContainer from '../../containers/domain_container';
+import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+});
+
+const mapStateToProps = state => ({
+  domains: state.getIn(['domain_lists', 'blocks', 'items']),
+  hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
+});
+
+class Blocks extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    hasMore: PropTypes.bool,
+    domains: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchDomainBlocks());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandDomainBlocks());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, domains, hasMore, multiColumn } = this.props;
+
+    if (!domains) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no blocked domains yet.' />;
+
+    return (
+      <Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <ScrollableList
+          scrollKey='domain_blocks'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {domains.map(domain =>
+            <DomainContainer key={domain} domain={domain} />,
+          )}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Blocks));
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji.js b/app/javascript/flavours/glitch/features/emoji/emoji.js
new file mode 100644
index 000000000..4f33200b6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji/emoji.js
@@ -0,0 +1,144 @@
+import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/initial_state';
+import unicodeMapping from './emoji_unicode_mapping_light';
+import { assetHost } from 'flavours/glitch/utils/config';
+import Trie from 'substring-trie';
+
+const trie = new Trie(Object.keys(unicodeMapping));
+
+// Convert to file names from emojis. (For different variation selector emojis)
+const emojiFilenames = (emojis) => {
+  return emojis.map(v => unicodeMapping[v].filename);
+};
+
+// Emoji requiring extra borders depending on theme
+const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲']);
+const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
+
+const emojiFilename = (filename) => {
+  const borderedEmoji = (document.body && document.body.classList.contains('skin-mastodon-light')) ? lightEmoji : darkEmoji;
+  return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
+};
+
+const emojifyTextNode = (node, customEmojis) => {
+  let str = node.textContent;
+
+  const fragment = new DocumentFragment();
+
+  for (;;) {
+    let match, i = 0;
+
+    if (customEmojis === null) {
+      while (i < str.length && (useSystemEmojiFont || !(match = trie.search(str.slice(i))))) {
+        i += str.codePointAt(i) < 65536 ? 1 : 2;
+      }
+    } else {
+      while (i < str.length && str[i] !== ':' && (useSystemEmojiFont || !(match = trie.search(str.slice(i))))) {
+        i += str.codePointAt(i) < 65536 ? 1 : 2;
+      }
+    }
+
+    let rend, replacement = null;
+    if (i === str.length) {
+      break;
+    } else if (str[i] === ':') {
+      if (!(() => {
+        rend = str.indexOf(':', i + 1) + 1;
+        if (!rend) return false; // no pair of ':'
+        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 = document.createElement('img');
+          replacement.setAttribute('draggable', false);
+          replacement.setAttribute('class', 'emojione custom-emoji');
+          replacement.setAttribute('alt', shortname);
+          replacement.setAttribute('title', shortname);
+          replacement.setAttribute('src', filename);
+          replacement.setAttribute('data-original', customEmojis[shortname].url);
+          replacement.setAttribute('data-static', customEmojis[shortname].static_url);
+          return true;
+        }
+        return false;
+      })()) rend = ++i;
+    } else if (!useSystemEmojiFont) { // matched to unicode emoji
+      const { filename, shortCode } = unicodeMapping[match];
+      const title = shortCode ? `:${shortCode}:` : '';
+      replacement = document.createElement('img');
+      replacement.setAttribute('draggable', false);
+      replacement.setAttribute('class', 'emojione');
+      replacement.setAttribute('alt', match);
+      replacement.setAttribute('title', title);
+      replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`);
+      rend = i + match.length;
+      // If the matched character was followed by VS15 (for selecting text presentation), skip it.
+      if (str.codePointAt(rend) === 65038) {
+        rend += 1;
+      }
+    }
+
+    fragment.append(document.createTextNode(str.slice(0, i)));
+    if (replacement) {
+      fragment.append(replacement);
+    }
+    str = str.slice(rend);
+  }
+
+  fragment.append(document.createTextNode(str));
+  node.parentElement.replaceChild(fragment, node);
+};
+
+const emojifyNode = (node, customEmojis) => {
+  for (const child of node.childNodes) {
+    switch(child.nodeType) {
+    case Node.TEXT_NODE:
+      emojifyTextNode(child, customEmojis);
+      break;
+    case Node.ELEMENT_NODE:
+      if (!child.classList.contains('invisible'))
+        emojifyNode(child, customEmojis);
+      break;
+    }
+  }
+};
+
+const emojify = (str, customEmojis = {}) => {
+  const wrapper = document.createElement('div');
+  wrapper.innerHTML = str;
+
+  if (!Object.keys(customEmojis).length)
+    customEmojis = null;
+
+  emojifyNode(wrapper, customEmojis);
+
+  return wrapper.innerHTML;
+};
+
+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,
+      customCategory: emoji.get('category'),
+    });
+  });
+
+  return emojis;
+};
+
+export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom']));
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_compressed.js b/app/javascript/flavours/glitch/features/emoji/emoji_compressed.js
new file mode 100644
index 000000000..74b53ce5c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji/emoji_compressed.js
@@ -0,0 +1,122 @@
+// @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 { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
+
+let data = require('emoji-mart/data/all.json');
+
+if(data.compressed) {
+  data = emojiMartUncompress(data);
+}
+
+const emojiMartData = data;
+
+const excluded       = ['®', '©', '™'];
+const skinTones      = ['🏻', '🏼', '🏽', '🏾', '🏿'];
+const shortcodeMap   = {};
+
+const shortCodesToEmojiData = {};
+const emojisWithoutShortCodes = [];
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+  let emoji = emojiIndex.emojis[key];
+
+  // Emojis with skin tone modifiers are stored like this
+  if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
+    emoji = emoji['1'];
+  }
+
+  shortcodeMap[emoji.native] = emoji.id;
+});
+
+const stripModifiers = unicode => {
+  skinTones.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 => {
+  let emoji = emojiIndex.emojis[key];
+
+  // Emojis with skin tone modifiers are stored like this
+  if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
+    emoji = emoji['1'];
+  }
+
+  const { native } = emoji;
+  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);
+  }
+
+  if (!Array.isArray(shortCodesToEmojiData[key])) {
+    shortCodesToEmojiData[key] = [[]];
+  }
+
+  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.aliases,
+  emojisWithoutShortCodes,
+]));
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_map.json b/app/javascript/flavours/glitch/features/emoji/emoji_map.json
new file mode 100644
index 000000000..64f6615b7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji/emoji_map.json
@@ -0,0 +1 @@
+{"😀":"1f600","😃":"1f603","😄":"1f604","😁":"1f601","😆":"1f606","😅":"1f605","🤣":"1f923","😂":"1f602","🙂":"1f642","🙃":"1f643","🫠":"1fae0","😉":"1f609","😊":"1f60a","😇":"1f607","🥰":"1f970","😍":"1f60d","🤩":"1f929","😘":"1f618","😗":"1f617","☺":"263a","😚":"1f61a","😙":"1f619","🥲":"1f972","😋":"1f60b","😛":"1f61b","😜":"1f61c","🤪":"1f92a","😝":"1f61d","🤑":"1f911","🤗":"1f917","🤭":"1f92d","🫢":"1fae2","🫣":"1fae3","🤫":"1f92b","🤔":"1f914","🫡":"1fae1","🤐":"1f910","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🫥":"1fae5","😏":"1f60f","😒":"1f612","🙄":"1f644","😬":"1f62c","🤥":"1f925","😌":"1f60c","😔":"1f614","😪":"1f62a","🤤":"1f924","😴":"1f634","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","🥵":"1f975","🥶":"1f976","🥴":"1f974","😵":"1f635","🤯":"1f92f","🤠":"1f920","🥳":"1f973","🥸":"1f978","😎":"1f60e","🤓":"1f913","🧐":"1f9d0","😕":"1f615","🫤":"1fae4","😟":"1f61f","🙁":"1f641","☹":"2639","😮":"1f62e","😯":"1f62f","😲":"1f632","😳":"1f633","🥺":"1f97a","🥹":"1f979","😦":"1f626","😧":"1f627","😨":"1f628","😰":"1f630","😥":"1f625","😢":"1f622","😭":"1f62d","😱":"1f631","😖":"1f616","😣":"1f623","😞":"1f61e","😓":"1f613","😩":"1f629","😫":"1f62b","🥱":"1f971","😤":"1f624","😡":"1f621","😠":"1f620","🤬":"1f92c","😈":"1f608","👿":"1f47f","💀":"1f480","☠":"2620","💩":"1f4a9","🤡":"1f921","👹":"1f479","👺":"1f47a","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","💋":"1f48b","💌":"1f48c","💘":"1f498","💝":"1f49d","💖":"1f496","💗":"1f497","💓":"1f493","💞":"1f49e","💕":"1f495","💟":"1f49f","❣":"2763","💔":"1f494","❤":"2764","🧡":"1f9e1","💛":"1f49b","💚":"1f49a","💙":"1f499","💜":"1f49c","🤎":"1f90e","🖤":"1f5a4","🤍":"1f90d","💯":"1f4af","💢":"1f4a2","💥":"1f4a5","💫":"1f4ab","💦":"1f4a6","💨":"1f4a8","🕳":"1f573","💣":"1f4a3","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","💤":"1f4a4","👋":"1f44b","🤚":"1f91a","🖐":"1f590","✋":"270b","🖖":"1f596","🫱":"1faf1","🫲":"1faf2","🫳":"1faf3","🫴":"1faf4","👌":"1f44c","🤌":"1f90c","🤏":"1f90f","✌":"270c","🤞":"1f91e","🫰":"1faf0","🤟":"1f91f","🤘":"1f918","🤙":"1f919","👈":"1f448","👉":"1f449","👆":"1f446","🖕":"1f595","👇":"1f447","☝":"261d","🫵":"1faf5","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","👏":"1f44f","🙌":"1f64c","🫶":"1faf6","👐":"1f450","🤲":"1f932","🤝":"1f91d","🙏":"1f64f","✍":"270d","💅":"1f485","🤳":"1f933","💪":"1f4aa","🦾":"1f9be","🦿":"1f9bf","🦵":"1f9b5","🦶":"1f9b6","👂":"1f442","🦻":"1f9bb","👃":"1f443","🧠":"1f9e0","🫀":"1fac0","🫁":"1fac1","🦷":"1f9b7","🦴":"1f9b4","👀":"1f440","👁":"1f441","👅":"1f445","👄":"1f444","🫦":"1fae6","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👱":"1f471","👨":"1f468","🧔":"1f9d4","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🧏":"1f9cf","🙇":"1f647","🤦":"1f926","🤷":"1f937","👮":"1f46e","🕵":"1f575","💂":"1f482","🥷":"1f977","👷":"1f477","🫅":"1fac5","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🤵":"1f935","👰":"1f470","🤰":"1f930","🫃":"1fac3","🫄":"1fac4","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🦸":"1f9b8","🦹":"1f9b9","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🧌":"1f9cc","💆":"1f486","💇":"1f487","🚶":"1f6b6","🧍":"1f9cd","🧎":"1f9ce","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","🕴":"1f574","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","👭":"1f46d","👫":"1f46b","👬":"1f46c","💏":"1f48f","💑":"1f491","👪":"1f46a","🗣":"1f5e3","👤":"1f464","👥":"1f465","🫂":"1fac2","👣":"1f463","🏻":"1f463","🏼":"1f463","🏽":"1f463","🏾":"1f463","🏿":"1f463","🦰":"1f463","🦱":"1f463","🦳":"1f463","🦲":"1f463","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🦧":"1f9a7","🐶":"1f436","🐕":"1f415","🦮":"1f9ae","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🦝":"1f99d","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🦬":"1f9ac","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦙":"1f999","🦒":"1f992","🐘":"1f418","🦣":"1f9a3","🦏":"1f98f","🦛":"1f99b","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦫":"1f9ab","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🦥":"1f9a5","🦦":"1f9a6","🦨":"1f9a8","🦘":"1f998","🦡":"1f9a1","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦢":"1f9a2","🦉":"1f989","🦤":"1f9a4","🪶":"1fab6","🦩":"1f9a9","🦚":"1f99a","🦜":"1f99c","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🦭":"1f9ad","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🪸":"1fab8","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🪲":"1fab2","🐞":"1f41e","🦗":"1f997","🪳":"1fab3","🕷":"1f577","🕸":"1f578","🦂":"1f982","🦟":"1f99f","🪰":"1fab0","🪱":"1fab1","🦠":"1f9a0","💐":"1f490","🌸":"1f338","💮":"1f4ae","🪷":"1fab7","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🪴":"1fab4","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🪹":"1fab9","🪺":"1faba","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🥭":"1f96d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🫐":"1fad0","🥝":"1f95d","🍅":"1f345","🫒":"1fad2","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🫑":"1fad1","🥒":"1f952","🥬":"1f96c","🥦":"1f966","🧄":"1f9c4","🧅":"1f9c5","🍄":"1f344","🥜":"1f95c","🫘":"1fad8","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🫓":"1fad3","🥨":"1f968","🥯":"1f96f","🥞":"1f95e","🧇":"1f9c7","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🫔":"1fad4","🥙":"1f959","🧆":"1f9c6","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🫕":"1fad5","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🧈":"1f9c8","🧂":"1f9c2","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🥮":"1f96e","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🦀":"1f980","🦞":"1f99e","🦐":"1f990","🦑":"1f991","🦪":"1f9aa","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🧁":"1f9c1","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🫖":"1fad6","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🫗":"1fad7","🥤":"1f964","🧋":"1f9cb","🧃":"1f9c3","🧉":"1f9c9","🧊":"1f9ca","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🫙":"1fad9","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🧭":"1f9ed","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🧱":"1f9f1","🪨":"1faa8","🪵":"1fab5","🛖":"1f6d6","🏘":"1f3d8","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🛕":"1f6d5","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🏙":"1f3d9","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🎠":"1f3a0","🛝":"1f6dd","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🚂":"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","🛻":"1f6fb","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🏎":"1f3ce","🏍":"1f3cd","🛵":"1f6f5","🦽":"1f9bd","🦼":"1f9bc","🛺":"1f6fa","🚲":"1f6b2","🛴":"1f6f4","🛹":"1f6f9","🛼":"1f6fc","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","🛢":"1f6e2","⛽":"26fd","🛞":"1f6de","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🛑":"1f6d1","🚧":"1f6a7","⚓":"2693","🛟":"1f6df","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","🪂":"1fa82","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🧳":"1f9f3","⌛":"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","🪐":"1fa90","⭐":"2b50","🌟":"1f31f","🌠":"1f320","🌌":"1f30c","☁":"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","🧨":"1f9e8","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🧧":"1f9e7","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🥎":"1f94e","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🥏":"1f94f","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🥍":"1f94d","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🤿":"1f93f","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎯":"1f3af","🪀":"1fa80","🪁":"1fa81","🎱":"1f3b1","🔮":"1f52e","🪄":"1fa84","🧿":"1f9ff","🪬":"1faac","🎮":"1f3ae","🕹":"1f579","🎰":"1f3b0","🎲":"1f3b2","🧩":"1f9e9","🧸":"1f9f8","🪅":"1fa85","🪩":"1faa9","🪆":"1fa86","♠":"2660","♥":"2665","♦":"2666","♣":"2663","♟":"265f","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🧵":"1f9f5","🪡":"1faa1","🧶":"1f9f6","🪢":"1faa2","👓":"1f453","🕶":"1f576","🥽":"1f97d","🥼":"1f97c","🦺":"1f9ba","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","🥻":"1f97b","🩱":"1fa71","🩲":"1fa72","🩳":"1fa73","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","🩴":"1fa74","👞":"1f45e","👟":"1f45f","🥾":"1f97e","🥿":"1f97f","👠":"1f460","👡":"1f461","🩰":"1fa70","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","🪖":"1fa96","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🪗":"1fa97","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🪕":"1fa95","🥁":"1f941","🪘":"1fa98","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🪫":"1faab","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🧮":"1f9ee","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","🪔":"1fa94","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","🪙":"1fa99","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","🧾":"1f9fe","💹":"1f4b9","✉":"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","🪓":"1fa93","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🪃":"1fa83","🏹":"1f3f9","🛡":"1f6e1","🪚":"1fa9a","🔧":"1f527","🪛":"1fa9b","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚖":"2696","🦯":"1f9af","🔗":"1f517","⛓":"26d3","🪝":"1fa9d","🧰":"1f9f0","🧲":"1f9f2","🪜":"1fa9c","⚗":"2697","🧪":"1f9ea","🧫":"1f9eb","🧬":"1f9ec","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","💉":"1f489","🩸":"1fa78","💊":"1f48a","🩹":"1fa79","🩼":"1fa7c","🩺":"1fa7a","🩻":"1fa7b","🚪":"1f6aa","🛗":"1f6d7","🪞":"1fa9e","🪟":"1fa9f","🛏":"1f6cf","🛋":"1f6cb","🪑":"1fa91","🚽":"1f6bd","🪠":"1faa0","🚿":"1f6bf","🛁":"1f6c1","🪤":"1faa4","🪒":"1fa92","🧴":"1f9f4","🧷":"1f9f7","🧹":"1f9f9","🧺":"1f9fa","🧻":"1f9fb","🪣":"1faa3","🧼":"1f9fc","🫧":"1fae7","🪥":"1faa5","🧽":"1f9fd","🧯":"1f9ef","🛒":"1f6d2","🚬":"1f6ac","⚰":"26b0","🪦":"1faa6","⚱":"26b1","🗿":"1f5ff","🪧":"1faa7","🪪":"1faaa","🏧":"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","⚧":"26a7","✖":"2716","➕":"2795","➖":"2796","➗":"2797","🟰":"1f7f0","♾":"267e","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","💱":"1f4b1","💲":"1f4b2","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","❌":"274c","❎":"274e","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","©":"a9","®":"ae","™":"2122","🔟":"1f51f","🔠":"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","🔴":"1f534","🟠":"1f7e0","🟡":"1f7e1","🟢":"1f7e2","🔵":"1f535","🟣":"1f7e3","🟤":"1f7e4","⚫":"26ab","⚪":"26aa","🟥":"1f7e5","🟧":"1f7e7","🟨":"1f7e8","🟩":"1f7e9","🟦":"1f7e6","🟪":"1f7ea","🟫":"1f7eb","⬛":"2b1b","⬜":"2b1c","◼":"25fc","◻":"25fb","◾":"25fe","◽":"25fd","▪":"25aa","▫":"25ab","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔳":"1f533","🔲":"1f532","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","❣️":"2763","❤️":"2764","🕳️":"1f573","🗨️":"1f5e8","🗯️":"1f5ef","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🫱🏻":"1faf1-1f3fb","🫱🏼":"1faf1-1f3fc","🫱🏽":"1faf1-1f3fd","🫱🏾":"1faf1-1f3fe","🫱🏿":"1faf1-1f3ff","🫲🏻":"1faf2-1f3fb","🫲🏼":"1faf2-1f3fc","🫲🏽":"1faf2-1f3fd","🫲🏾":"1faf2-1f3fe","🫲🏿":"1faf2-1f3ff","🫳🏻":"1faf3-1f3fb","🫳🏼":"1faf3-1f3fc","🫳🏽":"1faf3-1f3fd","🫳🏾":"1faf3-1f3fe","🫳🏿":"1faf3-1f3ff","🫴🏻":"1faf4-1f3fb","🫴🏼":"1faf4-1f3fc","🫴🏽":"1faf4-1f3fd","🫴🏾":"1faf4-1f3fe","🫴🏿":"1faf4-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","🤌🏻":"1f90c-1f3fb","🤌🏼":"1f90c-1f3fc","🤌🏽":"1f90c-1f3fd","🤌🏾":"1f90c-1f3fe","🤌🏿":"1f90c-1f3ff","🤏🏻":"1f90f-1f3fb","🤏🏼":"1f90f-1f3fc","🤏🏽":"1f90f-1f3fd","🤏🏾":"1f90f-1f3fe","🤏🏿":"1f90f-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🫰🏻":"1faf0-1f3fb","🫰🏼":"1faf0-1f3fc","🫰🏽":"1faf0-1f3fd","🫰🏾":"1faf0-1f3fe","🫰🏿":"1faf0-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-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","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","🫵🏻":"1faf5-1f3fb","🫵🏼":"1faf5-1f3fc","🫵🏽":"1faf5-1f3fd","🫵🏾":"1faf5-1f3fe","🫵🏿":"1faf5-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","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🫶🏻":"1faf6-1f3fb","🫶🏼":"1faf6-1f3fc","🫶🏽":"1faf6-1f3fd","🫶🏾":"1faf6-1f3fe","🫶🏿":"1faf6-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🤝🏻":"1f91d-1f3fb","🤝🏼":"1f91d-1f3fc","🤝🏽":"1f91d-1f3fd","🤝🏾":"1f91d-1f3fe","🤝🏿":"1f91d-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","🦵🏻":"1f9b5-1f3fb","🦵🏼":"1f9b5-1f3fc","🦵🏽":"1f9b5-1f3fd","🦵🏾":"1f9b5-1f3fe","🦵🏿":"1f9b5-1f3ff","🦶🏻":"1f9b6-1f3fb","🦶🏼":"1f9b6-1f3fc","🦶🏽":"1f9b6-1f3fd","🦶🏾":"1f9b6-1f3fe","🦶🏿":"1f9b6-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","🦻🏻":"1f9bb-1f3fb","🦻🏼":"1f9bb-1f3fc","🦻🏽":"1f9bb-1f3fd","🦻🏾":"1f9bb-1f3fe","🦻🏿":"1f9bb-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","👶🏻":"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","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-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","🙍🏻":"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","🧏🏻":"1f9cf-1f3fb","🧏🏼":"1f9cf-1f3fc","🧏🏽":"1f9cf-1f3fd","🧏🏾":"1f9cf-1f3fe","🧏🏿":"1f9cf-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","👮🏻":"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","🥷🏻":"1f977-1f3fb","🥷🏼":"1f977-1f3fc","🥷🏽":"1f977-1f3fd","🥷🏾":"1f977-1f3fe","🥷🏿":"1f977-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🫅🏻":"1fac5-1f3fb","🫅🏼":"1fac5-1f3fc","🫅🏽":"1fac5-1f3fd","🫅🏾":"1fac5-1f3fe","🫅🏿":"1fac5-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","🤵🏻":"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","🫃🏻":"1fac3-1f3fb","🫃🏼":"1fac3-1f3fc","🫃🏽":"1fac3-1f3fd","🫃🏾":"1fac3-1f3fe","🫃🏿":"1fac3-1f3ff","🫄🏻":"1fac4-1f3fb","🫄🏼":"1fac4-1f3fc","🫄🏽":"1fac4-1f3fd","🫄🏾":"1fac4-1f3fe","🫄🏿":"1fac4-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","🦸🏻":"1f9b8-1f3fb","🦸🏼":"1f9b8-1f3fc","🦸🏽":"1f9b8-1f3fd","🦸🏾":"1f9b8-1f3fe","🦸🏿":"1f9b8-1f3ff","🦹🏻":"1f9b9-1f3fb","🦹🏼":"1f9b9-1f3fc","🦹🏽":"1f9b9-1f3fd","🦹🏾":"1f9b9-1f3fe","🦹🏿":"1f9b9-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","💆🏻":"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","🧍🏻":"1f9cd-1f3fb","🧍🏼":"1f9cd-1f3fc","🧍🏽":"1f9cd-1f3fd","🧍🏾":"1f9cd-1f3fe","🧍🏿":"1f9cd-1f3ff","🧎🏻":"1f9ce-1f3fb","🧎🏼":"1f9ce-1f3fc","🧎🏽":"1f9ce-1f3fd","🧎🏾":"1f9ce-1f3fe","🧎🏿":"1f9ce-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","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🏇🏻":"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","🤸🏻":"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","🧘🏻":"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","👭🏻":"1f46d-1f3fb","👭🏼":"1f46d-1f3fc","👭🏽":"1f46d-1f3fd","👭🏾":"1f46d-1f3fe","👭🏿":"1f46d-1f3ff","👫🏻":"1f46b-1f3fb","👫🏼":"1f46b-1f3fc","👫🏽":"1f46b-1f3fd","👫🏾":"1f46b-1f3fe","👫🏿":"1f46b-1f3ff","👬🏻":"1f46c-1f3fb","👬🏼":"1f46c-1f3fc","👬🏽":"1f46c-1f3fd","👬🏾":"1f46c-1f3fe","👬🏿":"1f46c-1f3ff","💏🏻":"1f48f-1f3fb","💏🏼":"1f48f-1f3fc","💏🏽":"1f48f-1f3fd","💏🏾":"1f48f-1f3fe","💏🏿":"1f48f-1f3ff","💑🏻":"1f491-1f3fb","💑🏼":"1f491-1f3fc","💑🏽":"1f491-1f3fd","💑🏾":"1f491-1f3fe","💑🏿":"1f491-1f3ff","🗣️":"1f5e3","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏚️":"1f3da","⛩️":"26e9","🏙️":"1f3d9","♨️":"2668","🏎️":"1f3ce","🏍️":"1f3cd","🛣️":"1f6e3","🛤️":"1f6e4","🛢️":"1f6e2","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","⏱️":"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","♟️":"265f","🖼️":"1f5bc","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🎙️":"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","⚖️":"2696","⛓️":"26d3","⚗️":"2697","🛏️":"1f6cf","🛋️":"1f6cb","⚰️":"26b0","⚱️":"26b1","⚠️":"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","⚧️":"26a7","✖️":"2716","♾️":"267e","‼️":"203c","⁉️":"2049","〰️":"3030","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","©️":"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","◼️":"25fc","◻️":"25fb","▪️":"25aa","▫️":"25ab","🏳️":"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","😶‍🌫":"1f636-200d-1f32b-fe0f","😮‍💨":"1f62e-200d-1f4a8","😵‍💫":"1f635-200d-1f4ab","❤‍🔥":"2764-fe0f-200d-1f525","❤‍🩹":"2764-fe0f-200d-1fa79","👁‍🗨":"1f441-200d-1f5e8","🧔‍♂":"1f9d4-200d-2642-fe0f","🧔‍♀":"1f9d4-200d-2640-fe0f","👨‍🦰":"1f468-200d-1f9b0","👨‍🦱":"1f468-200d-1f9b1","👨‍🦳":"1f468-200d-1f9b3","👨‍🦲":"1f468-200d-1f9b2","👩‍🦰":"1f469-200d-1f9b0","🧑‍🦰":"1f9d1-200d-1f9b0","👩‍🦱":"1f469-200d-1f9b1","🧑‍🦱":"1f9d1-200d-1f9b1","👩‍🦳":"1f469-200d-1f9b3","🧑‍🦳":"1f9d1-200d-1f9b3","👩‍🦲":"1f469-200d-1f9b2","🧑‍🦲":"1f9d1-200d-1f9b2","👱‍♀":"1f471-200d-2640-fe0f","👱‍♂":"1f471-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","🧏‍♂":"1f9cf-200d-2642-fe0f","🧏‍♀":"1f9cf-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","🧑‍⚕":"1f9d1-200d-2695-fe0f","👨‍⚕":"1f468-200d-2695-fe0f","👩‍⚕":"1f469-200d-2695-fe0f","🧑‍🎓":"1f9d1-200d-1f393","👨‍🎓":"1f468-200d-1f393","👩‍🎓":"1f469-200d-1f393","🧑‍🏫":"1f9d1-200d-1f3eb","👨‍🏫":"1f468-200d-1f3eb","👩‍🏫":"1f469-200d-1f3eb","🧑‍⚖":"1f9d1-200d-2696-fe0f","👨‍⚖":"1f468-200d-2696-fe0f","👩‍⚖":"1f469-200d-2696-fe0f","🧑‍🌾":"1f9d1-200d-1f33e","👨‍🌾":"1f468-200d-1f33e","👩‍🌾":"1f469-200d-1f33e","🧑‍🍳":"1f9d1-200d-1f373","👨‍🍳":"1f468-200d-1f373","👩‍🍳":"1f469-200d-1f373","🧑‍🔧":"1f9d1-200d-1f527","👨‍🔧":"1f468-200d-1f527","👩‍🔧":"1f469-200d-1f527","🧑‍🏭":"1f9d1-200d-1f3ed","👨‍🏭":"1f468-200d-1f3ed","👩‍🏭":"1f469-200d-1f3ed","🧑‍💼":"1f9d1-200d-1f4bc","👨‍💼":"1f468-200d-1f4bc","👩‍💼":"1f469-200d-1f4bc","🧑‍🔬":"1f9d1-200d-1f52c","👨‍🔬":"1f468-200d-1f52c","👩‍🔬":"1f469-200d-1f52c","🧑‍💻":"1f9d1-200d-1f4bb","👨‍💻":"1f468-200d-1f4bb","👩‍💻":"1f469-200d-1f4bb","🧑‍🎤":"1f9d1-200d-1f3a4","👨‍🎤":"1f468-200d-1f3a4","👩‍🎤":"1f469-200d-1f3a4","🧑‍🎨":"1f9d1-200d-1f3a8","👨‍🎨":"1f468-200d-1f3a8","👩‍🎨":"1f469-200d-1f3a8","🧑‍✈":"1f9d1-200d-2708-fe0f","👨‍✈":"1f468-200d-2708-fe0f","👩‍✈":"1f469-200d-2708-fe0f","🧑‍🚀":"1f9d1-200d-1f680","👨‍🚀":"1f468-200d-1f680","👩‍🚀":"1f469-200d-1f680","🧑‍🚒":"1f9d1-200d-1f692","👨‍🚒":"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","🤵‍♂":"1f935-200d-2642-fe0f","🤵‍♀":"1f935-200d-2640-fe0f","👰‍♂":"1f470-200d-2642-fe0f","👰‍♀":"1f470-200d-2640-fe0f","👩‍🍼":"1f469-200d-1f37c","👨‍🍼":"1f468-200d-1f37c","🧑‍🍼":"1f9d1-200d-1f37c","🧑‍🎄":"1f9d1-200d-1f384","🦸‍♂":"1f9b8-200d-2642-fe0f","🦸‍♀":"1f9b8-200d-2640-fe0f","🦹‍♂":"1f9b9-200d-2642-fe0f","🦹‍♀":"1f9b9-200d-2640-fe0f","🧙‍♂":"1f9d9-200d-2642-fe0f","🧙‍♀":"1f9d9-200d-2640-fe0f","🧚‍♂":"1f9da-200d-2642-fe0f","🧚‍♀":"1f9da-200d-2640-fe0f","🧛‍♂":"1f9db-200d-2642-fe0f","🧛‍♀":"1f9db-200d-2640-fe0f","🧜‍♂":"1f9dc-200d-2642-fe0f","🧜‍♀":"1f9dc-200d-2640-fe0f","🧝‍♂":"1f9dd-200d-2642-fe0f","🧝‍♀":"1f9dd-200d-2640-fe0f","🧞‍♂":"1f9de-200d-2642-fe0f","🧞‍♀":"1f9de-200d-2640-fe0f","🧟‍♂":"1f9df-200d-2642-fe0f","🧟‍♀":"1f9df-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","🧍‍♂":"1f9cd-200d-2642-fe0f","🧍‍♀":"1f9cd-200d-2640-fe0f","🧎‍♂":"1f9ce-200d-2642-fe0f","🧎‍♀":"1f9ce-200d-2640-fe0f","🧑‍🦯":"1f9d1-200d-1f9af","👨‍🦯":"1f468-200d-1f9af","👩‍🦯":"1f469-200d-1f9af","🧑‍🦼":"1f9d1-200d-1f9bc","👨‍🦼":"1f468-200d-1f9bc","👩‍🦼":"1f469-200d-1f9bc","🧑‍🦽":"1f9d1-200d-1f9bd","👨‍🦽":"1f468-200d-1f9bd","👩‍🦽":"1f469-200d-1f9bd","🏃‍♂":"1f3c3-200d-2642-fe0f","🏃‍♀":"1f3c3-200d-2640-fe0f","👯‍♂":"1f46f-200d-2642-fe0f","👯‍♀":"1f46f-200d-2640-fe0f","🧖‍♂":"1f9d6-200d-2642-fe0f","🧖‍♀":"1f9d6-200d-2640-fe0f","🧗‍♂":"1f9d7-200d-2642-fe0f","🧗‍♀":"1f9d7-200d-2640-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","🧘‍♂":"1f9d8-200d-2642-fe0f","🧘‍♀":"1f9d8-200d-2640-fe0f","👨‍👦":"1f468-200d-1f466","👨‍👧":"1f468-200d-1f467","👩‍👦":"1f469-200d-1f466","👩‍👧":"1f469-200d-1f467","🐕‍🦺":"1f415-200d-1f9ba","🐈‍⬛":"1f408-200d-2b1b","🐻‍❄":"1f43b-200d-2744-fe0f","#️⃣":"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","🏳‍⚧":"1f3f3-fe0f-200d-26a7-fe0f","🏴‍☠":"1f3f4-200d-2620-fe0f","😶‍🌫️":"1f636-200d-1f32b-fe0f","❤️‍🔥":"2764-fe0f-200d-1f525","❤️‍🩹":"2764-fe0f-200d-1fa79","👁‍🗨️":"1f441-200d-1f5e8","👁️‍🗨":"1f441-200d-1f5e8","🧔‍♂️":"1f9d4-200d-2642-fe0f","🧔🏻‍♂":"1f9d4-1f3fb-200d-2642-fe0f","🧔🏼‍♂":"1f9d4-1f3fc-200d-2642-fe0f","🧔🏽‍♂":"1f9d4-1f3fd-200d-2642-fe0f","🧔🏾‍♂":"1f9d4-1f3fe-200d-2642-fe0f","🧔🏿‍♂":"1f9d4-1f3ff-200d-2642-fe0f","🧔‍♀️":"1f9d4-200d-2640-fe0f","🧔🏻‍♀":"1f9d4-1f3fb-200d-2640-fe0f","🧔🏼‍♀":"1f9d4-1f3fc-200d-2640-fe0f","🧔🏽‍♀":"1f9d4-1f3fd-200d-2640-fe0f","🧔🏾‍♀":"1f9d4-1f3fe-200d-2640-fe0f","🧔🏿‍♀":"1f9d4-1f3ff-200d-2640-fe0f","👨🏻‍🦰":"1f468-1f3fb-200d-1f9b0","👨🏼‍🦰":"1f468-1f3fc-200d-1f9b0","👨🏽‍🦰":"1f468-1f3fd-200d-1f9b0","👨🏾‍🦰":"1f468-1f3fe-200d-1f9b0","👨🏿‍🦰":"1f468-1f3ff-200d-1f9b0","👨🏻‍🦱":"1f468-1f3fb-200d-1f9b1","👨🏼‍🦱":"1f468-1f3fc-200d-1f9b1","👨🏽‍🦱":"1f468-1f3fd-200d-1f9b1","👨🏾‍🦱":"1f468-1f3fe-200d-1f9b1","👨🏿‍🦱":"1f468-1f3ff-200d-1f9b1","👨🏻‍🦳":"1f468-1f3fb-200d-1f9b3","👨🏼‍🦳":"1f468-1f3fc-200d-1f9b3","👨🏽‍🦳":"1f468-1f3fd-200d-1f9b3","👨🏾‍🦳":"1f468-1f3fe-200d-1f9b3","👨🏿‍🦳":"1f468-1f3ff-200d-1f9b3","👨🏻‍🦲":"1f468-1f3fb-200d-1f9b2","👨🏼‍🦲":"1f468-1f3fc-200d-1f9b2","👨🏽‍🦲":"1f468-1f3fd-200d-1f9b2","👨🏾‍🦲":"1f468-1f3fe-200d-1f9b2","👨🏿‍🦲":"1f468-1f3ff-200d-1f9b2","👩🏻‍🦰":"1f469-1f3fb-200d-1f9b0","👩🏼‍🦰":"1f469-1f3fc-200d-1f9b0","👩🏽‍🦰":"1f469-1f3fd-200d-1f9b0","👩🏾‍🦰":"1f469-1f3fe-200d-1f9b0","👩🏿‍🦰":"1f469-1f3ff-200d-1f9b0","🧑🏻‍🦰":"1f9d1-1f3fb-200d-1f9b0","🧑🏼‍🦰":"1f9d1-1f3fc-200d-1f9b0","🧑🏽‍🦰":"1f9d1-1f3fd-200d-1f9b0","🧑🏾‍🦰":"1f9d1-1f3fe-200d-1f9b0","🧑🏿‍🦰":"1f9d1-1f3ff-200d-1f9b0","👩🏻‍🦱":"1f469-1f3fb-200d-1f9b1","👩🏼‍🦱":"1f469-1f3fc-200d-1f9b1","👩🏽‍🦱":"1f469-1f3fd-200d-1f9b1","👩🏾‍🦱":"1f469-1f3fe-200d-1f9b1","👩🏿‍🦱":"1f469-1f3ff-200d-1f9b1","🧑🏻‍🦱":"1f9d1-1f3fb-200d-1f9b1","🧑🏼‍🦱":"1f9d1-1f3fc-200d-1f9b1","🧑🏽‍🦱":"1f9d1-1f3fd-200d-1f9b1","🧑🏾‍🦱":"1f9d1-1f3fe-200d-1f9b1","🧑🏿‍🦱":"1f9d1-1f3ff-200d-1f9b1","👩🏻‍🦳":"1f469-1f3fb-200d-1f9b3","👩🏼‍🦳":"1f469-1f3fc-200d-1f9b3","👩🏽‍🦳":"1f469-1f3fd-200d-1f9b3","👩🏾‍🦳":"1f469-1f3fe-200d-1f9b3","👩🏿‍🦳":"1f469-1f3ff-200d-1f9b3","🧑🏻‍🦳":"1f9d1-1f3fb-200d-1f9b3","🧑🏼‍🦳":"1f9d1-1f3fc-200d-1f9b3","🧑🏽‍🦳":"1f9d1-1f3fd-200d-1f9b3","🧑🏾‍🦳":"1f9d1-1f3fe-200d-1f9b3","🧑🏿‍🦳":"1f9d1-1f3ff-200d-1f9b3","👩🏻‍🦲":"1f469-1f3fb-200d-1f9b2","👩🏼‍🦲":"1f469-1f3fc-200d-1f9b2","👩🏽‍🦲":"1f469-1f3fd-200d-1f9b2","👩🏾‍🦲":"1f469-1f3fe-200d-1f9b2","👩🏿‍🦲":"1f469-1f3ff-200d-1f9b2","🧑🏻‍🦲":"1f9d1-1f3fb-200d-1f9b2","🧑🏼‍🦲":"1f9d1-1f3fc-200d-1f9b2","🧑🏽‍🦲":"1f9d1-1f3fd-200d-1f9b2","🧑🏾‍🦲":"1f9d1-1f3fe-200d-1f9b2","🧑🏿‍🦲":"1f9d1-1f3ff-200d-1f9b2","👱‍♀️":"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","👱‍♂️":"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","🙍‍♂️":"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","🧏‍♂️":"1f9cf-200d-2642-fe0f","🧏🏻‍♂":"1f9cf-1f3fb-200d-2642-fe0f","🧏🏼‍♂":"1f9cf-1f3fc-200d-2642-fe0f","🧏🏽‍♂":"1f9cf-1f3fd-200d-2642-fe0f","🧏🏾‍♂":"1f9cf-1f3fe-200d-2642-fe0f","🧏🏿‍♂":"1f9cf-1f3ff-200d-2642-fe0f","🧏‍♀️":"1f9cf-200d-2640-fe0f","🧏🏻‍♀":"1f9cf-1f3fb-200d-2640-fe0f","🧏🏼‍♀":"1f9cf-1f3fc-200d-2640-fe0f","🧏🏽‍♀":"1f9cf-1f3fd-200d-2640-fe0f","🧏🏾‍♀":"1f9cf-1f3fe-200d-2640-fe0f","🧏🏿‍♀":"1f9cf-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","🧑‍⚕️":"1f9d1-200d-2695-fe0f","🧑🏻‍⚕":"1f9d1-1f3fb-200d-2695-fe0f","🧑🏼‍⚕":"1f9d1-1f3fc-200d-2695-fe0f","🧑🏽‍⚕":"1f9d1-1f3fd-200d-2695-fe0f","🧑🏾‍⚕":"1f9d1-1f3fe-200d-2695-fe0f","🧑🏿‍⚕":"1f9d1-1f3ff-200d-2695-fe0f","👨‍⚕️":"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","🧑🏻‍🎓":"1f9d1-1f3fb-200d-1f393","🧑🏼‍🎓":"1f9d1-1f3fc-200d-1f393","🧑🏽‍🎓":"1f9d1-1f3fd-200d-1f393","🧑🏾‍🎓":"1f9d1-1f3fe-200d-1f393","🧑🏿‍🎓":"1f9d1-1f3ff-200d-1f393","👨🏻‍🎓":"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","🧑🏻‍🏫":"1f9d1-1f3fb-200d-1f3eb","🧑🏼‍🏫":"1f9d1-1f3fc-200d-1f3eb","🧑🏽‍🏫":"1f9d1-1f3fd-200d-1f3eb","🧑🏾‍🏫":"1f9d1-1f3fe-200d-1f3eb","🧑🏿‍🏫":"1f9d1-1f3ff-200d-1f3eb","👨🏻‍🏫":"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","🧑‍⚖️":"1f9d1-200d-2696-fe0f","🧑🏻‍⚖":"1f9d1-1f3fb-200d-2696-fe0f","🧑🏼‍⚖":"1f9d1-1f3fc-200d-2696-fe0f","🧑🏽‍⚖":"1f9d1-1f3fd-200d-2696-fe0f","🧑🏾‍⚖":"1f9d1-1f3fe-200d-2696-fe0f","🧑🏿‍⚖":"1f9d1-1f3ff-200d-2696-fe0f","👨‍⚖️":"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","🧑🏻‍🌾":"1f9d1-1f3fb-200d-1f33e","🧑🏼‍🌾":"1f9d1-1f3fc-200d-1f33e","🧑🏽‍🌾":"1f9d1-1f3fd-200d-1f33e","🧑🏾‍🌾":"1f9d1-1f3fe-200d-1f33e","🧑🏿‍🌾":"1f9d1-1f3ff-200d-1f33e","👨🏻‍🌾":"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","🧑🏻‍🍳":"1f9d1-1f3fb-200d-1f373","🧑🏼‍🍳":"1f9d1-1f3fc-200d-1f373","🧑🏽‍🍳":"1f9d1-1f3fd-200d-1f373","🧑🏾‍🍳":"1f9d1-1f3fe-200d-1f373","🧑🏿‍🍳":"1f9d1-1f3ff-200d-1f373","👨🏻‍🍳":"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","🧑🏻‍🔧":"1f9d1-1f3fb-200d-1f527","🧑🏼‍🔧":"1f9d1-1f3fc-200d-1f527","🧑🏽‍🔧":"1f9d1-1f3fd-200d-1f527","🧑🏾‍🔧":"1f9d1-1f3fe-200d-1f527","🧑🏿‍🔧":"1f9d1-1f3ff-200d-1f527","👨🏻‍🔧":"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","🧑🏻‍🏭":"1f9d1-1f3fb-200d-1f3ed","🧑🏼‍🏭":"1f9d1-1f3fc-200d-1f3ed","🧑🏽‍🏭":"1f9d1-1f3fd-200d-1f3ed","🧑🏾‍🏭":"1f9d1-1f3fe-200d-1f3ed","🧑🏿‍🏭":"1f9d1-1f3ff-200d-1f3ed","👨🏻‍🏭":"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","🧑🏻‍💼":"1f9d1-1f3fb-200d-1f4bc","🧑🏼‍💼":"1f9d1-1f3fc-200d-1f4bc","🧑🏽‍💼":"1f9d1-1f3fd-200d-1f4bc","🧑🏾‍💼":"1f9d1-1f3fe-200d-1f4bc","🧑🏿‍💼":"1f9d1-1f3ff-200d-1f4bc","👨🏻‍💼":"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","🧑🏻‍🔬":"1f9d1-1f3fb-200d-1f52c","🧑🏼‍🔬":"1f9d1-1f3fc-200d-1f52c","🧑🏽‍🔬":"1f9d1-1f3fd-200d-1f52c","🧑🏾‍🔬":"1f9d1-1f3fe-200d-1f52c","🧑🏿‍🔬":"1f9d1-1f3ff-200d-1f52c","👨🏻‍🔬":"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","🧑🏻‍💻":"1f9d1-1f3fb-200d-1f4bb","🧑🏼‍💻":"1f9d1-1f3fc-200d-1f4bb","🧑🏽‍💻":"1f9d1-1f3fd-200d-1f4bb","🧑🏾‍💻":"1f9d1-1f3fe-200d-1f4bb","🧑🏿‍💻":"1f9d1-1f3ff-200d-1f4bb","👨🏻‍💻":"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","🧑🏻‍🎤":"1f9d1-1f3fb-200d-1f3a4","🧑🏼‍🎤":"1f9d1-1f3fc-200d-1f3a4","🧑🏽‍🎤":"1f9d1-1f3fd-200d-1f3a4","🧑🏾‍🎤":"1f9d1-1f3fe-200d-1f3a4","🧑🏿‍🎤":"1f9d1-1f3ff-200d-1f3a4","👨🏻‍🎤":"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","🧑🏻‍🎨":"1f9d1-1f3fb-200d-1f3a8","🧑🏼‍🎨":"1f9d1-1f3fc-200d-1f3a8","🧑🏽‍🎨":"1f9d1-1f3fd-200d-1f3a8","🧑🏾‍🎨":"1f9d1-1f3fe-200d-1f3a8","🧑🏿‍🎨":"1f9d1-1f3ff-200d-1f3a8","👨🏻‍🎨":"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","🧑‍✈️":"1f9d1-200d-2708-fe0f","🧑🏻‍✈":"1f9d1-1f3fb-200d-2708-fe0f","🧑🏼‍✈":"1f9d1-1f3fc-200d-2708-fe0f","🧑🏽‍✈":"1f9d1-1f3fd-200d-2708-fe0f","🧑🏾‍✈":"1f9d1-1f3fe-200d-2708-fe0f","🧑🏿‍✈":"1f9d1-1f3ff-200d-2708-fe0f","👨‍✈️":"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","🧑🏻‍🚀":"1f9d1-1f3fb-200d-1f680","🧑🏼‍🚀":"1f9d1-1f3fc-200d-1f680","🧑🏽‍🚀":"1f9d1-1f3fd-200d-1f680","🧑🏾‍🚀":"1f9d1-1f3fe-200d-1f680","🧑🏿‍🚀":"1f9d1-1f3ff-200d-1f680","👨🏻‍🚀":"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","🧑🏻‍🚒":"1f9d1-1f3fb-200d-1f692","🧑🏼‍🚒":"1f9d1-1f3fc-200d-1f692","🧑🏽‍🚒":"1f9d1-1f3fd-200d-1f692","🧑🏾‍🚒":"1f9d1-1f3fe-200d-1f692","🧑🏿‍🚒":"1f9d1-1f3ff-200d-1f692","👨🏻‍🚒":"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","🤵‍♂️":"1f935-200d-2642-fe0f","🤵🏻‍♂":"1f935-1f3fb-200d-2642-fe0f","🤵🏼‍♂":"1f935-1f3fc-200d-2642-fe0f","🤵🏽‍♂":"1f935-1f3fd-200d-2642-fe0f","🤵🏾‍♂":"1f935-1f3fe-200d-2642-fe0f","🤵🏿‍♂":"1f935-1f3ff-200d-2642-fe0f","🤵‍♀️":"1f935-200d-2640-fe0f","🤵🏻‍♀":"1f935-1f3fb-200d-2640-fe0f","🤵🏼‍♀":"1f935-1f3fc-200d-2640-fe0f","🤵🏽‍♀":"1f935-1f3fd-200d-2640-fe0f","🤵🏾‍♀":"1f935-1f3fe-200d-2640-fe0f","🤵🏿‍♀":"1f935-1f3ff-200d-2640-fe0f","👰‍♂️":"1f470-200d-2642-fe0f","👰🏻‍♂":"1f470-1f3fb-200d-2642-fe0f","👰🏼‍♂":"1f470-1f3fc-200d-2642-fe0f","👰🏽‍♂":"1f470-1f3fd-200d-2642-fe0f","👰🏾‍♂":"1f470-1f3fe-200d-2642-fe0f","👰🏿‍♂":"1f470-1f3ff-200d-2642-fe0f","👰‍♀️":"1f470-200d-2640-fe0f","👰🏻‍♀":"1f470-1f3fb-200d-2640-fe0f","👰🏼‍♀":"1f470-1f3fc-200d-2640-fe0f","👰🏽‍♀":"1f470-1f3fd-200d-2640-fe0f","👰🏾‍♀":"1f470-1f3fe-200d-2640-fe0f","👰🏿‍♀":"1f470-1f3ff-200d-2640-fe0f","👩🏻‍🍼":"1f469-1f3fb-200d-1f37c","👩🏼‍🍼":"1f469-1f3fc-200d-1f37c","👩🏽‍🍼":"1f469-1f3fd-200d-1f37c","👩🏾‍🍼":"1f469-1f3fe-200d-1f37c","👩🏿‍🍼":"1f469-1f3ff-200d-1f37c","👨🏻‍🍼":"1f468-1f3fb-200d-1f37c","👨🏼‍🍼":"1f468-1f3fc-200d-1f37c","👨🏽‍🍼":"1f468-1f3fd-200d-1f37c","👨🏾‍🍼":"1f468-1f3fe-200d-1f37c","👨🏿‍🍼":"1f468-1f3ff-200d-1f37c","🧑🏻‍🍼":"1f9d1-1f3fb-200d-1f37c","🧑🏼‍🍼":"1f9d1-1f3fc-200d-1f37c","🧑🏽‍🍼":"1f9d1-1f3fd-200d-1f37c","🧑🏾‍🍼":"1f9d1-1f3fe-200d-1f37c","🧑🏿‍🍼":"1f9d1-1f3ff-200d-1f37c","🧑🏻‍🎄":"1f9d1-1f3fb-200d-1f384","🧑🏼‍🎄":"1f9d1-1f3fc-200d-1f384","🧑🏽‍🎄":"1f9d1-1f3fd-200d-1f384","🧑🏾‍🎄":"1f9d1-1f3fe-200d-1f384","🧑🏿‍🎄":"1f9d1-1f3ff-200d-1f384","🦸‍♂️":"1f9b8-200d-2642-fe0f","🦸🏻‍♂":"1f9b8-1f3fb-200d-2642-fe0f","🦸🏼‍♂":"1f9b8-1f3fc-200d-2642-fe0f","🦸🏽‍♂":"1f9b8-1f3fd-200d-2642-fe0f","🦸🏾‍♂":"1f9b8-1f3fe-200d-2642-fe0f","🦸🏿‍♂":"1f9b8-1f3ff-200d-2642-fe0f","🦸‍♀️":"1f9b8-200d-2640-fe0f","🦸🏻‍♀":"1f9b8-1f3fb-200d-2640-fe0f","🦸🏼‍♀":"1f9b8-1f3fc-200d-2640-fe0f","🦸🏽‍♀":"1f9b8-1f3fd-200d-2640-fe0f","🦸🏾‍♀":"1f9b8-1f3fe-200d-2640-fe0f","🦸🏿‍♀":"1f9b8-1f3ff-200d-2640-fe0f","🦹‍♂️":"1f9b9-200d-2642-fe0f","🦹🏻‍♂":"1f9b9-1f3fb-200d-2642-fe0f","🦹🏼‍♂":"1f9b9-1f3fc-200d-2642-fe0f","🦹🏽‍♂":"1f9b9-1f3fd-200d-2642-fe0f","🦹🏾‍♂":"1f9b9-1f3fe-200d-2642-fe0f","🦹🏿‍♂":"1f9b9-1f3ff-200d-2642-fe0f","🦹‍♀️":"1f9b9-200d-2640-fe0f","🦹🏻‍♀":"1f9b9-1f3fb-200d-2640-fe0f","🦹🏼‍♀":"1f9b9-1f3fc-200d-2640-fe0f","🦹🏽‍♀":"1f9b9-1f3fd-200d-2640-fe0f","🦹🏾‍♀":"1f9b9-1f3fe-200d-2640-fe0f","🦹🏿‍♀":"1f9b9-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","🧙‍♀️":"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","🧚‍♂️":"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","🧚‍♀️":"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","🧛‍♂️":"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","🧛‍♀️":"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","🧜‍♂️":"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","🧜‍♀️":"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","🧝‍♂️":"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","🧝‍♀️":"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","🧞‍♂️":"1f9de-200d-2642-fe0f","🧞‍♀️":"1f9de-200d-2640-fe0f","🧟‍♂️":"1f9df-200d-2642-fe0f","🧟‍♀️":"1f9df-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","🧍‍♂️":"1f9cd-200d-2642-fe0f","🧍🏻‍♂":"1f9cd-1f3fb-200d-2642-fe0f","🧍🏼‍♂":"1f9cd-1f3fc-200d-2642-fe0f","🧍🏽‍♂":"1f9cd-1f3fd-200d-2642-fe0f","🧍🏾‍♂":"1f9cd-1f3fe-200d-2642-fe0f","🧍🏿‍♂":"1f9cd-1f3ff-200d-2642-fe0f","🧍‍♀️":"1f9cd-200d-2640-fe0f","🧍🏻‍♀":"1f9cd-1f3fb-200d-2640-fe0f","🧍🏼‍♀":"1f9cd-1f3fc-200d-2640-fe0f","🧍🏽‍♀":"1f9cd-1f3fd-200d-2640-fe0f","🧍🏾‍♀":"1f9cd-1f3fe-200d-2640-fe0f","🧍🏿‍♀":"1f9cd-1f3ff-200d-2640-fe0f","🧎‍♂️":"1f9ce-200d-2642-fe0f","🧎🏻‍♂":"1f9ce-1f3fb-200d-2642-fe0f","🧎🏼‍♂":"1f9ce-1f3fc-200d-2642-fe0f","🧎🏽‍♂":"1f9ce-1f3fd-200d-2642-fe0f","🧎🏾‍♂":"1f9ce-1f3fe-200d-2642-fe0f","🧎🏿‍♂":"1f9ce-1f3ff-200d-2642-fe0f","🧎‍♀️":"1f9ce-200d-2640-fe0f","🧎🏻‍♀":"1f9ce-1f3fb-200d-2640-fe0f","🧎🏼‍♀":"1f9ce-1f3fc-200d-2640-fe0f","🧎🏽‍♀":"1f9ce-1f3fd-200d-2640-fe0f","🧎🏾‍♀":"1f9ce-1f3fe-200d-2640-fe0f","🧎🏿‍♀":"1f9ce-1f3ff-200d-2640-fe0f","🧑🏻‍🦯":"1f9d1-1f3fb-200d-1f9af","🧑🏼‍🦯":"1f9d1-1f3fc-200d-1f9af","🧑🏽‍🦯":"1f9d1-1f3fd-200d-1f9af","🧑🏾‍🦯":"1f9d1-1f3fe-200d-1f9af","🧑🏿‍🦯":"1f9d1-1f3ff-200d-1f9af","👨🏻‍🦯":"1f468-1f3fb-200d-1f9af","👨🏼‍🦯":"1f468-1f3fc-200d-1f9af","👨🏽‍🦯":"1f468-1f3fd-200d-1f9af","👨🏾‍🦯":"1f468-1f3fe-200d-1f9af","👨🏿‍🦯":"1f468-1f3ff-200d-1f9af","👩🏻‍🦯":"1f469-1f3fb-200d-1f9af","👩🏼‍🦯":"1f469-1f3fc-200d-1f9af","👩🏽‍🦯":"1f469-1f3fd-200d-1f9af","👩🏾‍🦯":"1f469-1f3fe-200d-1f9af","👩🏿‍🦯":"1f469-1f3ff-200d-1f9af","🧑🏻‍🦼":"1f9d1-1f3fb-200d-1f9bc","🧑🏼‍🦼":"1f9d1-1f3fc-200d-1f9bc","🧑🏽‍🦼":"1f9d1-1f3fd-200d-1f9bc","🧑🏾‍🦼":"1f9d1-1f3fe-200d-1f9bc","🧑🏿‍🦼":"1f9d1-1f3ff-200d-1f9bc","👨🏻‍🦼":"1f468-1f3fb-200d-1f9bc","👨🏼‍🦼":"1f468-1f3fc-200d-1f9bc","👨🏽‍🦼":"1f468-1f3fd-200d-1f9bc","👨🏾‍🦼":"1f468-1f3fe-200d-1f9bc","👨🏿‍🦼":"1f468-1f3ff-200d-1f9bc","👩🏻‍🦼":"1f469-1f3fb-200d-1f9bc","👩🏼‍🦼":"1f469-1f3fc-200d-1f9bc","👩🏽‍🦼":"1f469-1f3fd-200d-1f9bc","👩🏾‍🦼":"1f469-1f3fe-200d-1f9bc","👩🏿‍🦼":"1f469-1f3ff-200d-1f9bc","🧑🏻‍🦽":"1f9d1-1f3fb-200d-1f9bd","🧑🏼‍🦽":"1f9d1-1f3fc-200d-1f9bd","🧑🏽‍🦽":"1f9d1-1f3fd-200d-1f9bd","🧑🏾‍🦽":"1f9d1-1f3fe-200d-1f9bd","🧑🏿‍🦽":"1f9d1-1f3ff-200d-1f9bd","👨🏻‍🦽":"1f468-1f3fb-200d-1f9bd","👨🏼‍🦽":"1f468-1f3fc-200d-1f9bd","👨🏽‍🦽":"1f468-1f3fd-200d-1f9bd","👨🏾‍🦽":"1f468-1f3fe-200d-1f9bd","👨🏿‍🦽":"1f468-1f3ff-200d-1f9bd","👩🏻‍🦽":"1f469-1f3fb-200d-1f9bd","👩🏼‍🦽":"1f469-1f3fc-200d-1f9bd","👩🏽‍🦽":"1f469-1f3fd-200d-1f9bd","👩🏾‍🦽":"1f469-1f3fe-200d-1f9bd","👩🏿‍🦽":"1f469-1f3ff-200d-1f9bd","🏃‍♂️":"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-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","🧖‍♀️":"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","🧗‍♂️":"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","🧗‍♀️":"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","🏌‍♂️":"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","🧘‍♂️":"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","🧘‍♀️":"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","🐻‍❄️":"1f43b-200d-2744-fe0f","🏳️‍🌈":"1f3f3-fe0f-200d-1f308","🏳‍⚧️":"1f3f3-fe0f-200d-26a7-fe0f","🏳️‍⚧":"1f3f3-fe0f-200d-26a7-fe0f","🏴‍☠️":"1f3f4-200d-2620-fe0f","👁️‍🗨️":"1f441-200d-1f5e8","🫱🏻‍🫲🏼":"1faf1-1f3fb-200d-1faf2-1f3fc","🫱🏻‍🫲🏽":"1faf1-1f3fb-200d-1faf2-1f3fd","🫱🏻‍🫲🏾":"1faf1-1f3fb-200d-1faf2-1f3fe","🫱🏻‍🫲🏿":"1faf1-1f3fb-200d-1faf2-1f3ff","🫱🏼‍🫲🏻":"1faf1-1f3fc-200d-1faf2-1f3fb","🫱🏼‍🫲🏽":"1faf1-1f3fc-200d-1faf2-1f3fd","🫱🏼‍🫲🏾":"1faf1-1f3fc-200d-1faf2-1f3fe","🫱🏼‍🫲🏿":"1faf1-1f3fc-200d-1faf2-1f3ff","🫱🏽‍🫲🏻":"1faf1-1f3fd-200d-1faf2-1f3fb","🫱🏽‍🫲🏼":"1faf1-1f3fd-200d-1faf2-1f3fc","🫱🏽‍🫲🏾":"1faf1-1f3fd-200d-1faf2-1f3fe","🫱🏽‍🫲🏿":"1faf1-1f3fd-200d-1faf2-1f3ff","🫱🏾‍🫲🏻":"1faf1-1f3fe-200d-1faf2-1f3fb","🫱🏾‍🫲🏼":"1faf1-1f3fe-200d-1faf2-1f3fc","🫱🏾‍🫲🏽":"1faf1-1f3fe-200d-1faf2-1f3fd","🫱🏾‍🫲🏿":"1faf1-1f3fe-200d-1faf2-1f3ff","🫱🏿‍🫲🏻":"1faf1-1f3ff-200d-1faf2-1f3fb","🫱🏿‍🫲🏼":"1faf1-1f3ff-200d-1faf2-1f3fc","🫱🏿‍🫲🏽":"1faf1-1f3ff-200d-1faf2-1f3fd","🫱🏿‍🫲🏾":"1faf1-1f3ff-200d-1faf2-1f3fe","🧔🏻‍♂️":"1f9d4-1f3fb-200d-2642-fe0f","🧔🏼‍♂️":"1f9d4-1f3fc-200d-2642-fe0f","🧔🏽‍♂️":"1f9d4-1f3fd-200d-2642-fe0f","🧔🏾‍♂️":"1f9d4-1f3fe-200d-2642-fe0f","🧔🏿‍♂️":"1f9d4-1f3ff-200d-2642-fe0f","🧔🏻‍♀️":"1f9d4-1f3fb-200d-2640-fe0f","🧔🏼‍♀️":"1f9d4-1f3fc-200d-2640-fe0f","🧔🏽‍♀️":"1f9d4-1f3fd-200d-2640-fe0f","🧔🏾‍♀️":"1f9d4-1f3fe-200d-2640-fe0f","🧔🏿‍♀️":"1f9d4-1f3ff-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","👱🏻‍♂️":"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","🙍🏻‍♂️":"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","🧏🏻‍♂️":"1f9cf-1f3fb-200d-2642-fe0f","🧏🏼‍♂️":"1f9cf-1f3fc-200d-2642-fe0f","🧏🏽‍♂️":"1f9cf-1f3fd-200d-2642-fe0f","🧏🏾‍♂️":"1f9cf-1f3fe-200d-2642-fe0f","🧏🏿‍♂️":"1f9cf-1f3ff-200d-2642-fe0f","🧏🏻‍♀️":"1f9cf-1f3fb-200d-2640-fe0f","🧏🏼‍♀️":"1f9cf-1f3fc-200d-2640-fe0f","🧏🏽‍♀️":"1f9cf-1f3fd-200d-2640-fe0f","🧏🏾‍♀️":"1f9cf-1f3fe-200d-2640-fe0f","🧏🏿‍♀️":"1f9cf-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","🧑🏻‍⚕️":"1f9d1-1f3fb-200d-2695-fe0f","🧑🏼‍⚕️":"1f9d1-1f3fc-200d-2695-fe0f","🧑🏽‍⚕️":"1f9d1-1f3fd-200d-2695-fe0f","🧑🏾‍⚕️":"1f9d1-1f3fe-200d-2695-fe0f","🧑🏿‍⚕️":"1f9d1-1f3ff-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-1f3fb-200d-2695-fe0f","👩🏼‍⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽‍⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾‍⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿‍⚕️":"1f469-1f3ff-200d-2695-fe0f","🧑🏻‍⚖️":"1f9d1-1f3fb-200d-2696-fe0f","🧑🏼‍⚖️":"1f9d1-1f3fc-200d-2696-fe0f","🧑🏽‍⚖️":"1f9d1-1f3fd-200d-2696-fe0f","🧑🏾‍⚖️":"1f9d1-1f3fe-200d-2696-fe0f","🧑🏿‍⚖️":"1f9d1-1f3ff-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-1f3fb-200d-2696-fe0f","👩🏼‍⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽‍⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾‍⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿‍⚖️":"1f469-1f3ff-200d-2696-fe0f","🧑🏻‍✈️":"1f9d1-1f3fb-200d-2708-fe0f","🧑🏼‍✈️":"1f9d1-1f3fc-200d-2708-fe0f","🧑🏽‍✈️":"1f9d1-1f3fd-200d-2708-fe0f","🧑🏾‍✈️":"1f9d1-1f3fe-200d-2708-fe0f","🧑🏿‍✈️":"1f9d1-1f3ff-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-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","🤵🏻‍♂️":"1f935-1f3fb-200d-2642-fe0f","🤵🏼‍♂️":"1f935-1f3fc-200d-2642-fe0f","🤵🏽‍♂️":"1f935-1f3fd-200d-2642-fe0f","🤵🏾‍♂️":"1f935-1f3fe-200d-2642-fe0f","🤵🏿‍♂️":"1f935-1f3ff-200d-2642-fe0f","🤵🏻‍♀️":"1f935-1f3fb-200d-2640-fe0f","🤵🏼‍♀️":"1f935-1f3fc-200d-2640-fe0f","🤵🏽‍♀️":"1f935-1f3fd-200d-2640-fe0f","🤵🏾‍♀️":"1f935-1f3fe-200d-2640-fe0f","🤵🏿‍♀️":"1f935-1f3ff-200d-2640-fe0f","👰🏻‍♂️":"1f470-1f3fb-200d-2642-fe0f","👰🏼‍♂️":"1f470-1f3fc-200d-2642-fe0f","👰🏽‍♂️":"1f470-1f3fd-200d-2642-fe0f","👰🏾‍♂️":"1f470-1f3fe-200d-2642-fe0f","👰🏿‍♂️":"1f470-1f3ff-200d-2642-fe0f","👰🏻‍♀️":"1f470-1f3fb-200d-2640-fe0f","👰🏼‍♀️":"1f470-1f3fc-200d-2640-fe0f","👰🏽‍♀️":"1f470-1f3fd-200d-2640-fe0f","👰🏾‍♀️":"1f470-1f3fe-200d-2640-fe0f","👰🏿‍♀️":"1f470-1f3ff-200d-2640-fe0f","🦸🏻‍♂️":"1f9b8-1f3fb-200d-2642-fe0f","🦸🏼‍♂️":"1f9b8-1f3fc-200d-2642-fe0f","🦸🏽‍♂️":"1f9b8-1f3fd-200d-2642-fe0f","🦸🏾‍♂️":"1f9b8-1f3fe-200d-2642-fe0f","🦸🏿‍♂️":"1f9b8-1f3ff-200d-2642-fe0f","🦸🏻‍♀️":"1f9b8-1f3fb-200d-2640-fe0f","🦸🏼‍♀️":"1f9b8-1f3fc-200d-2640-fe0f","🦸🏽‍♀️":"1f9b8-1f3fd-200d-2640-fe0f","🦸🏾‍♀️":"1f9b8-1f3fe-200d-2640-fe0f","🦸🏿‍♀️":"1f9b8-1f3ff-200d-2640-fe0f","🦹🏻‍♂️":"1f9b9-1f3fb-200d-2642-fe0f","🦹🏼‍♂️":"1f9b9-1f3fc-200d-2642-fe0f","🦹🏽‍♂️":"1f9b9-1f3fd-200d-2642-fe0f","🦹🏾‍♂️":"1f9b9-1f3fe-200d-2642-fe0f","🦹🏿‍♂️":"1f9b9-1f3ff-200d-2642-fe0f","🦹🏻‍♀️":"1f9b9-1f3fb-200d-2640-fe0f","🦹🏼‍♀️":"1f9b9-1f3fc-200d-2640-fe0f","🦹🏽‍♀️":"1f9b9-1f3fd-200d-2640-fe0f","🦹🏾‍♀️":"1f9b9-1f3fe-200d-2640-fe0f","🦹🏿‍♀️":"1f9b9-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","🧙🏻‍♀️":"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","🧚🏻‍♂️":"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","🧚🏻‍♀️":"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","🧛🏻‍♂️":"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","🧛🏻‍♀️":"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","🧜🏻‍♂️":"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","🧜🏻‍♀️":"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","🧝🏻‍♂️":"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","🧝🏻‍♀️":"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","💆🏻‍♂️":"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","🧍🏻‍♂️":"1f9cd-1f3fb-200d-2642-fe0f","🧍🏼‍♂️":"1f9cd-1f3fc-200d-2642-fe0f","🧍🏽‍♂️":"1f9cd-1f3fd-200d-2642-fe0f","🧍🏾‍♂️":"1f9cd-1f3fe-200d-2642-fe0f","🧍🏿‍♂️":"1f9cd-1f3ff-200d-2642-fe0f","🧍🏻‍♀️":"1f9cd-1f3fb-200d-2640-fe0f","🧍🏼‍♀️":"1f9cd-1f3fc-200d-2640-fe0f","🧍🏽‍♀️":"1f9cd-1f3fd-200d-2640-fe0f","🧍🏾‍♀️":"1f9cd-1f3fe-200d-2640-fe0f","🧍🏿‍♀️":"1f9cd-1f3ff-200d-2640-fe0f","🧎🏻‍♂️":"1f9ce-1f3fb-200d-2642-fe0f","🧎🏼‍♂️":"1f9ce-1f3fc-200d-2642-fe0f","🧎🏽‍♂️":"1f9ce-1f3fd-200d-2642-fe0f","🧎🏾‍♂️":"1f9ce-1f3fe-200d-2642-fe0f","🧎🏿‍♂️":"1f9ce-1f3ff-200d-2642-fe0f","🧎🏻‍♀️":"1f9ce-1f3fb-200d-2640-fe0f","🧎🏼‍♀️":"1f9ce-1f3fc-200d-2640-fe0f","🧎🏽‍♀️":"1f9ce-1f3fd-200d-2640-fe0f","🧎🏾‍♀️":"1f9ce-1f3fe-200d-2640-fe0f","🧎🏿‍♀️":"1f9ce-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-2642-fe0f","🧖🏼‍♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽‍♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾‍♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿‍♂️":"1f9d6-1f3ff-200d-2642-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","🧗🏻‍♂️":"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","🧗🏻‍♀️":"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","🏌️‍♂️":"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","🧘🏻‍♂️":"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","🧘🏻‍♀️":"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","🧑‍🤝‍🧑":"1f9d1-200d-1f91d-200d-1f9d1","👩‍❤‍👨":"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","🏳️‍⚧️":"1f3f3-fe0f-200d-26a7-fe0f","👩‍❤️‍👨":"1f469-200d-2764-fe0f-200d-1f468","👨‍❤️‍👨":"1f468-200d-2764-fe0f-200d-1f468","👩‍❤️‍👩":"1f469-200d-2764-fe0f-200d-1f469","🧑🏻‍🤝‍🧑🏻":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fb","🧑🏻‍🤝‍🧑🏼":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fc","🧑🏻‍🤝‍🧑🏽":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fd","🧑🏻‍🤝‍🧑🏾":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fe","🧑🏻‍🤝‍🧑🏿":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3ff","🧑🏼‍🤝‍🧑🏻":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fb","🧑🏼‍🤝‍🧑🏼":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fc","🧑🏼‍🤝‍🧑🏽":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fd","🧑🏼‍🤝‍🧑🏾":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fe","🧑🏼‍🤝‍🧑🏿":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3ff","🧑🏽‍🤝‍🧑🏻":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fb","🧑🏽‍🤝‍🧑🏼":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fc","🧑🏽‍🤝‍🧑🏽":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fd","🧑🏽‍🤝‍🧑🏾":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fe","🧑🏽‍🤝‍🧑🏿":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3ff","🧑🏾‍🤝‍🧑🏻":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fb","🧑🏾‍🤝‍🧑🏼":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fc","🧑🏾‍🤝‍🧑🏽":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fd","🧑🏾‍🤝‍🧑🏾":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fe","🧑🏾‍🤝‍🧑🏿":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3ff","🧑🏿‍🤝‍🧑🏻":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fb","🧑🏿‍🤝‍🧑🏼":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fc","🧑🏿‍🤝‍🧑🏽":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fd","🧑🏿‍🤝‍🧑🏾":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fe","🧑🏿‍🤝‍🧑🏿":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3ff","👩🏻‍🤝‍👩🏼":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fc","👩🏻‍🤝‍👩🏽":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fd","👩🏻‍🤝‍👩🏾":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fe","👩🏻‍🤝‍👩🏿":"1f469-1f3fb-200d-1f91d-200d-1f469-1f3ff","👩🏼‍🤝‍👩🏻":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3fb","👩🏼‍🤝‍👩🏽":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3fd","👩🏼‍🤝‍👩🏾":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3fe","👩🏼‍🤝‍👩🏿":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3ff","👩🏽‍🤝‍👩🏻":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fb","👩🏽‍🤝‍👩🏼":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fc","👩🏽‍🤝‍👩🏾":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fe","👩🏽‍🤝‍👩🏿":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3ff","👩🏾‍🤝‍👩🏻":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fb","👩🏾‍🤝‍👩🏼":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fc","👩🏾‍🤝‍👩🏽":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fd","👩🏾‍🤝‍👩🏿":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3ff","👩🏿‍🤝‍👩🏻":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fb","👩🏿‍🤝‍👩🏼":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fc","👩🏿‍🤝‍👩🏽":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fd","👩🏿‍🤝‍👩🏾":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fe","👩🏻‍🤝‍👨🏼":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fc","👩🏻‍🤝‍👨🏽":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fd","👩🏻‍🤝‍👨🏾":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fe","👩🏻‍🤝‍👨🏿":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3ff","👩🏼‍🤝‍👨🏻":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fb","👩🏼‍🤝‍👨🏽":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fd","👩🏼‍🤝‍👨🏾":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fe","👩🏼‍🤝‍👨🏿":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3ff","👩🏽‍🤝‍👨🏻":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fb","👩🏽‍🤝‍👨🏼":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fc","👩🏽‍🤝‍👨🏾":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fe","👩🏽‍🤝‍👨🏿":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3ff","👩🏾‍🤝‍👨🏻":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fb","👩🏾‍🤝‍👨🏼":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fc","👩🏾‍🤝‍👨🏽":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fd","👩🏾‍🤝‍👨🏿":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3ff","👩🏿‍🤝‍👨🏻":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fb","👩🏿‍🤝‍👨🏼":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fc","👩🏿‍🤝‍👨🏽":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fd","👩🏿‍🤝‍👨🏾":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fe","👨🏻‍🤝‍👨🏼":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3fc","👨🏻‍🤝‍👨🏽":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3fd","👨🏻‍🤝‍👨🏾":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3fe","👨🏻‍🤝‍👨🏿":"1f468-1f3fb-200d-1f91d-200d-1f468-1f3ff","👨🏼‍🤝‍👨🏻":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3fb","👨🏼‍🤝‍👨🏽":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3fd","👨🏼‍🤝‍👨🏾":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3fe","👨🏼‍🤝‍👨🏿":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3ff","👨🏽‍🤝‍👨🏻":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fb","👨🏽‍🤝‍👨🏼":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fc","👨🏽‍🤝‍👨🏾":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fe","👨🏽‍🤝‍👨🏿":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3ff","👨🏾‍🤝‍👨🏻":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fb","👨🏾‍🤝‍👨🏼":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fc","👨🏾‍🤝‍👨🏽":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fd","👨🏾‍🤝‍👨🏿":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3ff","👨🏿‍🤝‍👨🏻":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fb","👨🏿‍🤝‍👨🏼":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fc","👨🏿‍🤝‍👨🏽":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fd","👨🏿‍🤝‍👨🏾":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fe","👩‍❤‍💋‍👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨‍❤‍💋‍👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩‍❤‍💋‍👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","🧑🏻‍❤‍🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏻‍❤‍🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏻‍❤‍🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏻‍❤‍🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏼‍❤‍🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏼‍❤‍🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏼‍❤‍🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏼‍❤‍🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏽‍❤‍🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏽‍❤‍🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏽‍❤‍🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏽‍❤‍🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏾‍❤‍🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏾‍❤‍🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏾‍❤‍🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏾‍❤‍🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏿‍❤‍🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏿‍❤‍🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏿‍❤‍🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏿‍❤‍🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fe","👩🏻‍❤‍👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👩🏻‍❤‍👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👩🏻‍❤‍👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👩🏻‍❤‍👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👩🏻‍❤‍👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👩🏼‍❤‍👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👩🏼‍❤‍👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👩🏼‍❤‍👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👩🏼‍❤‍👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👩🏼‍❤‍👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👩🏽‍❤‍👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👩🏽‍❤‍👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👩🏽‍❤‍👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👩🏽‍❤‍👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👩🏽‍❤‍👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👩🏾‍❤‍👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👩🏾‍❤‍👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👩🏾‍❤‍👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👩🏾‍❤‍👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👩🏾‍❤‍👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👩🏿‍❤‍👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👩🏿‍❤‍👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👩🏿‍❤‍👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👩🏿‍❤‍👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👩🏿‍❤‍👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👨🏻‍❤‍👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👨🏻‍❤‍👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👨🏻‍❤‍👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👨🏻‍❤‍👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👨🏻‍❤‍👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👨🏼‍❤‍👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👨🏼‍❤‍👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👨🏼‍❤‍👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👨🏼‍❤‍👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👨🏼‍❤‍👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👨🏽‍❤‍👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👨🏽‍❤‍👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👨🏽‍❤‍👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👨🏽‍❤‍👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👨🏽‍❤‍👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👨🏾‍❤‍👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👨🏾‍❤‍👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👨🏾‍❤‍👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👨🏾‍❤‍👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👨🏾‍❤‍👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👨🏿‍❤‍👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👨🏿‍❤‍👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👨🏿‍❤‍👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👨🏿‍❤‍👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👨🏿‍❤‍👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👩🏻‍❤‍👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fb","👩🏻‍❤‍👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fc","👩🏻‍❤‍👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fd","👩🏻‍❤‍👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fe","👩🏻‍❤‍👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3ff","👩🏼‍❤‍👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fb","👩🏼‍❤‍👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fc","👩🏼‍❤‍👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fd","👩🏼‍❤‍👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fe","👩🏼‍❤‍👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3ff","👩🏽‍❤‍👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fb","👩🏽‍❤‍👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fc","👩🏽‍❤‍👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fd","👩🏽‍❤‍👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fe","👩🏽‍❤‍👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3ff","👩🏾‍❤‍👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fb","👩🏾‍❤‍👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fc","👩🏾‍❤‍👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fd","👩🏾‍❤‍👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fe","👩🏾‍❤‍👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3ff","👩🏿‍❤‍👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fb","👩🏿‍❤‍👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fc","👩🏿‍❤‍👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fd","👩🏿‍❤‍👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fe","👩🏿‍❤‍👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3ff","👨‍👩‍👧‍👦":"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","🧑🏻‍❤️‍🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏻‍❤️‍🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏻‍❤️‍🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏻‍❤️‍🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏼‍❤️‍🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏼‍❤️‍🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏼‍❤️‍🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏼‍❤️‍🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏽‍❤️‍🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏽‍❤️‍🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏽‍❤️‍🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3fe","🧑🏽‍❤️‍🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏾‍❤️‍🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏾‍❤️‍🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏾‍❤️‍🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏾‍❤️‍🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f9d1-1f3ff","🧑🏿‍❤️‍🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fb","🧑🏿‍❤️‍🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fc","🧑🏿‍❤️‍🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fd","🧑🏿‍❤️‍🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f9d1-1f3fe","👩🏻‍❤️‍👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👩🏻‍❤️‍👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👩🏻‍❤️‍👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👩🏻‍❤️‍👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👩🏻‍❤️‍👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👩🏼‍❤️‍👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👩🏼‍❤️‍👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👩🏼‍❤️‍👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👩🏼‍❤️‍👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👩🏼‍❤️‍👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👩🏽‍❤️‍👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👩🏽‍❤️‍👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👩🏽‍❤️‍👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👩🏽‍❤️‍👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👩🏽‍❤️‍👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👩🏾‍❤️‍👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👩🏾‍❤️‍👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👩🏾‍❤️‍👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👩🏾‍❤️‍👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👩🏾‍❤️‍👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👩🏿‍❤️‍👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👩🏿‍❤️‍👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👩🏿‍❤️‍👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👩🏿‍❤️‍👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👩🏿‍❤️‍👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👨🏻‍❤️‍👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fb","👨🏻‍❤️‍👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc","👨🏻‍❤️‍👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fd","👨🏻‍❤️‍👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3fe","👨🏻‍❤️‍👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f468-1f3ff","👨🏼‍❤️‍👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fb","👨🏼‍❤️‍👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fc","👨🏼‍❤️‍👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fd","👨🏼‍❤️‍👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3fe","👨🏼‍❤️‍👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f468-1f3ff","👨🏽‍❤️‍👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fb","👨🏽‍❤️‍👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fc","👨🏽‍❤️‍👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fd","👨🏽‍❤️‍👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3fe","👨🏽‍❤️‍👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f468-1f3ff","👨🏾‍❤️‍👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fb","👨🏾‍❤️‍👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fc","👨🏾‍❤️‍👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fd","👨🏾‍❤️‍👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3fe","👨🏾‍❤️‍👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f468-1f3ff","👨🏿‍❤️‍👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fb","👨🏿‍❤️‍👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fc","👨🏿‍❤️‍👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fd","👨🏿‍❤️‍👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3fe","👨🏿‍❤️‍👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f468-1f3ff","👩🏻‍❤️‍👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fb","👩🏻‍❤️‍👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fc","👩🏻‍❤️‍👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fd","👩🏻‍❤️‍👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3fe","👩🏻‍❤️‍👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f469-1f3ff","👩🏼‍❤️‍👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fb","👩🏼‍❤️‍👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fc","👩🏼‍❤️‍👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fd","👩🏼‍❤️‍👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3fe","👩🏼‍❤️‍👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f469-1f3ff","👩🏽‍❤️‍👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fb","👩🏽‍❤️‍👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fc","👩🏽‍❤️‍👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fd","👩🏽‍❤️‍👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3fe","👩🏽‍❤️‍👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f469-1f3ff","👩🏾‍❤️‍👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fb","👩🏾‍❤️‍👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fc","👩🏾‍❤️‍👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fd","👩🏾‍❤️‍👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3fe","👩🏾‍❤️‍👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f469-1f3ff","👩🏿‍❤️‍👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fb","👩🏿‍❤️‍👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fc","👩🏿‍❤️‍👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fd","👩🏿‍❤️‍👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3fe","👩🏿‍❤️‍👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f469-1f3ff","🧑🏻‍❤‍💋‍🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏻‍❤‍💋‍🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏻‍❤‍💋‍🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏻‍❤‍💋‍🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏼‍❤‍💋‍🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏼‍❤‍💋‍🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏼‍❤‍💋‍🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏼‍❤‍💋‍🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏽‍❤‍💋‍🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏽‍❤‍💋‍🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏽‍❤‍💋‍🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏽‍❤‍💋‍🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏾‍❤‍💋‍🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏾‍❤‍💋‍🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏾‍❤‍💋‍🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏾‍❤‍💋‍🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏿‍❤‍💋‍🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏿‍❤‍💋‍🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏿‍❤‍💋‍🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏿‍❤‍💋‍🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","👩🏻‍❤‍💋‍👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏻‍❤‍💋‍👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏻‍❤‍💋‍👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏻‍❤‍💋‍👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏻‍❤‍💋‍👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏼‍❤‍💋‍👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏼‍❤‍💋‍👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏼‍❤‍💋‍👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏼‍❤‍💋‍👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏼‍❤‍💋‍👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏽‍❤‍💋‍👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏽‍❤‍💋‍👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏽‍❤‍💋‍👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏽‍❤‍💋‍👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏽‍❤‍💋‍👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏾‍❤‍💋‍👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏾‍❤‍💋‍👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏾‍❤‍💋‍👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏾‍❤‍💋‍👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏾‍❤‍💋‍👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏿‍❤‍💋‍👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏿‍❤‍💋‍👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏿‍❤‍💋‍👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏿‍❤‍💋‍👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏿‍❤‍💋‍👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏻‍❤‍💋‍👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏻‍❤‍💋‍👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏻‍❤‍💋‍👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏻‍❤‍💋‍👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏻‍❤‍💋‍👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏼‍❤‍💋‍👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏼‍❤‍💋‍👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏼‍❤‍💋‍👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏼‍❤‍💋‍👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏼‍❤‍💋‍👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏽‍❤‍💋‍👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏽‍❤‍💋‍👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏽‍❤‍💋‍👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏽‍❤‍💋‍👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏽‍❤‍💋‍👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏾‍❤‍💋‍👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏾‍❤‍💋‍👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏾‍❤‍💋‍👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏾‍❤‍💋‍👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏾‍❤‍💋‍👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏿‍❤‍💋‍👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏿‍❤‍💋‍👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏿‍❤‍💋‍👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏿‍❤‍💋‍👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏿‍❤‍💋‍👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏻‍❤‍💋‍👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏻‍❤‍💋‍👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏻‍❤‍💋‍👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏻‍❤‍💋‍👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏻‍❤‍💋‍👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏼‍❤‍💋‍👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏼‍❤‍💋‍👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏼‍❤‍💋‍👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏼‍❤‍💋‍👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏼‍❤‍💋‍👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏽‍❤‍💋‍👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏽‍❤‍💋‍👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏽‍❤‍💋‍👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏽‍❤‍💋‍👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏽‍❤‍💋‍👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏾‍❤‍💋‍👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏾‍❤‍💋‍👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏾‍❤‍💋‍👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏾‍❤‍💋‍👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏾‍❤‍💋‍👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏿‍❤‍💋‍👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏿‍❤‍💋‍👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏿‍❤‍💋‍👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏿‍❤‍💋‍👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏿‍❤‍💋‍👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","🧑🏻‍❤️‍💋‍🧑🏼":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏻‍❤️‍💋‍🧑🏽":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏻‍❤️‍💋‍🧑🏾":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏻‍❤️‍💋‍🧑🏿":"1f9d1-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏼‍❤️‍💋‍🧑🏻":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏼‍❤️‍💋‍🧑🏽":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏼‍❤️‍💋‍🧑🏾":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏼‍❤️‍💋‍🧑🏿":"1f9d1-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏽‍❤️‍💋‍🧑🏻":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏽‍❤️‍💋‍🧑🏼":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏽‍❤️‍💋‍🧑🏾":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","🧑🏽‍❤️‍💋‍🧑🏿":"1f9d1-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏾‍❤️‍💋‍🧑🏻":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏾‍❤️‍💋‍🧑🏼":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏾‍❤️‍💋‍🧑🏽":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏾‍❤️‍💋‍🧑🏿":"1f9d1-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3ff","🧑🏿‍❤️‍💋‍🧑🏻":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fb","🧑🏿‍❤️‍💋‍🧑🏼":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fc","🧑🏿‍❤️‍💋‍🧑🏽":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fd","🧑🏿‍❤️‍💋‍🧑🏾":"1f9d1-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f9d1-1f3fe","👩🏻‍❤️‍💋‍👨🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏻‍❤️‍💋‍👨🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏻‍❤️‍💋‍👨🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏻‍❤️‍💋‍👨🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏻‍❤️‍💋‍👨🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏼‍❤️‍💋‍👨🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏼‍❤️‍💋‍👨🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏼‍❤️‍💋‍👨🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏼‍❤️‍💋‍👨🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏼‍❤️‍💋‍👨🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏽‍❤️‍💋‍👨🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏽‍❤️‍💋‍👨🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏽‍❤️‍💋‍👨🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏽‍❤️‍💋‍👨🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏽‍❤️‍💋‍👨🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏾‍❤️‍💋‍👨🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏾‍❤️‍💋‍👨🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏾‍❤️‍💋‍👨🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏾‍❤️‍💋‍👨🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏾‍❤️‍💋‍👨🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏿‍❤️‍💋‍👨🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👩🏿‍❤️‍💋‍👨🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👩🏿‍❤️‍💋‍👨🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👩🏿‍❤️‍💋‍👨🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👩🏿‍❤️‍💋‍👨🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏻‍❤️‍💋‍👨🏻":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏻‍❤️‍💋‍👨🏼":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏻‍❤️‍💋‍👨🏽":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏻‍❤️‍💋‍👨🏾":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏻‍❤️‍💋‍👨🏿":"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏼‍❤️‍💋‍👨🏻":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏼‍❤️‍💋‍👨🏼":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏼‍❤️‍💋‍👨🏽":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏼‍❤️‍💋‍👨🏾":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏼‍❤️‍💋‍👨🏿":"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏽‍❤️‍💋‍👨🏻":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏽‍❤️‍💋‍👨🏼":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏽‍❤️‍💋‍👨🏽":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏽‍❤️‍💋‍👨🏾":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏽‍❤️‍💋‍👨🏿":"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏾‍❤️‍💋‍👨🏻":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏾‍❤️‍💋‍👨🏼":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏾‍❤️‍💋‍👨🏽":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏾‍❤️‍💋‍👨🏾":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏾‍❤️‍💋‍👨🏿":"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👨🏿‍❤️‍💋‍👨🏻":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb","👨🏿‍❤️‍💋‍👨🏼":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc","👨🏿‍❤️‍💋‍👨🏽":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd","👨🏿‍❤️‍💋‍👨🏾":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe","👨🏿‍❤️‍💋‍👨🏿":"1f468-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff","👩🏻‍❤️‍💋‍👩🏻":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏻‍❤️‍💋‍👩🏼":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏻‍❤️‍💋‍👩🏽":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏻‍❤️‍💋‍👩🏾":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏻‍❤️‍💋‍👩🏿":"1f469-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏼‍❤️‍💋‍👩🏻":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏼‍❤️‍💋‍👩🏼":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏼‍❤️‍💋‍👩🏽":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏼‍❤️‍💋‍👩🏾":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏼‍❤️‍💋‍👩🏿":"1f469-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏽‍❤️‍💋‍👩🏻":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏽‍❤️‍💋‍👩🏼":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏽‍❤️‍💋‍👩🏽":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏽‍❤️‍💋‍👩🏾":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏽‍❤️‍💋‍👩🏿":"1f469-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏾‍❤️‍💋‍👩🏻":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏾‍❤️‍💋‍👩🏼":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏾‍❤️‍💋‍👩🏽":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏾‍❤️‍💋‍👩🏾":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏾‍❤️‍💋‍👩🏿":"1f469-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff","👩🏿‍❤️‍💋‍👩🏻":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fb","👩🏿‍❤️‍💋‍👩🏼":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fc","👩🏿‍❤️‍💋‍👩🏽":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fd","👩🏿‍❤️‍💋‍👩🏾":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3fe","👩🏿‍❤️‍💋‍👩🏿":"1f469-1f3ff-200d-2764-fe0f-200d-1f48b-200d-1f469-1f3ff"}
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_mart_data_light.js b/app/javascript/flavours/glitch/features/emoji/emoji_mart_data_light.js
new file mode 100644
index 000000000..45086fc4c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/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/features/emoji/emoji_mart_search_light.js b/app/javascript/flavours/glitch/features/emoji/emoji_mart_search_light.js
new file mode 100644
index 000000000..70694ab6d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji/emoji_mart_search_light.js
@@ -0,0 +1,185 @@
+// 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, uniq, intersect } from './emoji_utils';
+
+let originalPool = {};
+let index = {};
+let emojisList = {};
+let emoticonsList = {};
+let customEmojisList = [];
+
+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 clearCustomEmojis(pool) {
+  customEmojisList.forEach((emoji) => {
+    let emojiId = emoji.id || emoji.short_names[0];
+
+    delete pool[emojiId];
+    delete emojisList[emojiId];
+  });
+}
+
+function addCustomToPool(custom, pool) {
+  if (customEmojisList.length) clearCustomEmojis(pool);
+
+  custom.forEach((emoji) => {
+    let emojiId = emoji.id || emoji.short_names[0];
+
+    if (emojiId && !pool[emojiId]) {
+      pool[emojiId] = getData(emoji);
+      emojisList[emojiId] = getSanitizedData(emoji);
+    }
+  });
+
+  customEmojisList = custom;
+  index = {};
+}
+
+function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) {
+  if (custom !== undefined) {
+    if (customEmojisList !== custom)
+      addCustomToPool(custom, originalPool);
+  } else {
+    custom = [];
+  }
+
+  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);
+        }
+      }
+    }
+
+    const searchValue = (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.slice(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;
+    };
+
+    if (values.length > 1) {
+      results = searchValue(value);
+    } else {
+      results = [];
+    }
+
+    allResults = values.map(searchValue).filter(a => a);
+
+    if (allResults.length > 1) {
+      allResults = intersect.apply(null, allResults);
+    } else if (allResults.length) {
+      allResults = allResults[0];
+    }
+
+    results = uniq(results.concat(allResults));
+  }
+
+  if (results) {
+    if (emojisToShowFilter) {
+      results = results.filter((result) => emojisToShowFilter(data.emojis[result.id]));
+    }
+
+    if (results && results.length > maxResults) {
+      results = results.slice(0, maxResults);
+    }
+  }
+
+  return results;
+}
+
+export { search };
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_picker.js b/app/javascript/flavours/glitch/features/emoji/emoji_picker.js
new file mode 100644
index 000000000..044d38cb2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji/emoji_picker.js
@@ -0,0 +1,7 @@
+import Picker from 'emoji-mart/dist-es/components/picker/picker';
+import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
+
+export {
+  Picker,
+  Emoji,
+};
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/flavours/glitch/features/emoji/emoji_unicode_mapping_light.js
new file mode 100644
index 000000000..918684c31
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/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/features/emoji/emoji_utils.js b/app/javascript/flavours/glitch/features/emoji/emoji_utils.js
new file mode 100644
index 000000000..be793526d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/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 (Object.prototype.hasOwnProperty.call(data.short_names, emoji)) {
+      emoji = data.short_names[emoji];
+    }
+
+    if (Object.prototype.hasOwnProperty.call(data.emojis, emoji)) {
+      emojiData = data.emojis[emoji];
+    }
+  } else if (emoji.id) {
+    if (Object.prototype.hasOwnProperty.call(data.short_names, emoji.id)) {
+      emoji.id = data.short_names[emoji.id];
+    }
+
+    if (Object.prototype.hasOwnProperty.call(data.emojis, 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 (Object.prototype.hasOwnProperty.call(b, 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/features/emoji/unicode_to_filename.js b/app/javascript/flavours/glitch/features/emoji/unicode_to_filename.js
new file mode 100644
index 000000000..c75c4cd7d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/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/features/emoji/unicode_to_unified_name.js b/app/javascript/flavours/glitch/features/emoji/unicode_to_unified_name.js
new file mode 100644
index 000000000..d29550f12
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji/unicode_to_unified_name.js
@@ -0,0 +1,21 @@
+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/features/explore/components/story.jsx b/app/javascript/flavours/glitch/features/explore/components/story.jsx
new file mode 100644
index 000000000..8270d3ccb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/explore/components/story.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Blurhash from 'flavours/glitch/components/blurhash';
+import { accountsCountRenderer } from 'flavours/glitch/components/hashtag';
+import ShortNumber from 'flavours/glitch/components/short_number';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import classNames from 'classnames';
+
+export default class Story extends React.PureComponent {
+
+  static propTypes = {
+    url: PropTypes.string,
+    title: PropTypes.string,
+    publisher: PropTypes.string,
+    sharedTimes: PropTypes.number,
+    thumbnail: PropTypes.string,
+    blurhash: PropTypes.string,
+  };
+
+  state = {
+    thumbnailLoaded: false,
+  };
+
+  handleImageLoad = () => this.setState({ thumbnailLoaded: true });
+
+  render () {
+    const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
+
+    const { thumbnailLoaded } = this.state;
+
+    return (
+      <a className='story' href={url} target='blank' rel='noopener'>
+        <div className='story__details'>
+          <div className='story__details__publisher'>{publisher ? publisher : <Skeleton width={50} />}</div>
+          <div className='story__details__title'>{title ? title : <Skeleton />}</div>
+          <div className='story__details__shared'>{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
+        </div>
+
+        <div className='story__thumbnail'>
+          {thumbnail ? (
+            <React.Fragment>
+              <div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
+              <img src={thumbnail} onLoad={this.handleImageLoad} alt='' role='presentation' />
+            </React.Fragment>
+          ) : <Skeleton />}
+        </div>
+      </a>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/explore/index.jsx b/app/javascript/flavours/glitch/features/explore/index.jsx
new file mode 100644
index 000000000..3587de1db
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/explore/index.jsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { NavLink, Switch, Route } from 'react-router-dom';
+import Links from './links';
+import Tags from './tags';
+import Statuses from './statuses';
+import Suggestions from './suggestions';
+import Search from 'flavours/glitch/features/compose/containers/search_container';
+import SearchResults from './results';
+import { showTrends } from 'flavours/glitch/initial_state';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  title: { id: 'explore.title', defaultMessage: 'Explore' },
+  searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
+});
+
+const mapStateToProps = state => ({
+  layout: state.getIn(['meta', 'layout']),
+  isSearching: state.getIn(['search', 'submitted']) || !showTrends,
+});
+
+class Explore extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    isSearching: PropTypes.bool,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  render() {
+    const { intl, multiColumn, isSearching } = this.props;
+    const { signedIn } = this.context.identity;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon={isSearching ? 'search' : 'hashtag'}
+          title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
+          onClick={this.handleHeaderClick}
+          multiColumn={multiColumn}
+        />
+
+        <div className='explore__search-header'>
+          <Search />
+        </div>
+
+        <div className='scrollable scrollable--flex'>
+          {isSearching ? (
+            <SearchResults />
+          ) : (
+            <>
+              <div className='account__section-headline'>
+                <NavLink exact to='/explore'>
+                  <FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
+                </NavLink>
+                <NavLink exact to='/explore/tags'>
+                  <FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
+                </NavLink>
+                <NavLink exact to='/explore/links'>
+                  <FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
+                </NavLink>
+                {signedIn && (
+                  <NavLink exact to='/explore/suggestions'>
+                    <FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='For you' />
+                  </NavLink>
+                )}
+              </div>
+
+              <Switch>
+                <Route path='/explore/tags' component={Tags} />
+                <Route path='/explore/links' component={Links} />
+                <Route path='/explore/suggestions' component={Suggestions} />
+                <Route exact path={['/explore', '/explore/posts', '/search']}>
+                  <Statuses multiColumn={multiColumn} />
+                </Route>
+              </Switch>
+
+              <Helmet>
+                <title>{intl.formatMessage(messages.title)}</title>
+                <meta name='robots' content={isSearching ? 'noindex' : 'all'} />
+              </Helmet>
+            </>
+          )}
+        </div>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Explore));
diff --git a/app/javascript/flavours/glitch/features/explore/links.jsx b/app/javascript/flavours/glitch/features/explore/links.jsx
new file mode 100644
index 000000000..425934c4a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/explore/links.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Story from './components/story';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingLinks } from 'flavours/glitch/actions/trends';
+import { FormattedMessage } from 'react-intl';
+import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
+
+const mapStateToProps = state => ({
+  links: state.getIn(['trends', 'links', 'items']),
+  isLoading: state.getIn(['trends', 'links', 'isLoading']),
+});
+
+class Links extends React.PureComponent {
+
+  static propTypes = {
+    links: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingLinks());
+  }
+
+  render () {
+    const { isLoading, links } = this.props;
+
+    const banner = (
+      <DismissableBanner id='explore/links'>
+        <FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These news stories are being talked about by people on this and other servers of the decentralized network right now.' />
+      </DismissableBanner>
+    );
+
+    if (!isLoading && links.isEmpty()) {
+      return (
+        <div className='explore__links scrollable scrollable--flex'>
+          {banner}
+
+          <div className='empty-column-indicator'>
+            <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div className='explore__links'>
+        {banner}
+
+        {isLoading ? (<LoadingIndicator />) : links.map(link => (
+          <Story
+            key={link.get('id')}
+            url={link.get('url')}
+            title={link.get('title')}
+            publisher={link.get('provider_name')}
+            sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
+            thumbnail={link.get('image')}
+            blurhash={link.get('blurhash')}
+          />
+        ))}
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Links);
diff --git a/app/javascript/flavours/glitch/features/explore/results.jsx b/app/javascript/flavours/glitch/features/explore/results.jsx
new file mode 100644
index 000000000..0d6c0e8f1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/explore/results.jsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { expandSearch } from 'flavours/glitch/actions/search';
+import Account from 'flavours/glitch/containers/account_container';
+import Status from 'flavours/glitch/containers/status_container';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
+import { List as ImmutableList } from 'immutable';
+import LoadMore from 'flavours/glitch/components/load_more';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
+});
+
+const mapStateToProps = state => ({
+  isLoading: state.getIn(['search', 'isLoading']),
+  results: state.getIn(['search', 'results']),
+  q: state.getIn(['search', 'searchTerm']),
+});
+
+const appendLoadMore = (id, list, onLoadMore) => {
+  if (list.size >= 5) {
+    return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />);
+  } else {
+    return list;
+  }
+};
+
+const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => (
+  <Account key={`account-${item}`} id={item} />
+)), onLoadMore);
+
+const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => (
+  <Hashtag key={`tag-${item.get('name')}`} hashtag={item} />
+)), onLoadMore);
+
+const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => (
+  <Status key={`status-${item}`} id={item} />
+)), onLoadMore);
+
+class Results extends React.PureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map,
+    isLoading: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+    q: PropTypes.string,
+    intl: PropTypes.object,
+  };
+
+  state = {
+    type: 'all',
+  };
+
+  handleSelectAll = () => this.setState({ type: 'all' });
+  handleSelectAccounts = () => this.setState({ type: 'accounts' });
+  handleSelectHashtags = () => this.setState({ type: 'hashtags' });
+  handleSelectStatuses = () => this.setState({ type: 'statuses' });
+  handleLoadMoreAccounts = () => this.loadMore('accounts');
+  handleLoadMoreStatuses = () => this.loadMore('statuses');
+  handleLoadMoreHashtags = () => this.loadMore('hashtags');
+
+  loadMore (type) {
+    const { dispatch } = this.props;
+    dispatch(expandSearch(type));
+  }
+
+  render () {
+    const { intl, isLoading, q, results } = this.props;
+    const { type } = this.state;
+
+    let filteredResults = ImmutableList();
+
+    if (!isLoading) {
+      switch(type) {
+      case 'all':
+        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
+        break;
+      case 'accounts':
+        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
+        break;
+      case 'hashtags':
+        filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
+        break;
+      case 'statuses':
+        filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
+        break;
+      }
+
+      if (filteredResults.size === 0) {
+        filteredResults = (
+          <div className='empty-column-indicator'>
+            <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
+          </div>
+        );
+      }
+    }
+
+    return (
+      <React.Fragment>
+        <div className='account__section-headline'>
+          <button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
+          <button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
+          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
+          <button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></button>
+        </div>
+
+        <div className='explore__search-results'>
+          {isLoading ? <LoadingIndicator /> : filteredResults}
+        </div>
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title, { q })}</title>
+        </Helmet>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Results));
diff --git a/app/javascript/flavours/glitch/features/explore/statuses.jsx b/app/javascript/flavours/glitch/features/explore/statuses.jsx
new file mode 100644
index 000000000..381c50c5d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/explore/statuses.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusList from 'flavours/glitch/components/status_list';
+import { FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { fetchTrendingStatuses, expandTrendingStatuses } from 'flavours/glitch/actions/trends';
+import { debounce } from 'lodash';
+import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'trending', 'items']),
+  isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
+  hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
+});
+
+class Statuses extends React.PureComponent {
+
+  static propTypes = {
+    statusIds: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingStatuses());
+  }
+
+  handleLoadMore = debounce(() => {
+    const { dispatch } = this.props;
+    dispatch(expandTrendingStatuses());
+  }, 300, { leading: true });
+
+  render () {
+    const { isLoading, hasMore, statusIds, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
+
+    return (
+      <>
+        <DismissableBanner id='explore/statuses'>
+          <FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These posts from this and other servers in the decentralized network are gaining traction on this server right now.' />
+        </DismissableBanner>
+
+        <StatusList
+          trackScroll
+          statusIds={statusIds}
+          scrollKey='explore-statuses'
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+          withCounters
+        />
+      </>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Statuses);
diff --git a/app/javascript/flavours/glitch/features/explore/suggestions.jsx b/app/javascript/flavours/glitch/features/explore/suggestions.jsx
new file mode 100644
index 000000000..e1b84098a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/explore/suggestions.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import AccountCard from 'flavours/glitch/features/directory/components/account_card';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
+import { FormattedMessage } from 'react-intl';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['suggestions', 'items']),
+  isLoading: state.getIn(['suggestions', 'isLoading']),
+});
+
+class Suggestions extends React.PureComponent {
+
+  static propTypes = {
+    isLoading: PropTypes.bool,
+    suggestions: ImmutablePropTypes.list,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchSuggestions(true));
+  }
+
+  handleDismiss = (accountId) => {
+    const { dispatch } = this.props;
+    dispatch(dismissSuggestion(accountId));
+  };
+
+  render () {
+    const { isLoading, suggestions } = this.props;
+
+    if (!isLoading && suggestions.isEmpty()) {
+      return (
+        <div className='explore__suggestions scrollable scrollable--flex'>
+          <div className='empty-column-indicator'>
+            <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div className='explore__suggestions'>
+        {isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
+          <AccountCard key={suggestion.get('account')} id={suggestion.get('account')} onDismiss={suggestion.get('source') === 'past_interactions' ? this.handleDismiss : null} />
+        ))}
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Suggestions);
diff --git a/app/javascript/flavours/glitch/features/explore/tags.jsx b/app/javascript/flavours/glitch/features/explore/tags.jsx
new file mode 100644
index 000000000..e0fdd1d91
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/explore/tags.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingHashtags } from 'flavours/glitch/actions/trends';
+import { FormattedMessage } from 'react-intl';
+import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['trends', 'tags', 'items']),
+  isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']),
+});
+
+class Tags extends React.PureComponent {
+
+  static propTypes = {
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingHashtags());
+  }
+
+  render () {
+    const { isLoading, hashtags } = this.props;
+
+    const banner = (
+      <DismissableBanner id='explore/tags'>
+        <FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These hashtags are gaining traction among people on this and other servers of the decentralized network right now.' />
+      </DismissableBanner>
+    );
+
+    if (!isLoading && hashtags.isEmpty()) {
+      return (
+        <div className='explore__links scrollable scrollable--flex'>
+          {banner}
+
+          <div className='empty-column-indicator'>
+            <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div className='explore__links'>
+        {banner}
+
+        {isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => (
+          <Hashtag key={hashtag.get('name')} hashtag={hashtag} />
+        ))}
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Tags);
diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx b/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx
new file mode 100644
index 000000000..60d281f97
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx
@@ -0,0 +1,108 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glitch/actions/favourites';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import StatusList from 'flavours/glitch/components/status_list';
+import Column from 'flavours/glitch/features/ui/components/column';
+
+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']),
+});
+
+class Favourites extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavouritedStatuses());
+  }
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('FAVOURITES', {}));
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFavouritedStatuses());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+    const pinned = !!columnId;
+
+    const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite posts yet. When you favourite one, it will show up here." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}>
+        <ColumnHeader
+          icon='star'
+          title={intl.formatMessage(messages.heading)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+        />
+
+        <StatusList
+          trackScroll={!pinned}
+          statusIds={statusIds}
+          scrollKey={`favourited_statuses-${columnId}`}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        />
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.heading)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/javascript/flavours/glitch/features/favourites/index.jsx b/app/javascript/flavours/glitch/features/favourites/index.jsx
new file mode 100644
index 000000000..21ce7fcc7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/favourites/index.jsx
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import Icon from 'flavours/glitch/components/icon';
+import { fetchFavourites } from 'flavours/glitch/actions/interactions';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  heading: { id: 'column.favourited_by', defaultMessage: 'Favourited by' },
+  refresh: { id: 'refresh', defaultMessage: 'Refresh' },
+});
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
+});
+
+class Favourites extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    if (!this.props.accountIds) {
+      this.props.dispatch(fetchFavourites(this.props.params.statusId));
+    }
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchFavourites(nextProps.params.statusId));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleRefresh = () => {
+    this.props.dispatch(fetchFavourites(this.props.params.statusId));
+  };
+
+  render () {
+    const { intl, accountIds, multiColumn } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this post yet. When someone does, they will show up here.' />;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='star'
+          title={intl.formatMessage(messages.heading)}
+          onClick={this.handleHeaderClick}
+          showBackButton
+          multiColumn={multiColumn}
+          extraButton={(
+            <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
+          )}
+        />
+        <ScrollableList
+          scrollKey='favourites'
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/javascript/flavours/glitch/features/filters/added_to_filter.jsx b/app/javascript/flavours/glitch/features/filters/added_to_filter.jsx
new file mode 100644
index 000000000..2f3f98c81
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/filters/added_to_filter.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'flavours/glitch/utils/filters';
+import Button from 'flavours/glitch/components/button';
+import { connect } from 'react-redux';
+
+const mapStateToProps = (state, { filterId }) => ({
+  filter: state.getIn(['filters', filterId]),
+});
+
+class AddedToFilter extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    contextType: PropTypes.string,
+    filter: ImmutablePropTypes.map.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleCloseClick = () => {
+    const { onClose } = this.props;
+    onClose();
+  };
+
+  render () {
+    const { filter, contextType } = this.props;
+
+    let expiredMessage = null;
+    if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
+      expiredMessage = (
+        <React.Fragment>
+          <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4>
+          <p className='report-dialog-modal__lead'>
+            <FormattedMessage
+              id='filter_modal.added.expired_explanation'
+              defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.'
+            />
+          </p>
+        </React.Fragment>
+      );
+    }
+
+    let contextMismatchMessage = null;
+    if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
+      contextMismatchMessage = (
+        <React.Fragment>
+          <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4>
+          <p className='report-dialog-modal__lead'>
+            <FormattedMessage
+              id='filter_modal.added.context_mismatch_explanation'
+              defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.'
+            />
+          </p>
+        </React.Fragment>
+      );
+    }
+
+    const settings_link = (
+      <a href={`/filters/${filter.get('id')}/edit`}>
+        <FormattedMessage
+          id='filter_modal.added.settings_link'
+          defaultMessage='settings page'
+        />
+      </a>
+    );
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
+        <p className='report-dialog-modal__lead'>
+          <FormattedMessage
+            id='filter_modal.added.short_explanation'
+            defaultMessage='This post has been added to the following filter category: {title}.'
+            values={{ title: filter.get('title') }}
+          />
+        </p>
+
+        {expiredMessage}
+        {contextMismatchMessage}
+
+        <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4>
+        <p className='report-dialog-modal__lead'>
+          <FormattedMessage
+            id='filter_modal.added.review_and_configure'
+            defaultMessage='To review and further configure this filter category, go to the {settings_link}.'
+            values={{ settings_link }}
+          />
+        </p>
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(AddedToFilter);
diff --git a/app/javascript/flavours/glitch/features/filters/select_filter.jsx b/app/javascript/flavours/glitch/features/filters/select_filter.jsx
new file mode 100644
index 000000000..b3285bc91
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/filters/select_filter.jsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'flavours/glitch/utils/filters';
+import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
+import Icon from 'flavours/glitch/components/icon';
+import fuzzysort from 'fuzzysort';
+
+const messages = defineMessages({
+  search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
+  clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
+});
+
+const mapStateToProps = (state, { contextType }) => ({
+  filters: Array.from(state.get('filters').values()).map((filter) => [
+    filter.get('id'),
+    filter.get('title'),
+    filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
+    filter.get('expires_at') && filter.get('expires_at') < new Date(),
+    contextType && !filter.get('context').includes(toServerSideType(contextType)),
+  ]),
+});
+
+class SelectFilter extends React.PureComponent {
+
+  static propTypes = {
+    onSelectFilter: PropTypes.func.isRequired,
+    onNewFilter: PropTypes.func.isRequired,
+    filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    searchValue: '',
+  };
+
+  search () {
+    const { filters } = this.props;
+    const { searchValue } = this.state;
+
+    if (searchValue === '') {
+      return filters;
+    }
+
+    return fuzzysort.go(searchValue, filters, {
+      keys: ['1', '2'],
+      limit: 5,
+      threshold: -10000,
+    }).map(result => result.obj);
+  }
+
+  renderItem = filter => {
+    let warning = null;
+    if (filter[3] || filter[4]) {
+      warning = (
+        <span className='language-dropdown__dropdown__results__item__common-name'>
+          (
+          {filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
+          {filter[3] && filter[4] && ', '}
+          {filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
+          )
+        </span>
+      );
+    }
+
+    return (
+      <div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
+        <span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
+      </div>
+    );
+  };
+
+  renderCreateNew (name) {
+    return (
+      <div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
+        <Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
+      </div>
+    );
+  }
+
+  handleSearchChange = ({ target }) => {
+    this.setState({ searchValue: target.value });
+  };
+
+  setListRef = c => {
+    this.listNode = c;
+  };
+
+  handleKeyDown = e => {
+    const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
+
+    let element = null;
+
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      e.currentTarget.click();
+      break;
+    case 'ArrowDown':
+      element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      } else {
+        element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.listNode.firstChild;
+      break;
+    case 'End':
+      element = this.listNode.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  };
+
+  handleSearchKeyDown = e => {
+    let element = null;
+
+    switch(e.key) {
+    case 'Tab':
+    case 'ArrowDown':
+      element = this.listNode.firstChild;
+
+      if (element) {
+        element.focus();
+        e.preventDefault();
+        e.stopPropagation();
+      }
+
+      break;
+    }
+  };
+
+  handleClear = () => {
+    this.setState({ searchValue: '' });
+  };
+
+  handleItemClick = e => {
+    const value = e.currentTarget.getAttribute('data-index');
+
+    e.preventDefault();
+
+    this.props.onSelectFilter(value);
+  };
+
+  handleNewFilterClick = e => {
+    e.preventDefault();
+
+    this.props.onNewFilter(this.state.searchValue);
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    const { searchValue } = this.state;
+    const isSearching = searchValue !== '';
+    const results = this.search();
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
+
+        <div className='emoji-mart-search'>
+          <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
+          <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
+        </div>
+
+        <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
+          {results.map(this.renderItem)}
+          {isSearching && this.renderCreateNew(searchValue) }
+        </div>
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(SelectFilter));
diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx
new file mode 100644
index 000000000..e56af7364
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import Permalink from 'flavours/glitch/components/permalink';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { injectIntl, defineMessages } from 'react-intl';
+import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.id),
+  });
+
+  return mapStateToProps;
+};
+
+const getFirstSentence = str => {
+  const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
+
+  return arr[0];
+};
+
+class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleFollow = () => {
+    const { account, dispatch } = this.props;
+
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+      dispatch(unfollowAccount(account.get('id')));
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  };
+
+  render () {
+    const { account, intl } = this.props;
+
+    let button;
+
+    if (account.getIn(['relationship', 'following'])) {
+      button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
+    } else {
+      button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
+    }
+
+    return (
+      <div className='account follow-recommendations-account'>
+        <div className='account__wrapper'>
+          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+
+            <DisplayName account={account} />
+
+            <div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
+          </Permalink>
+
+          <div className='account__relationship'>
+            {button}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(makeMapStateToProps)(injectIntl(Account));
diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/index.jsx b/app/javascript/flavours/glitch/features/follow_recommendations/index.jsx
new file mode 100644
index 000000000..70f2191f1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/follow_recommendations/index.jsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
+import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings';
+import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
+import { markAsPartial } from 'flavours/glitch/actions/timelines';
+import Column from 'flavours/glitch/features/ui/components/column';
+import Account from './components/account';
+import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
+import Button from 'flavours/glitch/components/button';
+import { Helmet } from 'react-helmet';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['suggestions', 'items']),
+  isLoading: state.getIn(['suggestions', 'isLoading']),
+});
+
+class FollowRecommendations extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+  };
+
+  componentDidMount () {
+    const { dispatch, suggestions } = this.props;
+
+    // Don't re-fetch if we're e.g. navigating backwards to this page,
+    // since we don't want followed accounts to disappear from the list
+
+    if (suggestions.size === 0) {
+      dispatch(fetchSuggestions(true));
+    }
+  }
+
+  componentWillUnmount () {
+    const { dispatch } = this.props;
+
+    // Force the home timeline to be reloaded when the user navigates
+    // to it; if the user is new, it would've been empty before
+
+    dispatch(markAsPartial('home'));
+  }
+
+  handleDone = () => {
+    const { dispatch } = this.props;
+    const { router } = this.context;
+
+    dispatch(requestBrowserPermission((permission) => {
+      if (permission === 'granted') {
+        dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
+        dispatch(saveSettings());
+      }
+    }));
+
+    router.history.push('/home');
+  };
+
+  render () {
+    const { suggestions, isLoading } = this.props;
+
+    return (
+      <Column>
+        <div className='scrollable follow-recommendations-container'>
+          <div className='column-title'>
+            <svg viewBox='0 0 79 79' className='logo'>
+              <use xlinkHref='#logo-symbol-icon' />
+            </svg>
+
+            <h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
+            <p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
+          </div>
+
+          {!isLoading && (
+            <React.Fragment>
+              <div className='column-list'>
+                {suggestions.size > 0 ? suggestions.map(suggestion => (
+                  <Account key={suggestion.get('account')} id={suggestion.get('account')} />
+                )) : (
+                  <div className='column-list__empty-message'>
+                    <FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
+                  </div>
+                )}
+              </div>
+
+              <div className='column-actions'>
+                <img src={imageGreeting} alt='' className='column-actions__background' />
+                <Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
+              </div>
+            </React.Fragment>
+          )}
+        </div>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(FollowRecommendations);
diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx
new file mode 100644
index 000000000..af8a534fa
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx
@@ -0,0 +1,50 @@
+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' },
+});
+
+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={`/@${account.get('acct')}`} 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 translate' 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>
+    );
+  }
+
+}
+
+export default injectIntl(AccountAuthorize);
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.jsx b/app/javascript/flavours/glitch/features/follow_requests/index.jsx
new file mode 100644
index 000000000..a9a35f54b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/follow_requests/index.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from 'flavours/glitch/actions/accounts';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { me } from 'flavours/glitch/initial_state';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
+  isLoading: state.getIn(['user_lists', 'follow_requests', 'isLoading'], true),
+  hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
+  locked: !!state.getIn(['accounts', me, 'locked']),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+class FollowRequests extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list,
+    locked: PropTypes.bool,
+    domain: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchFollowRequests());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowRequests());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;
+    const unlockedPrependMessage = !locked && accountIds.size > 0 && (
+      <div className='follow_requests-unlocked_explanation'>
+        <FormattedMessage
+          id='follow_requests.unlocked_explanation'
+          defaultMessage='Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.'
+          values={{ domain: domain }}
+        />
+      </div>
+    );
+
+    return (
+      <Column bindToDocument={!multiColumn} name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <ScrollableList
+          scrollKey='follow_requests'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          showLoading={isLoading && accountIds.size === 0}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+          prepend={unlockedPrependMessage}
+        >
+          {accountIds.map(id =>
+            <AccountAuthorizeContainer key={id} id={id} />,
+          )}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(FollowRequests));
diff --git a/app/javascript/flavours/glitch/features/followed_tags/index.jsx b/app/javascript/flavours/glitch/features/followed_tags/index.jsx
new file mode 100644
index 000000000..a5abb151f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followed_tags/index.jsx
@@ -0,0 +1,89 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import Column from 'flavours/glitch/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import { expandFollowedHashtags, fetchFollowedHashtags } from 'flavours/glitch/actions/tags';
+
+const messages = defineMessages({
+  heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
+});
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['followed_tags', 'items']),
+  isLoading: state.getIn(['followed_tags', 'isLoading'], true),
+  hasMore: !!state.getIn(['followed_tags', 'next']),
+});
+
+class FollowedTags extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentDidMount() {
+    this.props.dispatch(fetchFollowedHashtags());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowedHashtags());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
+
+    return (
+      <Column bindToDocument={!multiColumn}>
+        <ColumnHeader
+          icon='hashtag'
+          title={intl.formatMessage(messages.heading)}
+          showBackButton
+          multiColumn={multiColumn}
+        />
+
+        <ScrollableList
+          scrollKey='followed_tags'
+          emptyMessage={emptyMessage}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          bindToDocument={!multiColumn}
+        >
+          {hashtags.map((hashtag) => (
+            <Hashtag
+              key={hashtag.get('name')}
+              name={hashtag.get('name')}
+              to={`/tags/${hashtag.get('name')}`}
+              withGraph={false}
+              // Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
+              people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+              history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+            />
+          ))}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(FollowedTags));
diff --git a/app/javascript/flavours/glitch/features/followers/index.jsx b/app/javascript/flavours/glitch/features/followers/index.jsx
new file mode 100644
index 000000000..2565772d1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followers/index.jsx
@@ -0,0 +1,175 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import {
+  lookupAccount,
+  fetchAccount,
+  fetchFollowers,
+  expandFollowers,
+} from 'flavours/glitch/actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
+import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { getAccountHidden } from 'flavours/glitch/selectors';
+import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
+
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+    hidden: getAccountHidden(state, accountId),
+  };
+};
+
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
+class Followers extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    suspended: PropTypes.bool,
+    hidden: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowers(accountId));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowers(this.props.accountId));
+  }, 300, { leading: true });
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  render () {
+    const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    let emptyMessage;
+
+    const forceEmptyState = suspended || hidden;
+
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (hidden) {
+      emptyMessage = <LimitedAccountHint accountId={accountId} />;
+    } else if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
+
+    return (
+      <Column ref={this.setRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <ScrollableList
+          scrollKey='followers'
+          hasMore={!forceEmptyState && hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
+          alwaysPrepend
+          append={remoteMessage}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Followers);
diff --git a/app/javascript/flavours/glitch/features/following/index.jsx b/app/javascript/flavours/glitch/features/following/index.jsx
new file mode 100644
index 000000000..2c05e3310
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/following/index.jsx
@@ -0,0 +1,175 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import {
+  lookupAccount,
+  fetchAccount,
+  fetchFollowing,
+  expandFollowing,
+} from 'flavours/glitch/actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
+import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { getAccountHidden } from 'flavours/glitch/selectors';
+import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
+
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+    hidden: getAccountHidden(state, accountId),
+  };
+};
+
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
+class Following extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    suspended: PropTypes.bool,
+    hidden: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowing(accountId));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowing(this.props.accountId));
+  }, 300, { leading: true });
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  render () {
+    const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    let emptyMessage;
+
+    const forceEmptyState = suspended || hidden;
+
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (hidden) {
+      emptyMessage = <LimitedAccountHint accountId={accountId} />;
+    } else if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
+
+    return (
+      <Column ref={this.setRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <ScrollableList
+          scrollKey='following'
+          hasMore={!forceEmptyState && hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
+          alwaysPrepend
+          append={remoteMessage}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Following);
diff --git a/app/javascript/flavours/glitch/features/generic_not_found/index.jsx b/app/javascript/flavours/glitch/features/generic_not_found/index.jsx
new file mode 100644
index 000000000..4412adaed
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/generic_not_found/index.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from 'flavours/glitch/features/ui/components/column';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+
+const GenericNotFound = () => (
+  <Column>
+    <MissingIndicator fullPage />
+  </Column>
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx b/app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx
new file mode 100644
index 000000000..5c3a27f93
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx
@@ -0,0 +1,450 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Icon from 'flavours/glitch/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
+import { autoPlayGif, reduceMotion, disableSwiping, mascot } from 'flavours/glitch/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import unicodeMapping from 'flavours/glitch/features/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'flavours/glitch/features/compose/containers/emoji_picker_dropdown_container';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import TransitionMotion from 'react-motion/lib/TransitionMotion';
+import spring from 'react-motion/lib/spring';
+import { assetHost } from 'flavours/glitch/utils/config';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+class Content extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+  };
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  componentDidMount () {
+    this._updateLinks();
+  }
+
+  componentDidUpdate () {
+    this._updateLinks();
+  }
+
+  _updateLinks () {
+    const node = this.node;
+
+    if (!node) {
+      return;
+    }
+
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link = links[i];
+
+      if (link.classList.contains('status-link')) {
+        continue;
+      }
+
+      link.classList.add('status-link');
+
+      let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+      if (mention) {
+        link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+        link.setAttribute('title', mention.get('acct'));
+      } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+        link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+      } else {
+        let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
+        if (status) {
+          link.addEventListener('click', this.onStatusClick.bind(this, status), false);
+        }
+        link.setAttribute('title', link.href);
+        link.classList.add('unhandled-link');
+      }
+
+      link.setAttribute('target', '_blank');
+      link.setAttribute('rel', 'noopener noreferrer');
+    }
+  }
+
+  onMentionClick = (mention, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/@${mention.get('acct')}`);
+    }
+  };
+
+  onHashtagClick = (hashtag, e) => {
+    hashtag = hashtag.replace(/^#/, '');
+
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/tags/${hashtag}`);
+    }
+  };
+
+  onStatusClick = (status, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
+    }
+  };
+
+  handleMouseEnter = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-original');
+    }
+  };
+
+  handleMouseLeave = ({ currentTarget }) => {
+    if (autoPlayGif) {
+      return;
+    }
+
+    const emojis = currentTarget.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      emoji.src = emoji.getAttribute('data-static');
+    }
+  };
+
+  render () {
+    const { announcement } = this.props;
+
+    return (
+      <div
+        className='announcements__item__content translate'
+        ref={this.setRef}
+        dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+      />
+    );
+  }
+
+}
+
+class Emoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.string.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    hovered: PropTypes.bool.isRequired,
+  };
+
+  render () {
+    const { emoji, emojiMap, hovered } = this.props;
+
+    if (unicodeMapping[emoji]) {
+      const { filename, shortCode } = unicodeMapping[this.props.emoji];
+      const title = shortCode ? `:${shortCode}:` : '';
+
+      return (
+        <img
+          draggable='false'
+          className='emojione'
+          alt={emoji}
+          title={title}
+          src={`${assetHost}/emoji/${filename}.svg`}
+        />
+      );
+    } else if (emojiMap.get(emoji)) {
+      const filename  = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+      const shortCode = `:${emoji}:`;
+
+      return (
+        <img
+          draggable='false'
+          className='emojione custom-emoji'
+          alt={shortCode}
+          title={shortCode}
+          src={filename}
+        />
+      );
+    } else {
+      return null;
+    }
+  }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reaction: ImmutablePropTypes.map.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    style: PropTypes.object,
+  };
+
+  state = {
+    hovered: false,
+  };
+
+  handleClick = () => {
+    const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+    if (reaction.get('me')) {
+      removeReaction(announcementId, reaction.get('name'));
+    } else {
+      addReaction(announcementId, reaction.get('name'));
+    }
+  };
+
+  handleMouseEnter = () => this.setState({ hovered: true });
+
+  handleMouseLeave = () => this.setState({ hovered: false });
+
+  render () {
+    const { reaction } = this.props;
+
+    let shortCode = reaction.get('name');
+
+    if (unicodeMapping[shortCode]) {
+      shortCode = unicodeMapping[shortCode].shortCode;
+    }
+
+    return (
+      <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
+        <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
+        <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
+      </button>
+    );
+  }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reactions: ImmutablePropTypes.list.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleEmojiPick = data => {
+    const { addReaction, announcementId } = this.props;
+    addReaction(announcementId, data.native.replace(/:/g, ''));
+  };
+
+  willEnter () {
+    return { scale: reduceMotion ? 1 : 0 };
+  }
+
+  willLeave () {
+    return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
+  }
+
+  render () {
+    const { reactions } = this.props;
+    const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+    const styles = visibleReactions.map(reaction => ({
+      key: reaction.get('name'),
+      data: reaction,
+      style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
+    })).toArray();
+
+    return (
+      <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
+        {items => (
+          <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
+            {items.map(({ key, data, style }) => (
+              <Reaction
+                key={key}
+                reaction={data}
+                style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
+                announcementId={this.props.announcementId}
+                addReaction={this.props.addReaction}
+                removeReaction={this.props.removeReaction}
+                emojiMap={this.props.emojiMap}
+              />
+            ))}
+
+            {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
+          </div>
+        )}
+      </TransitionMotion>
+    );
+  }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    selected: PropTypes.bool,
+  };
+
+  state = {
+    unread: !this.props.announcement.get('read'),
+  };
+
+  componentDidUpdate () {
+    const { selected, announcement } = this.props;
+    if (!selected && this.state.unread !== !announcement.get('read')) {
+      this.setState({ unread: !announcement.get('read') });
+    }
+  }
+
+  render () {
+    const { announcement } = this.props;
+    const { unread } = this.state;
+    const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+    const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+    const now = new Date();
+    const hasTimeRange = startsAt && endsAt;
+    const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+    const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+    const skipTime = announcement.get('all_day');
+
+    return (
+      <div className='announcements__item'>
+        <strong className='announcements__item__range'>
+          <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
+          {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
+        </strong>
+
+        <Content announcement={announcement} />
+
+        <ReactionsBar
+          reactions={announcement.get('reactions')}
+          announcementId={announcement.get('id')}
+          addReaction={this.props.addReaction}
+          removeReaction={this.props.removeReaction}
+          emojiMap={this.props.emojiMap}
+        />
+
+        {unread && <span className='announcements__item__unread' />}
+      </div>
+    );
+  }
+
+}
+
+class Announcements extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcements: ImmutablePropTypes.list,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: 0,
+  };
+
+  static getDerivedStateFromProps(props, state) {
+    if (props.announcements.size > 0 && state.index >= props.announcements.size) {
+      return { index: props.announcements.size - 1 };
+    } else {
+      return null;
+    }
+  }
+
+  componentDidMount () {
+    this._markAnnouncementAsRead();
+  }
+
+  componentDidUpdate () {
+    this._markAnnouncementAsRead();
+  }
+
+  _markAnnouncementAsRead () {
+    const { dismissAnnouncement, announcements } = this.props;
+    const { index } = this.state;
+    const announcement = announcements.get(index);
+    if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
+  }
+
+  handleChangeIndex = index => {
+    this.setState({ index: index % this.props.announcements.size });
+  };
+
+  handleNextClick = () => {
+    this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+  };
+
+  handlePrevClick = () => {
+    this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+  };
+
+  render () {
+    const { announcements, intl } = this.props;
+    const { index } = this.state;
+
+    if (announcements.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='announcements'>
+        <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
+
+        <div className='announcements__container'>
+          <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
+            {announcements.map((announcement, idx) => (
+              <Announcement
+                key={announcement.get('id')}
+                announcement={announcement}
+                emojiMap={this.props.emojiMap}
+                addReaction={this.props.addReaction}
+                removeReaction={this.props.removeReaction}
+                intl={intl}
+                selected={index === idx}
+                disabled={disableSwiping}
+              />
+            ))}
+          </ReactSwipeableViews>
+
+          {announcements.size > 1 && (
+            <div className='announcements__pagination'>
+              <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
+              <span>{index + 1} / {announcements.size}</span>
+              <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Announcements);
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.jsx b/app/javascript/flavours/glitch/features/getting_started/components/trends.jsx
new file mode 100644
index 000000000..d7e222d71
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router-dom';
+
+export default class Trends extends ImmutablePureComponent {
+
+  static defaultProps = {
+    loading: false,
+  };
+
+  static propTypes = {
+    trends: ImmutablePropTypes.list,
+    fetchTrends: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    this.props.fetchTrends();
+    this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
+  }
+
+  componentWillUnmount () {
+    if (this.refreshInterval) {
+      clearInterval(this.refreshInterval);
+    }
+  }
+
+  render () {
+    const { trends } = this.props;
+
+    if (!trends || trends.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='getting-started__trends'>
+        <h4>
+          <Link to={'/explore/tags'}>
+            <FormattedMessage id='trends.trending_now' defaultMessage='Trending now' />
+          </Link>
+        </h4>
+
+        {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js
new file mode 100644
index 000000000..d472323f8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux';
+import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/glitch/actions/announcements';
+import Announcements from '../components/announcements';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
+
+const mapStateToProps = state => ({
+  announcements: state.getIn(['announcements', 'items']),
+  emojiMap: customEmojiMap(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+  dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
+  addReaction: (id, name) => dispatch(addReaction(id, name)),
+  removeReaction: (id, name) => dispatch(removeReaction(id, name)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Announcements);
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
new file mode 100644
index 000000000..d88dbbaf4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { fetchTrendingHashtags } from 'flavours/glitch/actions/trends';
+import Trends from '../components/trends';
+
+const mapStateToProps = state => ({
+  trends: state.getIn(['trends', 'tags', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchTrends: () => dispatch(fetchTrendingHashtags()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Trends);
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.jsx b/app/javascript/flavours/glitch/features/getting_started/index.jsx
new file mode 100644
index 000000000..4064a5451
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/index.jsx
@@ -0,0 +1,204 @@
+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, showTrends } from 'flavours/glitch/initial_state';
+import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
+import { List as ImmutableList } from 'immutable';
+import { createSelector } from 'reselect';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import { preferencesLink } from 'flavours/glitch/utils/backend_links';
+import NavigationBar from '../compose/components/navigation_bar';
+import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
+import TrendsContainer from './containers/trends_container';
+import { Helmet } from 'react-helmet';
+
+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' },
+  explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
+  direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+  bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' },
+  misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' },
+  menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+});
+
+const makeMapStateToProps = () => {
+  const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+    if (!lists) {
+      return lists;
+    }
+
+    return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+  });
+
+  const mapStateToProps = state => ({
+    lists: getOrderedLists(state),
+    myAccount: state.getIn(['accounts', me]),
+    columns: state.getIn(['settings', 'columns']),
+    unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
+    unreadNotifications: state.getIn(['notifications', 'unread']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+  fetchFollowRequests: () => dispatch(fetchFollowRequests()),
+  fetchLists: () => dispatch(fetchLists()),
+  openSettings: () => dispatch(openModal('SETTINGS', {})),
+});
+
+const badgeDisplay = (number, limit) => {
+  if (number === 0) {
+    return undefined;
+  } else if (limit && number >= limit) {
+    return `${limit}+`;
+  } else {
+    return number;
+  }
+};
+
+const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
+
+class GettingStarted extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    myAccount: ImmutablePropTypes.map,
+    columns: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    fetchFollowRequests: PropTypes.func.isRequired,
+    unreadFollowRequests: PropTypes.number,
+    unreadNotifications: PropTypes.number,
+    lists: ImmutablePropTypes.list,
+    fetchLists: PropTypes.func.isRequired,
+    openSettings: PropTypes.func.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.fetchLists();
+  }
+
+  componentDidMount () {
+    const { fetchFollowRequests } = this.props;
+    const { signedIn } = this.context.identity;
+
+    if (!signedIn) {
+      return;
+    }
+
+    fetchFollowRequests();
+  }
+
+  render () {
+    const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications, lists, openSettings } = this.props;
+    const { signedIn } = this.context.identity;
+
+    const navItems = [];
+    let listItems = [];
+
+    if (multiColumn) {
+      if (signedIn && !columns.find(item => item.get('id') === 'HOME')) {
+        navItems.push(<ColumnLink key='home' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/home' />);
+      }
+
+      if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
+        navItems.push(<ColumnLink key='notifications' icon='bell' text={intl.formatMessage(messages.notifications)} badge={badgeDisplay(unreadNotifications)} to='/notifications' />);
+      }
+
+      if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
+        navItems.push(<ColumnLink key='community_timeline' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/public/local' />);
+      }
+
+      if (!columns.find(item => item.get('id') === 'PUBLIC')) {
+        navItems.push(<ColumnLink key='public_timeline' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/public' />);
+      }
+    }
+
+    if (showTrends) {
+      navItems.push(<ColumnLink key='explore' icon='hashtag' text={intl.formatMessage(messages.explore)} to='/explore' />);
+    }
+
+    if (signedIn) {
+      if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
+        navItems.push(<ColumnLink key='conversations' icon='envelope' text={intl.formatMessage(messages.direct)} to='/conversations' />);
+      }
+
+      if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) {
+        navItems.push(<ColumnLink key='bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />);
+      }
+
+      if (myAccount.get('locked') || unreadFollowRequests > 0) {
+        navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
+      }
+
+      navItems.push(<ColumnLink key='getting_started' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
+
+      listItems = listItems.concat([
+        <div key='9'>
+          <ColumnLink key='lists' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
+          {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list =>
+            <ColumnLink key={`list-${list.get('id')}`} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
+          )}
+        </div>,
+      ]);
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn} name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile>
+        <div className='scrollable optionally-scrollable'>
+          <div className='getting-started__wrapper'>
+            {!multiColumn && signedIn && <NavigationBar account={myAccount} />}
+            {multiColumn && <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />}
+            {navItems}
+            {signedIn && (
+              <React.Fragment>
+                <ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} />
+                {listItems}
+                <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
+                { preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> }
+                <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} />
+              </React.Fragment>
+            )}
+          </div>
+
+          <LinkFooter />
+        </div>
+
+        {multiColumn && showTrends && <TrendsContainer />}
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.menu)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(GettingStarted));
diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx b/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx
new file mode 100644
index 000000000..fb4ec2fce
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
+import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { connect } from 'react-redux';
+
+const messages = defineMessages({
+  heading: { id: 'column.heading', defaultMessage: 'Misc' },
+  subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
+  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+  keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
+  featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },
+});
+
+class GettingStartedMisc extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  openOnboardingModal = (e) => {
+    this.props.dispatch(openModal('ONBOARDING'));
+  };
+
+  openFeaturedAccountsModal = (e) => {
+    this.props.dispatch(openModal('PINNED_ACCOUNTS_EDITOR'));
+  };
+
+  render () {
+    const { intl } = this.props;
+    const { signedIn } = this.context.identity;
+
+    return (
+      <Column icon='ellipsis-h' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <div className='scrollable'>
+          <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
+          {signedIn && (<ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />)}
+          {signedIn && (<ColumnLink key='pinned' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />)}
+          {signedIn && (<ColumnLink key='featured_users' icon='users' text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} />)}
+          {signedIn && (<ColumnLink key='mutes' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />)}
+          {signedIn && (<ColumnLink key='blocks' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />)}
+          {signedIn && (<ColumnLink key='domain_blocks' icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />)}
+          <ColumnLink key='shortcuts' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
+          {signedIn && (<ColumnLink key='onboarding' icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />)}
+        </div>
+      </Column>
+    );
+  }
+
+}
+
+export default connect()(injectIntl(GettingStartedMisc));
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..f140f2d01
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import AsyncSelect from 'react-select/async';
+import { NonceProvider } from 'react-select';
+import SettingToggle from '../../notifications/components/setting_toggle';
+
+const messages = defineMessages({
+  placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
+  noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
+});
+
+class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onLoad: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    open: this.hasTags(),
+  };
+
+  hasTags () {
+    return ['all', 'any', 'none'].map(mode => this.tags(mode).length > 0).includes(true);
+  }
+
+  tags (mode) {
+    let tags = this.props.settings.getIn(['tags', mode]) || [];
+
+    if (tags.toJS) {
+      return tags.toJS();
+    } else {
+      return tags;
+    }
+  }
+
+  onSelect = mode => value => {
+    const oldValue = this.tags(mode);
+
+    // Prevent changes that add more than 4 tags, but allow removing
+    // tags that were already added before
+    if ((value.length > 4) && !(value < oldValue)) {
+      return;
+    }
+
+    this.props.onChange(['tags', mode], value);
+  };
+
+  onToggle = () => {
+    if (this.state.open && this.hasTags()) {
+      this.props.onChange('tags', {});
+    }
+
+    this.setState({ open: !this.state.open });
+  };
+
+  noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
+
+  modeSelect (mode) {
+    return (
+      <div className='column-settings__row'>
+        <span className='column-settings__section'>
+          {this.modeLabel(mode)}
+        </span>
+
+        <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='tags'>
+          <AsyncSelect
+            isMulti
+            autoFocus
+            value={this.tags(mode)}
+            onChange={this.onSelect(mode)}
+            loadOptions={this.props.onLoad}
+            className='column-select__container'
+            classNamePrefix='column-select'
+            name='tags'
+            placeholder={this.props.intl.formatMessage(messages.placeholder)}
+            noOptionsMessage={this.noOptionsMessage}
+          />
+        </NonceProvider>
+      </div>
+    );
+  }
+
+  modeLabel (mode) {
+    switch(mode) {
+    case 'any':
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
+    case 'all':
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
+    case 'none':
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
+    default:
+      return '';
+    }
+  }
+
+  render () {
+    const { settings, onChange } = this.props;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <div className='setting-toggle'>
+            <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
+
+            <span className='setting-toggle__label'>
+              <FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
+            </span>
+          </div>
+        </div>
+
+        {this.state.open && (
+          <div className='column-settings__hashtags'>
+            {this.modeSelect('any')}
+            {this.modeSelect('all')}
+            {this.modeSelect('none')}
+          </div>
+        )}
+
+        <div className='column-settings__row'>
+          <SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..004856b04
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeColumnParams } from 'flavours/glitch/actions/columns';
+import api from 'flavours/glitch/api';
+
+const mapStateToProps = (state, { columnId }) => {
+  const columns = state.getIn(['settings', 'columns']);
+  const index   = columns.findIndex(c => c.get('uuid') === columnId);
+
+  if (!(columnId && index >= 0)) {
+    return {};
+  }
+
+  return {
+    settings: columns.get(index).get('params'),
+    onLoad (value) {
+      return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
+        return (response.data.hashtags || []).map((tag) => {
+          return { value: tag.name, label: `#${tag.name}` };
+        });
+      });
+    },
+  };
+};
+
+const mapDispatchToProps = (dispatch, { columnId }) => ({
+  onChange (key, value) {
+    dispatch(changeColumnParams(columnId, key, value));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx b/app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx
new file mode 100644
index 000000000..fe5afa240
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx
@@ -0,0 +1,241 @@
+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 ImmutablePropTypes from 'react-immutable-proptypes';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
+import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
+import { isEqual } from 'lodash';
+import { fetchHashtag, followHashtag, unfollowHashtag } from 'flavours/glitch/actions/tags';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
+  unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
+});
+
+const mapStateToProps = (state, props) => ({
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
+  tag: state.getIn(['tags', props.params.id]),
+});
+
+class HashtagTimeline extends React.PureComponent {
+
+  disconnects = [];
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    hasUnread: PropTypes.bool,
+    tag: ImmutablePropTypes.map,
+    multiColumn: PropTypes.bool,
+    intl: PropTypes.object,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
+    }
+  };
+
+  title = () => {
+    const { id } = this.props.params;
+    const title  = [id];
+
+    if (this.additionalFor('any')) {
+      title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any'  values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
+    }
+
+    if (this.additionalFor('all')) {
+      title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all'  values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
+    }
+
+    if (this.additionalFor('none')) {
+      title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
+    }
+
+    return title;
+  };
+
+  additionalFor = (mode) => {
+    const { tags } = this.props.params;
+
+    if (tags && (tags[mode] || []).length > 0) {
+      return tags[mode].map(tag => tag.value).join('/');
+    } else {
+      return '';
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  _subscribe (dispatch, id, tags = {}, local) {
+    const { signedIn } = this.context.identity;
+
+    if (!signedIn) {
+      return;
+    }
+
+    let any  = (tags.any || []).map(tag => tag.value);
+    let all  = (tags.all || []).map(tag => tag.value);
+    let none = (tags.none || []).map(tag => tag.value);
+
+    [id, ...any].map(tag => {
+      this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
+        let tags = status.tags.map(tag => tag.name);
+
+        return all.filter(tag => tags.includes(tag)).length === all.length &&
+               none.filter(tag => tags.includes(tag)).length === 0;
+      })));
+    });
+  }
+
+  _unsubscribe () {
+    this.disconnects.map(disconnect => disconnect());
+    this.disconnects = [];
+  }
+
+  _unload () {
+    const { dispatch } = this.props;
+    const { id, local } = this.props.params;
+
+    this._unsubscribe();
+    dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
+  }
+
+  _load() {
+    const { dispatch } = this.props;
+    const { id, tags, local } = this.props.params;
+
+    this._subscribe(dispatch, id, tags, local);
+    dispatch(expandHashtagTimeline(id, { tags, local }));
+    dispatch(fetchHashtag(id));
+  }
+
+  componentDidMount () {
+    this._load();
+  }
+
+  componentDidUpdate (prevProps) {
+    const { params } = this.props;
+    const { id, tags, local } = prevProps.params;
+
+    if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
+      this._unload();
+      this._load();
+    }
+  }
+
+  componentWillUnmount () {
+    this._unsubscribe();
+  }
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMore = maxId => {
+    const { dispatch, params } = this.props;
+    const { id, tags, local }  = params;
+
+    dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
+  };
+
+  handleFollow = () => {
+    const { dispatch, params, tag } = this.props;
+    const { id } = params;
+    const { signedIn } = this.context.identity;
+
+    if (!signedIn) {
+      return;
+    }
+
+    if (tag.get('following')) {
+      dispatch(unfollowHashtag(id));
+    } else {
+      dispatch(followHashtag(id));
+    }
+  };
+
+  render () {
+    const { hasUnread, columnId, multiColumn, tag, intl } = this.props;
+    const { id, local } = this.props.params;
+    const pinned = !!columnId;
+    const { signedIn } = this.context.identity;
+
+    let followButton;
+
+    if (tag) {
+      const following = tag.get('following');
+
+      const classes = classNames('column-header__button', {
+        active: following,
+      });
+
+      followButton = (
+        <button className={classes} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
+          <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
+        </button>
+      );
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}>
+        <ColumnHeader
+          icon='hashtag'
+          active={hasUnread}
+          title={this.title()}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          extraButton={followButton}
+          showBackButton
+        >
+          {columnId && <ColumnSettingsContainer columnId={columnId} />}
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`hashtag_timeline-${columnId}`}
+          timelineId={`hashtag:${id}${local ? ':local' : ''}`}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
+          bindToDocument={!multiColumn}
+        />
+
+        <Helmet>
+          <title>#{id}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(HashtagTimeline));
diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..1eeeaa378
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import 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' },
+});
+
+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>
+    );
+  }
+
+}
+
+export default injectIntl(ColumnSettings);
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.jsx b/app/javascript/flavours/glitch/features/home_timeline/index.jsx
new file mode 100644
index 000000000..71619394b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/home_timeline/index.jsx
@@ -0,0 +1,178 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router-dom';
+import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements';
+import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
+import classNames from 'classnames';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+import NotSignedInIndicator from 'flavours/glitch/components/not_signed_in_indicator';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  title: { id: 'column.home', defaultMessage: 'Home' },
+  show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
+  hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
+  isPartial: state.getIn(['timelines', 'home', 'isPartial']),
+  hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
+  unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
+  showAnnouncements: state.getIn(['announcements', 'show']),
+  regex: state.getIn(['settings', 'home', 'regex', 'body']),
+});
+
+class HomeTimeline extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    isPartial: PropTypes.bool,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasAnnouncements: PropTypes.bool,
+    unreadAnnouncements: PropTypes.number,
+    showAnnouncements: PropTypes.bool,
+    regex: PropTypes.string,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('HOME', {}));
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHomeTimeline({ maxId }));
+  };
+
+  componentDidMount () {
+    setTimeout(() => this.props.dispatch(fetchAnnouncements()), 700);
+    this._checkIfReloadNeeded(false, this.props.isPartial);
+  }
+
+  componentDidUpdate (prevProps) {
+    this._checkIfReloadNeeded(prevProps.isPartial, this.props.isPartial);
+  }
+
+  componentWillUnmount () {
+    this._stopPolling();
+  }
+
+  _checkIfReloadNeeded (wasPartial, isPartial) {
+    const { dispatch } = this.props;
+
+    if (wasPartial === isPartial) {
+      return;
+    } else if (!wasPartial && isPartial) {
+      this.polling = setInterval(() => {
+        dispatch(expandHomeTimeline());
+      }, 3000);
+    } else if (wasPartial && !isPartial) {
+      this._stopPolling();
+    }
+  }
+
+  _stopPolling () {
+    if (this.polling) {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleToggleAnnouncementsClick = (e) => {
+    e.stopPropagation();
+    this.props.dispatch(toggleShowAnnouncements());
+  };
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
+    const pinned = !!columnId;
+    const { signedIn } = this.context.identity;
+
+    let announcementsButton = null;
+
+    if (hasAnnouncements) {
+      announcementsButton = (
+        <button
+          className={classNames('column-header__button', { 'active': showAnnouncements })}
+          title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
+          aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
+          onClick={this.handleToggleAnnouncementsClick}
+        >
+          <IconWithBadge id='bullhorn' count={unreadAnnouncements} />
+        </button>
+      );
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='home'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          extraButton={announcementsButton}
+          appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        {signedIn ? (
+          <StatusListContainer
+            trackScroll={!pinned}
+            scrollKey={`home_timeline-${columnId}`}
+            onLoadMore={this.handleLoadMore}
+            timelineId='home'
+            emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
+            bindToDocument={!multiColumn}
+            regex={this.props.regex}
+          />
+        ) : <NotSignedInIndicator />}
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(HomeTimeline));
diff --git a/app/javascript/flavours/glitch/features/interaction_modal/index.jsx b/app/javascript/flavours/glitch/features/interaction_modal/index.jsx
new file mode 100644
index 000000000..20e4959e6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/interaction_modal/index.jsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { registrationsOpen } from 'flavours/glitch/initial_state';
+import { connect } from 'react-redux';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+
+const mapStateToProps = (state, { accountId }) => ({
+  displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+  onSignupClick() {
+    dispatch(closeModal());
+    dispatch(openModal('CLOSED_REGISTRATIONS'));
+  },
+});
+
+class Copypaste extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+  };
+
+  state = {
+    copied: false,
+  };
+
+  setRef = c => {
+    this.input = c;
+  };
+
+  handleInputClick = () => {
+    this.setState({ copied: false });
+    this.input.focus();
+    this.input.select();
+    this.input.setSelectionRange(0, this.input.value.length);
+  };
+
+  handleButtonClick = () => {
+    const { value } = this.props;
+    navigator.clipboard.writeText(value);
+    this.input.blur();
+    this.setState({ copied: true });
+    this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
+  };
+
+  componentWillUnmount () {
+    if (this.timeout) clearTimeout(this.timeout);
+  }
+
+  render () {
+    const { value } = this.props;
+    const { copied } = this.state;
+
+    return (
+      <div className={classNames('copypaste', { copied })}>
+        <input
+          type='text'
+          ref={this.setRef}
+          value={value}
+          readOnly
+          onClick={this.handleInputClick}
+        />
+
+        <button className='button' onClick={this.handleButtonClick}>
+          {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy' defaultMessage='Copy' />}
+        </button>
+      </div>
+    );
+  }
+
+}
+
+class InteractionModal extends React.PureComponent {
+
+  static propTypes = {
+    displayNameHtml: PropTypes.string,
+    url: PropTypes.string,
+    type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
+    onSignupClick: PropTypes.func.isRequired,
+  };
+
+  handleSignupClick = () => {
+    this.props.onSignupClick();
+  };
+
+  render () {
+    const { url, type, displayNameHtml } = this.props;
+
+    const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
+
+    let title, actionDescription, icon;
+
+    switch(type) {
+    case 'reply':
+      icon = <Icon id='reply' />;
+      title = <FormattedMessage id='interaction_modal.title.reply' defaultMessage="Reply to {name}'s post" values={{ name }} />;
+      actionDescription = <FormattedMessage id='interaction_modal.description.reply' defaultMessage='With an account on Mastodon, you can respond to this post.' />;
+      break;
+    case 'reblog':
+      icon = <Icon id='retweet' />;
+      title = <FormattedMessage id='interaction_modal.title.reblog' defaultMessage="Boost {name}'s post" values={{ name }} />;
+      actionDescription = <FormattedMessage id='interaction_modal.description.reblog' defaultMessage='With an account on Mastodon, you can boost this post to share it with your own followers.' />;
+      break;
+    case 'favourite':
+      icon = <Icon id='star' />;
+      title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favourite {name}'s post" values={{ name }} />;
+      actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.' />;
+      break;
+    case 'follow':
+      icon = <Icon id='user-plus' />;
+      title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />;
+      actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />;
+      break;
+    }
+
+    let signupButton;
+
+    if (registrationsOpen) {
+      signupButton = (
+        <a href='/auth/sign_up' className='button button--block button-tertiary'>
+          <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+        </a>
+      );
+    } else {
+      signupButton = (
+        <button className='button button--block button-tertiary' onClick={this.handleSignupClick}>
+          <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+        </button>
+      );
+    }
+
+    return (
+      <div className='modal-root__modal interaction-modal'>
+        <div className='interaction-modal__lead'>
+          <h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3>
+          <p>{actionDescription} <FormattedMessage id='interaction_modal.preamble' defaultMessage="Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one." /></p>
+        </div>
+
+        <div className='interaction-modal__choices'>
+          <div className='interaction-modal__choices__choice'>
+            <h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
+            <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
+            {signupButton}
+          </div>
+
+          <div className='interaction-modal__choices__choice'>
+            <h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
+            <p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Copy and paste this URL into the search field of your favourite Mastodon app or the web interface of your Mastodon server.' /></p>
+            <Copypaste value={url} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(InteractionModal);
diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx
new file mode 100644
index 000000000..7160e7efb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx
@@ -0,0 +1,149 @@
+import React from 'react';
+import Column from 'flavours/glitch/components/column';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
+});
+
+const mapStateToProps = state => ({
+  collapseEnabled: state.getIn(['local_settings', 'collapsed', 'enabled']),
+});
+
+class KeyboardShortcuts extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    collapseEnabled: PropTypes.bool,
+  };
+
+  render () {
+    const { intl, collapseEnabled, multiColumn } = this.props;
+
+    return (
+      <Column>
+        <ColumnHeader
+          title={intl.formatMessage(messages.heading)}
+          icon='question'
+          multiColumn={multiColumn}
+        />
+
+        <div className='keyboard-shortcuts scrollable optionally-scrollable'>
+          <table>
+            <thead>
+              <tr>
+                <th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
+                <th><FormattedMessage id='keyboard_shortcuts.description' defaultMessage='Description' /></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td><kbd>r</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></td>
+              </tr>
+              <tr>
+                <td><kbd>m</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></td>
+              </tr>
+              <tr>
+                <td><kbd>p</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></td>
+              </tr>
+              <tr>
+                <td><kbd>f</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to favourite' /></td>
+              </tr>
+              <tr>
+                <td><kbd>b</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
+              </tr>
+              <tr>
+                <td><kbd>d</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.bookmark' defaultMessage='to bookmark' /></td>
+              </tr>
+              <tr>
+                <td><kbd>enter</kbd>, <kbd>o</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
+              </tr>
+              <tr>
+                <td><kbd>e</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td>
+              </tr>
+              <tr>
+                <td><kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
+              </tr>
+              <tr>
+                <td><kbd>h</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
+              </tr>
+              {collapseEnabled && (
+                <tr>
+                  <td><kbd>shift</kbd>+<kbd>x</kbd></td>
+                  <td><FormattedMessage id='keyboard_shortcuts.toggle_collapse' defaultMessage='to collapse/uncollapse toots' /></td>
+                </tr>
+              )}
+              <tr>
+                <td><kbd>up</kbd>, <kbd>k</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
+              </tr>
+              <tr>
+                <td><kbd>down</kbd>, <kbd>j</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
+              </tr>
+              <tr>
+                <td><kbd>1</kbd>-<kbd>9</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
+              </tr>
+              <tr>
+                <td><kbd>n</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
+              </tr>
+              <tr>
+                <td><kbd>alt</kbd>+<kbd>n</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td>
+              </tr>
+              <tr>
+                <td><kbd>alt</kbd>+<kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
+              </tr>
+              <tr>
+                <td><kbd>backspace</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
+              </tr>
+              <tr>
+                <td><kbd>s</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
+              </tr>
+              <tr>
+                <td><kbd>alt</kbd>+<kbd>enter</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.secondary_toot' defaultMessage='to send toot using secondary privacy setting' /></td>
+              </tr>
+              <tr>
+                <td><kbd>esc</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
+              </tr>
+              <tr>
+                <td><kbd>?</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(KeyboardShortcuts));
diff --git a/app/javascript/flavours/glitch/features/list_adder/components/account.jsx b/app/javascript/flavours/glitch/features/list_adder/components/account.jsx
new file mode 100644
index 000000000..034ed0edc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_adder/components/account.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import { injectIntl } from 'react-intl';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+
+class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+    return (
+      <div className='account'>
+        <div className='account__wrapper'>
+          <div className='account__display-name'>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            <DisplayName account={account} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(makeMapStateToProps)(injectIntl(Account));
diff --git a/app/javascript/flavours/glitch/features/list_adder/components/list.jsx b/app/javascript/flavours/glitch/features/list_adder/components/list.jsx
new file mode 100644
index 000000000..1957bbe42
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_adder/components/list.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
+  add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
+});
+
+const mapStateToProps = (state, { listId, added }) => ({
+  list: state.get('lists').get(listId),
+  added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
+});
+
+const mapDispatchToProps = (dispatch, { listId }) => ({
+  onRemove: () => dispatch(removeFromListAdder(listId)),
+  onAdd: () => dispatch(addToListAdder(listId)),
+});
+
+class List extends ImmutablePureComponent {
+
+  static propTypes = {
+    list: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    onAdd: PropTypes.func.isRequired,
+    added: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    added: false,
+  };
+
+  render () {
+    const { list, intl, onRemove, onAdd, added } = this.props;
+
+    let button;
+
+    if (added) {
+      button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
+    } else {
+      button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
+    }
+
+    return (
+      <div className='list'>
+        <div className='list__wrapper'>
+          <div className='list__display-name'>
+            <Icon id='list-ul' className='column-link__icon' fixedWidth />
+            {list.get('title')}
+          </div>
+
+          <div className='account__relationship'>
+            {button}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(List));
diff --git a/app/javascript/flavours/glitch/features/list_adder/index.jsx b/app/javascript/flavours/glitch/features/list_adder/index.jsx
new file mode 100644
index 000000000..45d5589f9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_adder/index.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl } from 'react-intl';
+import { setupListAdder, resetListAdder } from '../../actions/lists';
+import { createSelector } from 'reselect';
+import List from './components/list';
+import Account from './components/account';
+import NewListForm from '../lists/components/new_list_form';
+// hack
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+  if (!lists) {
+    return lists;
+  }
+
+  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+});
+
+const mapStateToProps = state => ({
+  listIds: getOrderedLists(state).map(list=>list.get('id')),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onInitialize: accountId => dispatch(setupListAdder(accountId)),
+  onReset: () => dispatch(resetListAdder()),
+});
+
+class ListAdder extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    onInitialize: PropTypes.func.isRequired,
+    onReset: PropTypes.func.isRequired,
+    listIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  componentDidMount () {
+    const { onInitialize, accountId } = this.props;
+    onInitialize(accountId);
+  }
+
+  componentWillUnmount () {
+    const { onReset } = this.props;
+    onReset();
+  }
+
+  render () {
+    const { accountId, listIds } = this.props;
+
+    return (
+      <div className='modal-root__modal list-adder'>
+        <div className='list-adder__account'>
+          <Account accountId={accountId} />
+        </div>
+
+        <NewListForm />
+
+
+        <div className='list-adder__lists'>
+          {listIds.map(ListId => <List key={ListId} listId={ListId} />)}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder));
diff --git a/app/javascript/flavours/glitch/features/list_editor/components/account.jsx b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx
new file mode 100644
index 000000000..71a8b7673
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
+  add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
+});
+
+export default class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    onAdd: PropTypes.func.isRequired,
+    added: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    added: false,
+  };
+
+  render () {
+    const { account, intl, onRemove, onAdd, added } = this.props;
+
+    let button;
+
+    if (added) {
+      button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
+    } else {
+      button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
+    }
+
+    return (
+      <div className='account'>
+        <div className='account__wrapper'>
+          <div className='account__display-name'>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            <DisplayName account={account} />
+          </div>
+
+          <div className='account__relationship'>
+            {button}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.jsx b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.jsx
new file mode 100644
index 000000000..b4886ef0e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { changeListEditorTitle, submitListEditor } from 'flavours/glitch/actions/lists';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
+});
+
+const mapStateToProps = state => ({
+  value: state.getIn(['listEditor', 'title']),
+  disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onChange: value => dispatch(changeListEditorTitle(value)),
+  onSubmit: () => dispatch(submitListEditor(false)),
+});
+
+class ListForm extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+  };
+
+  handleChange = e => {
+    this.props.onChange(e.target.value);
+  };
+
+  handleSubmit = e => {
+    e.preventDefault();
+    this.props.onSubmit();
+  };
+
+  handleClick = () => {
+    this.props.onSubmit();
+  };
+
+  render () {
+    const { value, disabled, intl } = this.props;
+
+    const title = intl.formatMessage(messages.title);
+
+    return (
+      <form className='column-inline-form' onSubmit={this.handleSubmit}>
+        <input
+          className='setting-text'
+          value={value}
+          onChange={this.handleChange}
+        />
+
+        <IconButton
+          disabled={disabled}
+          icon='check'
+          title={title}
+          onClick={this.handleClick}
+        />
+      </form>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListForm));
diff --git a/app/javascript/flavours/glitch/features/list_editor/components/search.jsx b/app/javascript/flavours/glitch/features/list_editor/components/search.jsx
new file mode 100644
index 000000000..94782ba69
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/search.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
+});
+
+export default class Search extends React.PureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    value: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+  };
+
+  handleChange = e => {
+    this.props.onChange(e.target.value);
+  };
+
+  handleKeyUp = e => {
+    if (e.keyCode === 13) {
+      this.props.onSubmit(this.props.value);
+    }
+  };
+
+  handleClear = () => {
+    this.props.onClear();
+  };
+
+  render () {
+    const { value, intl } = this.props;
+    const hasValue = value.length > 0;
+
+    return (
+      <div className='list-editor__search search'>
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
+
+          <input
+            className='search__input'
+            type='text'
+            value={value}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyUp}
+            placeholder={intl.formatMessage(messages.search)}
+          />
+        </label>
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <Icon id='search' className={classNames({ active: !hasValue })} />
+          <Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js
new file mode 100644
index 000000000..782eb42f3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import { injectIntl } from 'react-intl';
+import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
+import Account from '../components/account';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId, added }) => ({
+    account: getAccount(state, accountId),
+    added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+  onRemove: () => dispatch(removeFromListEditor(accountId)),
+  onAdd: () => dispatch(addToListEditor(accountId)),
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js
new file mode 100644
index 000000000..5af20efbd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { injectIntl } from 'react-intl';
+import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['listEditor', 'suggestions', 'value']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onSubmit: value => dispatch(fetchListSuggestions(value)),
+  onClear: () => dispatch(clearListSuggestions()),
+  onChange: value => dispatch(changeListSuggestions(value)),
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));
diff --git a/app/javascript/flavours/glitch/features/list_editor/index.jsx b/app/javascript/flavours/glitch/features/list_editor/index.jsx
new file mode 100644
index 000000000..8b8a0cf31
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/index.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl } from 'react-intl';
+import { setupListEditor, clearListSuggestions, resetListEditor } from 'flavours/glitch/actions/lists';
+import AccountContainer from './containers/account_container';
+import SearchContainer from './containers/search_container';
+import EditListForm from './components/edit_list_form';
+import Motion from '../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['listEditor', 'accounts', 'items']),
+  searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onInitialize: listId => dispatch(setupListEditor(listId)),
+  onClear: () => dispatch(clearListSuggestions()),
+  onReset: () => dispatch(resetListEditor()),
+});
+
+class ListEditor extends ImmutablePureComponent {
+
+  static propTypes = {
+    listId: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    onInitialize: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onReset: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    searchAccountIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  componentDidMount () {
+    const { onInitialize, listId } = this.props;
+    onInitialize(listId);
+  }
+
+  componentWillUnmount () {
+    const { onReset } = this.props;
+    onReset();
+  }
+
+  render () {
+    const { accountIds, searchAccountIds, onClear } = this.props;
+    const showSearch = searchAccountIds.size > 0;
+
+    return (
+      <div className='modal-root__modal list-editor'>
+        <EditListForm />
+
+        <SearchContainer />
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner list-editor__accounts'>
+            {accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
+          </div>
+
+          {showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
+
+          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              (<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                {searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
+              </div>)
+            }
+          </Motion>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListEditor));
diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.jsx b/app/javascript/flavours/glitch/features/list_timeline/index.jsx
new file mode 100644
index 000000000..f885a751f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.jsx
@@ -0,0 +1,224 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { fetchList, deleteList, updateList } from 'flavours/glitch/actions/lists';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { connectListStream } from 'flavours/glitch/actions/streaming';
+import { expandListTimeline } from 'flavours/glitch/actions/timelines';
+import Column from 'flavours/glitch/components/column';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import Icon from 'flavours/glitch/components/icon';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import RadioButton from 'flavours/glitch/components/radio_button';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+
+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' },
+  followed:   { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
+  none:    { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
+  list:  { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
+});
+
+const mapStateToProps = (state, props) => ({
+  list: state.getIn(['lists', props.params.id]),
+  hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
+});
+
+class ListTimeline extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    list: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
+    intl: PropTypes.object.isRequired,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('LIST', { id: this.props.params.id }));
+      this.context.router.history.push('/');
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(fetchList(id));
+    dispatch(expandListTimeline(id));
+
+    this.disconnect = dispatch(connectListStream(id));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    const { dispatch } = this.props;
+    const { id } = nextProps.params;
+
+    if (id !== this.props.params.id) {
+      if (this.disconnect) {
+        this.disconnect();
+        this.disconnect = null;
+      }
+
+      dispatch(fetchList(id));
+      dispatch(expandListTimeline(id));
+
+      this.disconnect = dispatch(connectListStream(id));
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMore = maxId => {
+    const { id } = this.props.params;
+    this.props.dispatch(expandListTimeline(id, { maxId }));
+  };
+
+  handleEditClick = () => {
+    this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id }));
+  };
+
+  handleDeleteClick = () => {
+    const { dispatch, columnId, intl } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.deleteMessage),
+      confirm: intl.formatMessage(messages.deleteConfirm),
+      onConfirm: () => {
+        dispatch(deleteList(id));
+
+        if (columnId) {
+          dispatch(removeColumn(columnId));
+        } else {
+          this.context.router.history.push('/lists');
+        }
+      },
+    }));
+  };
+
+  handleRepliesPolicyChange = ({ target }) => {
+    const { dispatch, list } = this.props;
+    const { id } = this.props.params;
+    this.props.dispatch(updateList(id, undefined, false, target.value));
+  };
+
+  render () {
+    const { hasUnread, columnId, multiColumn, list, intl } = this.props;
+    const { id } = this.props.params;
+    const pinned = !!columnId;
+    const title  = list ? list.get('title') : id;
+    const replies_policy = list ? list.get('replies_policy') : undefined;
+
+    if (typeof list === 'undefined') {
+      return (
+        <Column>
+          <div className='scrollable'>
+            <LoadingIndicator />
+          </div>
+        </Column>
+      );
+    } else if (list === false) {
+      return (
+        <Column>
+          <div className='scrollable'>
+            <MissingIndicator />
+          </div>
+        </Column>
+      );
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
+        <ColumnHeader
+          icon='list-ul'
+          active={hasUnread}
+          title={title}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <div className='column-settings__row column-header__links'>
+            <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}>
+              <Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
+            </button>
+
+            <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}>
+              <Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
+            </button>
+          </div>
+
+          { replies_policy !== undefined && (
+            <div role='group' aria-labelledby={`list-${id}-replies-policy`}>
+              <span id={`list-${id}-replies-policy`} className='column-settings__section'>
+                <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' />
+              </span>
+              <div className='column-settings__row'>
+                { ['none', 'list', 'followed'].map(policy => (
+                  <RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
+                ))}
+              </div>
+            </div>
+          )}
+
+          <hr />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`list_timeline-${columnId}`}
+          timelineId={`list:${id}`}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />}
+          bindToDocument={!multiColumn}
+        />
+
+        <Helmet>
+          <title>{title}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(ListTimeline));
diff --git a/app/javascript/flavours/glitch/features/lists/components/new_list_form.jsx b/app/javascript/flavours/glitch/features/lists/components/new_list_form.jsx
new file mode 100644
index 000000000..be94ff559
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/lists/components/new_list_form.jsx
@@ -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)),
+});
+
+class NewListForm extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+  };
+
+  handleChange = e => {
+    this.props.onChange(e.target.value);
+  };
+
+  handleSubmit = e => {
+    e.preventDefault();
+    this.props.onSubmit();
+  };
+
+  handleClick = () => {
+    this.props.onSubmit();
+  };
+
+  render () {
+    const { value, disabled, intl } = this.props;
+
+    const label = intl.formatMessage(messages.label);
+    const title = intl.formatMessage(messages.title);
+
+    return (
+      <form className='column-inline-form' onSubmit={this.handleSubmit}>
+        <label>
+          <span style={{ display: 'none' }}>{label}</span>
+
+          <input
+            className='setting-text'
+            value={value}
+            disabled={disabled}
+            onChange={this.handleChange}
+            placeholder={label}
+          />
+        </label>
+
+        <IconButton
+          disabled={disabled || !value}
+          icon='plus'
+          title={title}
+          onClick={this.handleClick}
+        />
+      </form>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewListForm));
diff --git a/app/javascript/flavours/glitch/features/lists/index.jsx b/app/javascript/flavours/glitch/features/lists/index.jsx
new file mode 100644
index 000000000..dce0dcd8f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/lists/index.jsx
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+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 NewListForm from './components/new_list_form';
+
+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),
+});
+
+class Lists extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    lists: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchLists());
+  }
+
+  render () {
+    const { intl, lists, multiColumn } = this.props;
+
+    if (!lists) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} icon='bars' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <NewListForm />
+
+        <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
+        <ScrollableList
+          scrollKey='lists'
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {lists.map(list =>
+            <ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
+          )}
+        </ScrollableList>
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.heading)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Lists));
diff --git a/app/javascript/flavours/glitch/features/local_settings/index.jsx b/app/javascript/flavours/glitch/features/local_settings/index.jsx
new file mode 100644
index 000000000..4e4605ea9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/index.jsx
@@ -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.jsx b/app/javascript/flavours/glitch/features/local_settings/navigation/index.jsx
new file mode 100644
index 000000000..fe08e5d7b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.jsx
@@ -0,0 +1,93 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports
+import LocalSettingsNavigationItem from './item';
+import { preferencesLink } from 'flavours/glitch/utils/backend_links';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+  general: {  id: 'settings.general', defaultMessage: 'General' },
+  compose: {  id: 'settings.compose_box_opts', defaultMessage: 'Compose box' },
+  content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' },
+  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' },
+});
+
+class LocalSettingsNavigation extends React.PureComponent {
+
+  static propTypes = {
+    index      : PropTypes.number,
+    intl       : PropTypes.object.isRequired,
+    onClose    : PropTypes.func.isRequired,
+    onNavigate : PropTypes.func.isRequired,
+  };
+
+  render () {
+
+    const { index, intl, onClose, onNavigate } = this.props;
+
+    return (
+      <nav className='glitch local-settings__navigation'>
+        <LocalSettingsNavigationItem
+          active={index === 0}
+          index={0}
+          onNavigate={onNavigate}
+          icon='cogs'
+          title={intl.formatMessage(messages.general)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 1}
+          index={1}
+          onNavigate={onNavigate}
+          icon='pencil'
+          title={intl.formatMessage(messages.compose)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 2}
+          index={2}
+          onNavigate={onNavigate}
+          textIcon='CW'
+          title={intl.formatMessage(messages.content_warnings)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 3}
+          index={3}
+          onNavigate={onNavigate}
+          icon='angle-double-up'
+          title={intl.formatMessage(messages.collapsed)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 4}
+          index={4}
+          onNavigate={onNavigate}
+          icon='image'
+          title={intl.formatMessage(messages.media)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 5}
+          href={preferencesLink}
+          index={5}
+          icon='cog'
+          title={intl.formatMessage(messages.preferences)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 6}
+          className='close'
+          index={6}
+          onNavigate={onClose}
+          icon='times'
+          title={intl.formatMessage(messages.close)}
+        />
+      </nav>
+    );
+  }
+
+}
+
+export default injectIntl(LocalSettingsNavigation);
diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.jsx b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.jsx
new file mode 100644
index 000000000..a4d1b40fa
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.jsx
@@ -0,0 +1,74 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import Icon from 'flavours/glitch/components/icon';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPage extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    className: PropTypes.string,
+    href: PropTypes.string,
+    icon: PropTypes.string,
+    textIcon: PropTypes.string,
+    index: PropTypes.number.isRequired,
+    onNavigate: PropTypes.func,
+    title: PropTypes.string,
+  };
+
+  handleClick = (e) => {
+    const { index, onNavigate } = this.props;
+    if (onNavigate) {
+      onNavigate(index);
+      e.preventDefault();
+    }
+  };
+
+  render () {
+    const { handleClick } = this;
+    const {
+      active,
+      className,
+      href,
+      icon,
+      textIcon,
+      onNavigate,
+      title,
+    } = this.props;
+
+    const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
+      active,
+    }, className);
+
+    const iconElem = icon ? <Icon fixedWidth id={icon} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null);
+
+    if (href) return (
+      <a
+        href={href}
+        className={finalClassName}
+        title={title}
+        aria-label={title}
+      >
+        {iconElem} <span>{title}</span>
+      </a>
+    );
+    else if (onNavigate) return (
+      <a
+        onClick={handleClick}
+        role='button'
+        tabIndex='0'
+        className={finalClassName}
+        title={title}
+        aria-label={title}
+      >
+        {iconElem} <span>{title}</span>
+      </a>
+    );
+    else return null;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/deprecated_item/index.jsx b/app/javascript/flavours/glitch/features/local_settings/page/deprecated_item/index.jsx
new file mode 100644
index 000000000..362bd97c0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/page/deprecated_item/index.jsx
@@ -0,0 +1,83 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPageItem extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node.isRequired,
+    id: PropTypes.string.isRequired,
+    options: PropTypes.arrayOf(PropTypes.shape({
+      value: PropTypes.string.isRequired,
+      message: PropTypes.string.isRequired,
+      hint: PropTypes.string,
+    })),
+    value: PropTypes.any,
+    placeholder: PropTypes.string,
+  };
+
+  render () {
+    const { id, options, children, placeholder, value } = this.props;
+
+    if (options && options.length > 0) {
+      const currentValue = value;
+      const optionElems = options && options.length > 0 && options.map((opt) => {
+        let optionId = `${id}--${opt.value}`;
+        return (
+          <label htmlFor={optionId}>
+            <input
+              type='radio'
+              name={id}
+              id={optionId}
+              value={opt.value}
+              checked={currentValue === opt.value}
+              disabled
+            />
+            {opt.message}
+            {opt.hint && <span className='hint'>{opt.hint}</span>}
+          </label>
+        );
+      });
+      return (
+        <div className='glitch local-settings__page__item radio_buttons'>
+          <fieldset>
+            <legend>{children}</legend>
+            {optionElems}
+          </fieldset>
+        </div>
+      );
+    } else if (placeholder) {
+      return (
+        <div className='glitch local-settings__page__item string'>
+          <label htmlFor={id}>
+            <p>{children}</p>
+            <p>
+              <input
+                id={id}
+                type='text'
+                value={value}
+                placeholder={placeholder}
+                disabled
+              />
+            </p>
+          </label>
+        </div>
+      );
+    } else return (
+      <div className='glitch local-settings__page__item boolean'>
+        <label htmlFor={id}>
+          <input
+            id={id}
+            type='checkbox'
+            checked={value}
+            disabled
+          />
+          {children}
+        </label>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.jsx b/app/javascript/flavours/glitch/features/local_settings/page/index.jsx
new file mode 100644
index 000000000..83b0c7960
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.jsx
@@ -0,0 +1,516 @@
+//  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 { expandSpoilers } from 'flavours/glitch/initial_state';
+import { preferenceLink } from 'flavours/glitch/utils/backend_links';
+import LocalSettingsPageItem from './item';
+import DeprecatedLocalSettingsPageItem from './deprecated_item';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+  layout_auto: {  id: 'layout.auto', defaultMessage: 'Auto' },
+  layout_auto_hint: {  id: 'layout.hint.auto', defaultMessage: 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.' },
+  layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
+  layout_desktop_hint: { id: 'layout.hint.desktop', defaultMessage: 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
+  layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
+  layout_mobile_hint: { id: 'layout.hint.single', defaultMessage: 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
+  side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
+  side_arm_keep: { id: 'settings.side_arm_reply_mode.keep', defaultMessage: 'Keep its set privacy' },
+  side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' },
+  side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' },
+  regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' },
+  rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' },
+  rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' },
+  rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage:  'Rewrite with username' },
+  pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
+  pop_in_right: { id: 'settings.pop_in_right', defaultMessage:  'Right' },
+});
+
+class LocalSettingsPage extends React.PureComponent {
+
+  static propTypes = {
+    index    : PropTypes.number,
+    intl     : PropTypes.object.isRequired,
+    onChange : PropTypes.func.isRequired,
+    settings : ImmutablePropTypes.map.isRequired,
+  };
+
+  pages = [
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page general'>
+        <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['show_reply_count']}
+          id='mastodon-settings--reply-count'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['hicolor_privacy_icons']}
+          id='mastodon-settings--hicolor_privacy_icons'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.hicolor_privacy_icons' defaultMessage='High color privacy icons' />
+          <span className='hint'><FormattedMessage id='settings.hicolor_privacy_icons.hint' defaultMessage='Display privacy icons in bright and easily distinguishable colors' /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['confirm_boost_missing_media_description']}
+          id='mastodon-settings--confirm_boost_missing_media_description'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['tag_misleading_links']}
+          id='mastodon-settings--tag_misleading_links'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' />
+          <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage='Add a visual indication with the link target host to every link not mentioning it explicitly' /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['rewrite_mentions']}
+          id='mastodon-settings--rewrite_mentions'
+          options={[
+            { value: 'no', message: intl.formatMessage(messages.rewrite_mentions_no) },
+            { value: 'acct', message: intl.formatMessage(messages.rewrite_mentions_acct) },
+            { value: 'username', message: intl.formatMessage(messages.rewrite_mentions_username) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.rewrite_mentions' defaultMessage='Rewrite mentions in displayed statuses' />
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['notifications', 'tab_badge']}
+            id='mastodon-settings--notifications-tab_badge'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.notifications.tab_badge' defaultMessage='Unread notifications badge' />
+            <span className='hint'><FormattedMessage id='settings.notifications.tab_badge.hint' defaultMessage="Display a badge for unread notifications in the column icons when the notifications column isn't open" /></span>
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['notifications', 'favicon_badge']}
+            id='mastodon-settings--notifications-favicon_badge'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' />
+            <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage='Add a badge for unread notifications to the favicon' /></span>
+          </LocalSettingsPageItem>
+        </section>
+
+        <section>
+          <h2><FormattedMessage id='settings.status_icons' defaultMessage='Toot icons' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['status_icons', 'language']}
+            id='mastodon-settings--status-icons-language'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.status_icons_language' defaultMessage='Language indicator' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['status_icons', 'reply']}
+            id='mastodon-settings--status-icons-reply'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.status_icons_reply' defaultMessage='Reply indicator' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['status_icons', 'local_only']}
+            id='mastodon-settings--status-icons-local_only'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.status_icons_local_only' defaultMessage='Local-only indicator' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['status_icons', 'media']}
+            id='mastodon-settings--status-icons-media'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.status_icons_media' defaultMessage='Media and poll indicators' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['status_icons', 'visibility']}
+            id='mastodon-settings--status-icons-visibility'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.status_icons_visibility' defaultMessage='Toot privacy indicator' />
+          </LocalSettingsPageItem>
+        </section>
+        <section>
+          <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['layout']}
+            id='mastodon-settings--layout'
+            options={[
+              { value: 'auto', message: intl.formatMessage(messages.layout_auto), hint: intl.formatMessage(messages.layout_auto_hint) },
+              { value: 'multiple', message: intl.formatMessage(messages.layout_desktop), hint: intl.formatMessage(messages.layout_desktop_hint) },
+              { value: 'single', message: intl.formatMessage(messages.layout_mobile), hint: intl.formatMessage(messages.layout_mobile_hint) },
+            ]}
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.layout' defaultMessage='Layout:' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['stretch']}
+            id='mastodon-settings--stretch'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
+            <span className='hint'><FormattedMessage id='settings.wide_view_hint' defaultMessage='Stretches columns to better fill the available space.' /></span>
+          </LocalSettingsPageItem>
+        </section>
+      </div>
+    ),
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page compose_box_opts'>
+        <h1><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['always_show_spoilers_field']}
+          id='mastodon-settings--always_show_spoilers_field'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.always_show_spoilers_field' defaultMessage='Always enable the Content Warning field' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['prepend_cw_re']}
+          id='mastodon-settings--prepend_cw_re'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.prepend_cw_re' defaultMessage='Prepend “re: ” to content warnings when replying' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['preselect_on_reply']}
+          id='mastodon-settings--preselect_on_reply'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.preselect_on_reply' defaultMessage='Pre-select usernames on reply' />
+          <span className='hint'><FormattedMessage id='settings.preselect_on_reply_hint' defaultMessage='When replying to a conversation with multiple participants, pre-select usernames past the first' /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['confirm_missing_media_description']}
+          id='mastodon-settings--confirm_missing_media_description'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.confirm_missing_media_description' defaultMessage='Show confirmation dialog before sending toots lacking media descriptions' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['confirm_before_clearing_draft']}
+          id='mastodon-settings--confirm_before_clearing_draft'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.confirm_before_clearing_draft' defaultMessage='Show confirmation dialog before overwriting the message being composed' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['show_content_type_choice']}
+          id='mastodon-settings--show_content_type_choice'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.show_content_type_choice' defaultMessage='Show content-type choice when authoring toots' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['side_arm']}
+          id='mastodon-settings--side_arm'
+          options={[
+            { value: 'none', message: intl.formatMessage(messages.side_arm_none) },
+            { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) },
+            { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) },
+            { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) },
+            { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['side_arm_reply_mode']}
+          id='mastodon-settings--side_arm_reply_mode'
+          options={[
+            { value: 'keep', message: intl.formatMessage(messages.side_arm_keep) },
+            { value: 'copy', message: intl.formatMessage(messages.side_arm_copy) },
+            { value: 'restrict', message: intl.formatMessage(messages.side_arm_restrict) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.side_arm_reply_mode' defaultMessage='When replying to a toot, the secondary toot button should:' />
+        </LocalSettingsPageItem>
+      </div>
+    ),
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page content_warnings'>
+        <h1><FormattedMessage id='settings.content_warnings' defaultMessage='Content warnings' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['content_warnings', 'shared_state']}
+          id='mastodon-settings--content_warnings-shared_state'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.content_warnings_shared_state' defaultMessage='Show/hide content of all copies at once' />
+          <span className='hint'><FormattedMessage id='settings.content_warnings_shared_state_hint' defaultMessage='Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW' /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['content_warnings', 'media_outside']}
+          id='mastodon-settings--content_warnings-media_outside'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.content_warnings_media_outside' defaultMessage='Display media attachments outside content warnings' />
+          <span className='hint'><FormattedMessage id='settings.content_warnings_media_outside_hint' defaultMessage='Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments' /></span>
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.content_warnings_unfold_opts' defaultMessage='Auto-unfolding options' /></h2>
+          <DeprecatedLocalSettingsPageItem
+            id='mastodon-settings--content_warnings-auto_unfold'
+            value={expandSpoilers}
+          >
+            <FormattedMessage id='settings.enable_content_warnings_auto_unfold' defaultMessage='Automatically unfold content-warnings' />
+            <span className='hint'>
+              <FormattedMessage
+                id='settings.deprecated_setting'
+                defaultMessage="This setting is now controlled from Mastodon's {settings_page_link}"
+                values={{
+                  settings_page_link: (
+                    <a href={preferenceLink('user_setting_expand_spoilers')}>
+                      <FormattedMessage
+                        id='settings.shared_settings_link'
+                        defaultMessage='user preferences'
+                      />
+                    </a>
+                  ),
+                }}
+              />
+            </span>
+          </DeprecatedLocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['content_warnings', 'filter']}
+            id='mastodon-settings--content_warnings-auto_unfold'
+            onChange={onChange}
+            placeholder={intl.formatMessage(messages.regexp)}
+            disabled={!expandSpoilers}
+          >
+            <FormattedMessage id='settings.content_warnings_filter' defaultMessage='Content warnings to not automatically unfold:' />
+          </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' />
+          <span className='hint'><FormattedMessage id='settings.enable_collapsed_hint' defaultMessage='Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature' /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['collapsed', 'show_action_bar']}
+          id='mastodon-settings--collapsed-show-action-bar'
+          onChange={onChange}
+          dependsOn={[['collapsed', 'enabled']]}
+        >
+          <FormattedMessage id='settings.show_action_bar' defaultMessage='Show action buttons in collapsed toots' />
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'all']}
+            id='mastodon-settings--collapsed-auto-all'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'notifications']}
+            id='mastodon-settings--collapsed-auto-notifications'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'lengthy']}
+            id='mastodon-settings--collapsed-auto-lengthy'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'reblogs']}
+            id='mastodon-settings--collapsed-auto-reblogs'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'replies']}
+            id='mastodon-settings--collapsed-auto-replies'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'media']}
+            id='mastodon-settings--collapsed-auto-media'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'height']}
+            id='mastodon-settings--collapsed-auto-height'
+            placeholder='400'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+            inputProps={{ type: 'number', min: '200', max: '999' }}
+          >
+            <FormattedMessage id='settings.auto_collapse_height' defaultMessage='Height (in pixels) for a toot to be considered lengthy' />
+          </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' />
+            <span className='hint'><FormattedMessage id='settings.image_backgrounds_media_hint' defaultMessage='If the post has any media attachment, use the first one as a background' /></span>
+          </LocalSettingsPageItem>
+        </section>
+      </div>
+    ),
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page media'>
+        <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'letterbox']}
+          id='mastodon-settings--media-letterbox'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
+          <span className='hint'><FormattedMessage id='settings.media_letterbox_hint' defaultMessage='Scale down and letterbox media to fill the image containers instead of stretching and cropping them' /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'fullwidth']}
+          id='mastodon-settings--media-fullwidth'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['inline_preview_cards']}
+          id='mastodon-settings--inline-preview-cards'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.inline_preview_cards' defaultMessage='Inline preview cards for external links' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'reveal_behind_cw']}
+          id='mastodon-settings--reveal-behind-cw'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'pop_in_player']}
+          id='mastodon-settings--pop-in-player'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.pop_in_player' defaultMessage='Enable pop-in player' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'pop_in_position']}
+          id='mastodon-settings--pop-in-position'
+          options={[
+            { value: 'left', message: intl.formatMessage(messages.pop_in_left) },
+            { value: 'right', message: intl.formatMessage(messages.pop_in_right) },
+          ]}
+          onChange={onChange}
+          dependsOn={[['media', 'pop_in_player']]}
+        >
+          <FormattedMessage id='settings.pop_in_position' defaultMessage='Pop-in player position:' />
+        </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} />;
+  }
+
+}
+
+export default injectIntl(LocalSettingsPage);
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/item/index.jsx b/app/javascript/flavours/glitch/features/local_settings/page/item/index.jsx
new file mode 100644
index 000000000..41c0676a2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/page/item/index.jsx
@@ -0,0 +1,119 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPageItem extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node.isRequired,
+    dependsOn: PropTypes.array,
+    dependsOnNot: PropTypes.array,
+    id: PropTypes.string.isRequired,
+    item: PropTypes.array.isRequired,
+    onChange: PropTypes.func.isRequired,
+    inputProps: PropTypes.object,
+    options: PropTypes.arrayOf(PropTypes.shape({
+      value: PropTypes.string.isRequired,
+      message: PropTypes.string.isRequired,
+      hint: PropTypes.string,
+    })),
+    settings: ImmutablePropTypes.map.isRequired,
+    placeholder: PropTypes.string,
+    disabled: PropTypes.bool,
+  };
+
+  handleChange = e => {
+    const { target } = e;
+    const { item, onChange, options, placeholder } = this.props;
+    if (options && options.length > 0) onChange(item, target.value);
+    else if (placeholder) onChange(item, target.value);
+    else onChange(item, target.checked);
+  };
+
+  render () {
+    const { handleChange } = this;
+    const { settings, item, id, inputProps, options, children, dependsOn, dependsOnNot, placeholder, disabled } = this.props;
+    let enabled = !disabled;
+
+    if (dependsOn) {
+      for (let i = 0; i < dependsOn.length; i++) {
+        enabled = enabled && settings.getIn(dependsOn[i]);
+      }
+    }
+    if (dependsOnNot) {
+      for (let i = 0; i < dependsOnNot.length; i++) {
+        enabled = enabled && !settings.getIn(dependsOnNot[i]);
+      }
+    }
+
+    if (options && options.length > 0) {
+      const currentValue = settings.getIn(item);
+      const optionElems = options && options.length > 0 && options.map((opt) => {
+        let optionId = `${id}--${opt.value}`;
+        return (
+          <label htmlFor={optionId}>
+            <input
+              type='radio'
+              name={id}
+              id={optionId}
+              key={optionId}
+              value={opt.value}
+              onBlur={handleChange}
+              onChange={handleChange}
+              checked={currentValue === opt.value}
+              disabled={!enabled}
+              {...inputProps}
+            />
+            {opt.message}
+            {opt.hint && <span className='hint'>{opt.hint}</span>}
+          </label>
+        );
+      });
+      return (
+        <div className='glitch local-settings__page__item radio_buttons'>
+          <fieldset>
+            <legend>{children}</legend>
+            {optionElems}
+          </fieldset>
+        </div>
+      );
+    } else if (placeholder) {
+      return (
+        <div className='glitch local-settings__page__item string'>
+          <label htmlFor={id}>
+            <p>{children}</p>
+            <p>
+              <input
+                id={id}
+                type='text'
+                value={settings.getIn(item)}
+                placeholder={placeholder}
+                onChange={handleChange}
+                disabled={!enabled}
+	        {...inputProps}
+              />
+            </p>
+          </label>
+        </div>
+      );
+    } else return (
+      <div className='glitch local-settings__page__item boolean'>
+        <label htmlFor={id}>
+          <input
+            id={id}
+            type='checkbox'
+            checked={settings.getIn(item)}
+            onChange={handleChange}
+            disabled={!enabled}
+            {...inputProps}
+          />
+          {children}
+        </label>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/mutes/index.jsx b/app/javascript/flavours/glitch/features/mutes/index.jsx
new file mode 100644
index 000000000..b699fdb27
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/mutes/index.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import { fetchMutes, expandMutes } from 'flavours/glitch/actions/mutes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'mutes', 'items']),
+  hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
+  isLoading: state.getIn(['user_lists', 'mutes', 'isLoading'], true),
+});
+
+class Mutes extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchMutes());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandMutes());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollableList
+          scrollKey='mutes'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} defaultAction='mute' />,
+          )}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Mutes));
diff --git a/app/javascript/flavours/glitch/features/notifications/components/admin_report.jsx b/app/javascript/flavours/glitch/features/notifications/components/admin_report.jsx
new file mode 100644
index 000000000..556df8f66
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/admin_report.jsx
@@ -0,0 +1,112 @@
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
+
+// Our imports.
+import Permalink from 'flavours/glitch/components/permalink';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import NotificationOverlayContainer from '../containers/overlay_container';
+import Icon from 'flavours/glitch/components/icon';
+import Report from './report';
+
+const messages = defineMessages({
+  adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
+});
+
+export default class AdminReport extends ImmutablePureComponent {
+
+  static propTypes = {
+    hidden: PropTypes.bool,
+    id: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
+    report: 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(`/@${notification.getIn(['account', 'acct'])}`);
+  };
+
+  handleMention = e => {
+    e.preventDefault();
+
+    const { notification, onMention } = this.props;
+    onMention(notification.get('account'), this.context.router.history);
+  };
+
+  getHandlers () {
+    return {
+      moveUp: this.handleMoveUp,
+      moveDown: this.handleMoveDown,
+      open: this.handleOpen,
+      openProfile: this.handleOpenProfile,
+      mention: this.handleMention,
+      reply: this.handleMention,
+    };
+  }
+
+  render () {
+    const { intl, account, notification, unread, report } = this.props;
+
+    if (!report) {
+      return null;
+    }
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <bdi><Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/@${account.get('acct')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      /></bdi>
+    );
+
+    const targetAccount = report.get('target_account');
+    const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
+    const targetLink = <bdi><Permalink className='notification__display-name' href={targetAccount.get('url')} title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
+
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon id='flag' fixedWidth />
+            </div>
+
+            <span title={notification.get('created_at')}>
+              <FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} />
+            </span>
+          </div>
+
+          <Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
+          <NotificationOverlayContainer notification={notification} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/admin_signup.jsx b/app/javascript/flavours/glitch/features/notifications/components/admin_signup.jsx
new file mode 100644
index 000000000..ead2a9701
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/admin_signup.jsx
@@ -0,0 +1,101 @@
+//  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';
+import classNames from 'classnames';
+
+// Our imports.
+import Permalink from 'flavours/glitch/components/permalink';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import NotificationOverlayContainer from '../containers/overlay_container';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class NotificationFollow extends ImmutablePureComponent {
+
+  static propTypes = {
+    hidden: PropTypes.bool,
+    id: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
+  };
+
+  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(`/@${notification.getIn(['account', 'acct'])}`);
+  };
+
+  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, unread } = this.props;
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <bdi><Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/@${account.get('acct')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      /></bdi>
+    );
+
+    //  Renders.
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon fixedWidth id='user-plus' />
+            </div>
+
+            <FormattedMessage
+              id='notification.admin.sign_up'
+              defaultMessage='{name} signed up'
+              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/clear_column_button.jsx b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.jsx
new file mode 100644
index 000000000..ee77cfb8e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class ClearColumnButton extends React.Component {
+
+  static propTypes = {
+    onClick: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><Icon id='eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx b/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx
new file mode 100644
index 000000000..1c04218ba
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx
@@ -0,0 +1,203 @@
+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 GrantPermissionButton from './grant_permission_button';
+import SettingToggle from './setting_toggle';
+import PillBarButton from './pill_bar_button';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions';
+
+export default class ColumnSettings extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    pushSettings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onRequestNotificationPermission: PropTypes.func,
+    alertsEnabled: PropTypes.bool,
+    browserSupport: PropTypes.bool,
+    browserPermission: PropTypes.string,
+  };
+
+  onPushChange = (path, checked) => {
+    this.props.onChange(['push', ...path], checked);
+  };
+
+  render () {
+    const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
+
+    const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
+    const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
+    const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
+    const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
+    const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
+
+    const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+    const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
+
+    return (
+      <div>
+        {alertsEnabled && browserSupport && browserPermission === 'denied' && (
+          <div className='column-settings__row column-settings__row--with-margin'>
+            <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
+          </div>
+        )}
+
+        {alertsEnabled && browserSupport && browserPermission === 'default' && (
+          <div className='column-settings__row column-settings__row--with-margin'>
+            <span className='warning-hint'>
+              <FormattedMessage id='notifications.permission_required' defaultMessage='Desktop notifications are unavailable because the required permission has not been granted.' /> <GrantPermissionButton onClick={onRequestNotificationPermission} />
+            </span>
+          </div>
+        )}
+
+        <div className='column-settings__row'>
+          <ClearColumnButton onClick={onClear} />
+        </div>
+
+        <div role='group' aria-labelledby='notifications-unread-markers'>
+          <span id='notifications-unread-markers' className='column-settings__section'>
+            <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
+          </span>
+
+          <div className='column-settings__row'>
+            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-filter-bar'>
+          <span id='notifications-filter-bar' className='column-settings__section'>
+            <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
+          </span>
+
+          <div className='column-settings__row'>
+            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
+            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-follow'>
+          <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
+
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-follow-request'>
+          <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
+
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-favourite'>
+          <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
+
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <PillBarButton 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__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <PillBarButton 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__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-poll'>
+          <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
+
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-status'>
+          <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New posts:' /></span>
+
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-update'>
+          <span id='notifications-update' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span>
+
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'update']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'update']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'update']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'update']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        {((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && (
+          <div role='group' aria-labelledby='notifications-admin-sign-up'>
+            <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span>
+
+            <div className='column-settings__pillbar'>
+              <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.sign_up']} onChange={onChange} label={alertStr} />
+              {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.sign_up']} onChange={this.onPushChange} label={pushStr} />}
+              <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.sign_up']} onChange={onChange} label={showStr} />
+              <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.sign_up']} onChange={onChange} label={soundStr} />
+            </div>
+          </div>
+        )}
+
+        {((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && (
+          <div role='group' aria-labelledby='notifications-admin-report'>
+            <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span>
+
+            <div className='column-settings__pillbar'>
+              <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} />
+              {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.report']} onChange={this.onPushChange} label={pushStr} />}
+              <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} />
+              <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} />
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx
new file mode 100644
index 000000000..7f36fb813
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+const tooltips = defineMessages({
+  mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
+  favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
+  boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
+  polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
+  follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+  statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
+});
+
+class FilterBar extends React.PureComponent {
+
+  static propTypes = {
+    selectFilter: PropTypes.func.isRequired,
+    selectedFilter: PropTypes.string.isRequired,
+    advancedMode: PropTypes.bool.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  onClick (notificationType) {
+    return () => this.props.selectFilter(notificationType);
+  }
+
+  render () {
+    const { selectedFilter, advancedMode, intl } = this.props;
+    const renderedElement = !advancedMode ? (
+      <div className='notification__filter-bar'>
+        <button
+          className={selectedFilter === 'all' ? 'active' : ''}
+          onClick={this.onClick('all')}
+        >
+          <FormattedMessage
+            id='notifications.filter.all'
+            defaultMessage='All'
+          />
+        </button>
+        <button
+          className={selectedFilter === 'mention' ? 'active' : ''}
+          onClick={this.onClick('mention')}
+        >
+          <FormattedMessage
+            id='notifications.filter.mentions'
+            defaultMessage='Mentions'
+          />
+        </button>
+      </div>
+    ) : (
+      <div className='notification__filter-bar'>
+        <button
+          className={selectedFilter === 'all' ? 'active' : ''}
+          onClick={this.onClick('all')}
+        >
+          <FormattedMessage
+            id='notifications.filter.all'
+            defaultMessage='All'
+          />
+        </button>
+        <button
+          className={selectedFilter === 'mention' ? 'active' : ''}
+          onClick={this.onClick('mention')}
+          title={intl.formatMessage(tooltips.mentions)}
+        >
+          <Icon id='reply-all' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'favourite' ? 'active' : ''}
+          onClick={this.onClick('favourite')}
+          title={intl.formatMessage(tooltips.favourites)}
+        >
+          <Icon id='star' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'reblog' ? 'active' : ''}
+          onClick={this.onClick('reblog')}
+          title={intl.formatMessage(tooltips.boosts)}
+        >
+          <Icon id='retweet' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'poll' ? 'active' : ''}
+          onClick={this.onClick('poll')}
+          title={intl.formatMessage(tooltips.polls)}
+        >
+          <Icon id='tasks' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'status' ? 'active' : ''}
+          onClick={this.onClick('status')}
+          title={intl.formatMessage(tooltips.statuses)}
+        >
+          <Icon id='home' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'follow' ? 'active' : ''}
+          onClick={this.onClick('follow')}
+          title={intl.formatMessage(tooltips.follows)}
+        >
+          <Icon id='user-plus' fixedWidth />
+        </button>
+      </div>
+    );
+    return renderedElement;
+  }
+
+}
+
+export default injectIntl(FilterBar);
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.jsx b/app/javascript/flavours/glitch/features/notifications/components/follow.jsx
new file mode 100644
index 000000000..434d6609d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.jsx
@@ -0,0 +1,101 @@
+//  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';
+import classNames from 'classnames';
+
+// Our imports.
+import Permalink from 'flavours/glitch/components/permalink';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import NotificationOverlayContainer from '../containers/overlay_container';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class NotificationFollow extends ImmutablePureComponent {
+
+  static propTypes = {
+    hidden: PropTypes.bool,
+    id: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
+  };
+
+  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(`/@${notification.getIn(['account', 'acct'])}`);
+  };
+
+  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, unread } = this.props;
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <bdi><Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/@${account.get('acct')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      /></bdi>
+    );
+
+    //  Renders.
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon fixedWidth id='user-plus' />
+            </div>
+
+            <FormattedMessage
+              id='notification.follow'
+              defaultMessage='{name} followed you'
+              values={{ name: link }}
+            />
+          </div>
+
+          <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} />
+          <NotificationOverlayContainer notification={notification} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx b/app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx
new file mode 100644
index 000000000..01dec320e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx
@@ -0,0 +1,133 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import Permalink from 'flavours/glitch/components/permalink';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import NotificationOverlayContainer from '../containers/overlay_container';
+import { HotKeys } from 'react-hotkeys';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+  reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
+});
+
+class FollowRequest extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onAuthorize: PropTypes.func.isRequired,
+    onReject: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
+  };
+
+  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(`/@${notification.getIn(['account', 'acct'])}`);
+  };
+
+  handleMention = e => {
+    e.preventDefault();
+
+    const { notification, onMention } = this.props;
+    onMention(notification.get('account'), this.context.router.history);
+  };
+
+  getHandlers () {
+    return {
+      moveUp: this.handleMoveUp,
+      moveDown: this.handleMoveDown,
+      open: this.handleOpen,
+      openProfile: this.handleOpenProfile,
+      mention: this.handleMention,
+      reply: this.handleMention,
+    };
+  }
+
+  render () {
+    const { intl, hidden, account, onAuthorize, onReject, notification, unread } = this.props;
+
+    if (!account) {
+      return <div />;
+    }
+
+    if (hidden) {
+      return (
+        <Fragment>
+          {account.get('display_name')}
+          {account.get('username')}
+        </Fragment>
+      );
+    }
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <bdi><Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/@${account.get('acct')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      /></bdi>
+    );
+
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon id='user' fixedWidth />
+            </div>
+
+            <FormattedMessage
+              id='notification.follow_request'
+              defaultMessage='{name} has requested to follow you'
+              values={{ name: link }}
+            />
+          </div>
+
+          <div className='account'>
+            <div className='account__wrapper'>
+              <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
+                <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+                <DisplayName account={account} />
+              </Permalink>
+
+              <div className='account__relationship'>
+                <IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />
+                <IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} />
+              </div>
+            </div>
+          </div>
+
+          <NotificationOverlayContainer notification={notification} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
+
+export default injectIntl(FollowRequest);
diff --git a/app/javascript/flavours/glitch/features/notifications/components/grant_permission_button.jsx b/app/javascript/flavours/glitch/features/notifications/components/grant_permission_button.jsx
new file mode 100644
index 000000000..798e4c787
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/grant_permission_button.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default class GrantPermissionButton extends React.PureComponent {
+
+  static propTypes = {
+    onClick: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <button className='text-btn column-header__permission-btn' tabIndex='0' onClick={this.props.onClick}>
+        <FormattedMessage id='notifications.grant_permission' defaultMessage='Grant permission.' />
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx
new file mode 100644
index 000000000..d1aea1b21
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx
@@ -0,0 +1,234 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Our imports,
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import NotificationFollow from './follow';
+import NotificationFollowRequestContainer from '../containers/follow_request_container';
+import NotificationAdminSignup from './admin_signup';
+import NotificationAdminReportContainer from '../containers/admin_report_container';
+
+export default class Notification extends ImmutablePureComponent {
+
+  static propTypes = {
+    notification: ImmutablePropTypes.map.isRequired,
+    hidden: PropTypes.bool,
+    onMoveUp: PropTypes.func.isRequired,
+    onMoveDown: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    getScrollPosition: PropTypes.func,
+    updateScrollBottom: PropTypes.func,
+    cacheMediaWidth: PropTypes.func,
+    cachedMediaWidth: PropTypes.number,
+    onUnmount: PropTypes.func,
+    unread: PropTypes.bool,
+  };
+
+  render () {
+    const {
+      hidden,
+      notification,
+      onMoveDown,
+      onMoveUp,
+      onMention,
+      getScrollPosition,
+      updateScrollBottom,
+    } = this.props;
+
+    switch(notification.get('type')) {
+    case 'follow':
+      return (
+        <NotificationFollow
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          unread={this.props.unread}
+        />
+      );
+    case 'follow_request':
+      return (
+        <NotificationFollowRequestContainer
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          unread={this.props.unread}
+        />
+      );
+    case 'admin.sign_up':
+      return (
+        <NotificationAdminSignup
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          unread={this.props.unread}
+        />
+      );
+    case 'admin.report':
+      return (
+        <NotificationAdminReportContainer
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          unread={this.props.unread}
+        />
+      );
+    case 'mention':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          contextType='notifications'
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
+    case 'status':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='status'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          contextType='notifications'
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
+    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}
+          contextType='notifications'
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
+    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}
+          contextType='notifications'
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
+    case 'poll':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='poll'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          contextType='notifications'
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
+    case 'update':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='update'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          contextType='notifications'
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
+    default:
+      return null;
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.jsx b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.jsx
new file mode 100644
index 000000000..5a12191a5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import Icon from 'flavours/glitch/components/icon';
+import Button from 'flavours/glitch/components/button';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+class NotificationsPermissionBanner extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.dispatch(requestBrowserPermission());
+  };
+
+  handleClose = () => {
+    this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <div className='notifications-permission-banner'>
+        <div className='notifications-permission-banner__close'>
+          <IconButton icon='times' onClick={this.handleClose} title={intl.formatMessage(messages.close)} />
+        </div>
+
+        <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
+        <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
+        <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
+      </div>
+    );
+  }
+
+}
+
+export default connect()(injectIntl(NotificationsPermissionBanner));
diff --git a/app/javascript/flavours/glitch/features/notifications/components/overlay.jsx b/app/javascript/flavours/glitch/features/notifications/components/overlay.jsx
new file mode 100644
index 000000000..554a7a668
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.jsx
@@ -0,0 +1,59 @@
+/**
+ * Notification overlay
+ */
+
+
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
+});
+
+class NotificationOverlay extends ImmutablePureComponent {
+
+  static propTypes = {
+    notification    : ImmutablePropTypes.map.isRequired,
+    onMarkForDelete : PropTypes.func.isRequired,
+    show            : PropTypes.bool.isRequired,
+    intl            : PropTypes.object.isRequired,
+  };
+
+  onToggleMark = () => {
+    const mark = !this.props.notification.get('markedForDelete');
+    const id = this.props.notification.get('id');
+    this.props.onMarkForDelete(id, mark);
+  };
+
+  render () {
+    const { notification, show, intl } = this.props;
+
+    const active = notification.get('markedForDelete');
+    const label = intl.formatMessage(messages.markForDeletion);
+
+    return show ? (
+      <div
+        aria-label={label}
+        role='checkbox'
+        aria-checked={active}
+        tabIndex={0}
+        className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
+        onClick={this.onToggleMark}
+      >
+        <div className='wrappy'>
+          <div className='ckbox' aria-hidden='true' title={label}>
+            {active ? (<Icon id='check' />) : ''}
+          </div>
+        </div>
+      </div>
+    ) : null;
+  }
+
+}
+
+export default injectIntl(NotificationOverlay);
diff --git a/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.jsx b/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.jsx
new file mode 100644
index 000000000..2f0b48ef9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import classNames from 'classnames';
+
+export default class PillBarButton extends React.PureComponent {
+
+  static propTypes = {
+    prefix: PropTypes.string,
+    settings: ImmutablePropTypes.map.isRequired,
+    settingPath: PropTypes.array.isRequired,
+    label: PropTypes.node.isRequired,
+    onChange: PropTypes.func.isRequired,
+    disabled: PropTypes.bool,
+  };
+
+  onChange = () => {
+    const { settings, settingPath } = this.props;
+    this.props.onChange(settingPath, !settings.getIn(settingPath));
+  };
+
+  render () {
+    const { prefix, settings, settingPath, label, disabled } = this.props;
+    const id = ['setting-pillbar-button', prefix, ...settingPath].filter(Boolean).join('-');
+    const active = settings.getIn(settingPath);
+
+    return (
+      <button
+        key={id}
+        id={id}
+        className={classNames('pillbar-button', { active })}
+        disabled={disabled}
+        onClick={this.onChange}
+        aria-pressed={active}
+      >
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/report.jsx b/app/javascript/flavours/glitch/features/notifications/components/report.jsx
new file mode 100644
index 000000000..9110735a1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/report.jsx
@@ -0,0 +1,63 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AvatarOverlay from 'flavours/glitch/components/avatar_overlay';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+
+const messages = defineMessages({
+  openReport: { id: 'report_notification.open', defaultMessage: 'Open report' },
+  other: { id: 'report_notification.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
+});
+
+class Report extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    report: ImmutablePropTypes.map.isRequired,
+    hidden: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { intl, hidden, report, account } = this.props;
+
+    if (!report) {
+      return null;
+    }
+
+    if (hidden) {
+      return (
+        <Fragment>
+          {report.get('id')}
+        </Fragment>
+      );
+    }
+
+    return (
+      <div className='notification__report'>
+        <div className='notification__report__avatar'>
+          <AvatarOverlay account={report.get('target_account')} friend={account} />
+        </div>
+
+        <div className='notification__report__details'>
+          <div>
+            <RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {{count} post} other {{count} posts}} attached' values={{ count: report.get('status_ids').size }} />
+            <br />
+            <strong>{intl.formatMessage(messages[report.get('category')])}</strong>
+          </div>
+
+          <div className='notification__report__actions'>
+            <a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Report);
diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.jsx b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.jsx
new file mode 100644
index 000000000..dc7b89b7f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class SettingToggle extends React.PureComponent {
+
+  static propTypes = {
+    prefix: PropTypes.string,
+    settings: ImmutablePropTypes.map.isRequired,
+    settingPath: PropTypes.array.isRequired,
+    label: PropTypes.node.isRequired,
+    meta: PropTypes.node,
+    onChange: PropTypes.func.isRequired,
+    defaultValue: PropTypes.bool,
+    disabled: PropTypes.bool,
+  };
+
+  onChange = ({ target }) => {
+    this.props.onChange(this.props.settingPath, target.checked);
+  };
+
+  render () {
+    const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props;
+    const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
+
+    return (
+      <div className='setting-toggle'>
+        <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
+        <label htmlFor={id} className='setting-toggle__label'>{label}</label>
+        {meta && <span className='setting-meta__label'>{meta}</span>}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js
new file mode 100644
index 000000000..4198afce8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { makeGetReport } from 'flavours/glitch/selectors';
+import AdminReport from '../components/admin_report';
+
+const mapStateToProps = (state, { notification }) => {
+  const getReport = makeGetReport();
+
+  return {
+    report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null,
+  };
+};
+
+export default connect(mapStateToProps)(AdminReport);
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..27c2f96fe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,73 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { setFilter, clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications';
+import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { showAlert } from 'flavours/glitch/actions/alerts';
+
+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' },
+  permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
+});
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'notifications']),
+  pushSettings: state.get('push_notifications'),
+  alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
+  browserSupport: state.getIn(['notifications', 'browserSupport']),
+  browserPermission: state.getIn(['notifications', 'browserPermission']),
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onChange (path, checked) {
+    if (path[0] === 'push') {
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changePushNotifications(path.slice(1), checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changePushNotifications(path.slice(1), checked));
+      }
+    } else if (path[0] === 'quickFilter') {
+      dispatch(changeSetting(['notifications', ...path], checked));
+      dispatch(setFilter('all'));
+    } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changeSetting(['notifications', ...path], checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changeSetting(['notifications', ...path], checked));
+      }
+    } else {
+      dispatch(changeSetting(['notifications', ...path], checked));
+    }
+  },
+
+  onClear () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.clearMessage),
+      confirm: intl.formatMessage(messages.clearConfirm),
+      onConfirm: () => dispatch(clearNotifications()),
+    }));
+  },
+
+  onRequestNotificationPermission () {
+    dispatch(requestBrowserPermission());
+  },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js
new file mode 100644
index 000000000..4d495c290
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import FilterBar from '../components/filter_bar';
+import { setFilter } from '../../../actions/notifications';
+
+const makeMapStateToProps = state => ({
+  selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
+  advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+  selectFilter (newActiveFilter) {
+    dispatch(setFilter(newActiveFilter));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js
new file mode 100644
index 000000000..82357adfb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import FollowRequest from '../components/follow_request';
+import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts';
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+  onAuthorize () {
+    dispatch(authorizeFollowRequest(account.get('id')));
+  },
+
+  onReject () {
+    dispatch(rejectFollowRequest(account.get('id')));
+  },
+});
+
+export default connect(null, mapDispatchToProps)(FollowRequest);
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js
new file mode 100644
index 000000000..be007f30b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js
@@ -0,0 +1,26 @@
+//  Package imports.
+import { connect } from 'react-redux';
+
+//  Our imports.
+import { makeGetNotification } from 'flavours/glitch/selectors';
+import Notification from '../components/notification';
+import { mentionCompose } from 'flavours/glitch/actions/compose';
+
+const makeMapStateToProps = () => {
+  const getNotification = makeGetNotification();
+
+  const mapStateToProps = (state, props) => ({
+    notification: getNotification(state, props.notification, props.accountId),
+    notifCleaning: state.getIn(['notifications', 'cleaningMode']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+  onMention: (account, router) => {
+    dispatch(mentionCompose(account, router));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js
new file mode 100644
index 000000000..ee2d19814
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js
@@ -0,0 +1,18 @@
+//  Package imports.
+import { connect } from 'react-redux';
+
+//  Our imports.
+import NotificationOverlay from '../components/overlay';
+import { markNotificationForDelete } from 'flavours/glitch/actions/notifications';
+
+const mapDispatchToProps = dispatch => ({
+  onMarkForDelete(id, yes) {
+    dispatch(markNotificationForDelete(id, yes));
+  },
+});
+
+const mapStateToProps = state => ({
+  show: state.getIn(['notifications', 'cleaningMode']),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);
diff --git a/app/javascript/flavours/glitch/features/notifications/index.jsx b/app/javascript/flavours/glitch/features/notifications/index.jsx
new file mode 100644
index 000000000..2de073077
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/index.jsx
@@ -0,0 +1,382 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import {
+  enterNotificationClearingMode,
+  expandNotifications,
+  scrollTopNotifications,
+  mountNotifications,
+  unmountNotifications,
+  loadPending,
+  markNotificationsAsRead,
+} from 'flavours/glitch/actions/notifications';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { submitMarkers } from 'flavours/glitch/actions/markers';
+import NotificationContainer from './containers/notification_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import FilterBarContainer from './containers/filter_bar_container';
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+import { debounce } from 'lodash';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import LoadGap from 'flavours/glitch/components/load_gap';
+import Icon from 'flavours/glitch/components/icon';
+import compareId from 'flavours/glitch/compare_id';
+import NotificationsPermissionBanner from './components/notifications_permission_banner';
+import NotSignedInIndicator from 'flavours/glitch/components/not_signed_in_indicator';
+import { Helmet } from 'react-helmet';
+
+import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
+
+const messages = defineMessages({
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
+  markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
+});
+
+const getExcludedTypes = createSelector([
+  state => state.getIn(['settings', 'notifications', 'shows']),
+], (shows) => {
+  return ImmutableList(shows.filter(item => !item).keys());
+});
+
+const getNotifications = createSelector([
+  state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
+  state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
+  getExcludedTypes,
+  state => state.getIn(['notifications', 'items']),
+], (showFilterBar, allowedType, excludedTypes, notifications) => {
+  if (!showFilterBar || allowedType === 'all') {
+    // used if user changed the notification settings after loading the notifications from the server
+    // otherwise a list of notifications will come pre-filtered from the backend
+    // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
+    return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
+  }
+  return notifications.filter(item => item === null || allowedType === item.get('type'));
+});
+
+const mapStateToProps = state => ({
+  showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
+  notifications: getNotifications(state),
+  localSettings:  state.get('local_settings'),
+  isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0,
+  isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
+  hasMore: state.getIn(['notifications', 'hasMore']),
+  numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
+  notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
+  lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
+  canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
+});
+
+/* glitch */
+const mapDispatchToProps = dispatch => ({
+  onEnterCleaningMode(yes) {
+    dispatch(enterNotificationClearingMode(yes));
+  },
+  onMarkAsRead() {
+    dispatch(markNotificationsAsRead());
+    dispatch(submitMarkers({ immediate: true }));
+  },
+  onMount() {
+    dispatch(mountNotifications());
+  },
+  onUnmount() {
+    dispatch(unmountNotifications());
+  },
+  dispatch,
+});
+
+class Notifications extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    columnId: PropTypes.string,
+    notifications: ImmutablePropTypes.list.isRequired,
+    showFilterBar: PropTypes.bool.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    isLoading: PropTypes.bool,
+    isUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    numPending: PropTypes.number,
+    localSettings: ImmutablePropTypes.map,
+    notifCleaningActive: PropTypes.bool,
+    onEnterCleaningMode: PropTypes.func,
+    onMount: PropTypes.func,
+    onUnmount: PropTypes.func,
+    lastReadId: PropTypes.string,
+    canMarkAsRead: PropTypes.bool,
+    needsNotificationPermission: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  state = {
+    animatingNCD: false,
+  };
+
+  handleLoadGap = (maxId) => {
+    this.props.dispatch(expandNotifications({ maxId }));
+  };
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.notifications.last();
+    this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
+  }, 300, { leading: true });
+
+  handleLoadPending = () => {
+    this.props.dispatch(loadPending());
+  };
+
+  handleScrollToTop = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(true));
+  }, 100);
+
+  handleScroll = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
+  }, 100);
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('NOTIFICATIONS', {}));
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setColumnRef = c => {
+    this.column = c;
+  };
+
+  handleMoveUp = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
+    this._selectChild(elementIndex, true);
+  };
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
+    this._selectChild(elementIndex, false);
+  };
+
+  _selectChild (index, align_top) {
+    const container = this.column.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  componentDidMount () {
+    const { onMount } = this.props;
+    if (onMount) {
+      onMount();
+    }
+  }
+
+  componentWillUnmount () {
+    const { onUnmount } = this.props;
+    if (onUnmount) {
+      onUnmount();
+    }
+  }
+
+  handleTransitionEndNCD = () => {
+    this.setState({ animatingNCD: false });
+  };
+
+  onEnterCleaningMode = () => {
+    this.setState({ animatingNCD: true });
+    this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
+  };
+
+  handleMarkAsRead = () => {
+    this.props.onMarkAsRead();
+  };
+
+  render () {
+    const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
+    const { notifCleaning, notifCleaningActive } = this.props;
+    const { animatingNCD } = this.state;
+    const pinned = !!columnId;
+    const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
+    const { signedIn } = this.context.identity;
+
+    let scrollableContent = null;
+
+    const filterBarContainer = (signedIn && showFilterBar)
+      ? (<FilterBarContainer />)
+      : null;
+
+    if (isLoading && this.scrollableContent) {
+      scrollableContent = this.scrollableContent;
+    } else if (notifications.size > 0 || hasMore) {
+      scrollableContent = notifications.map((item, index) => item === null ? (
+        <LoadGap
+          key={'gap:' + notifications.getIn([index + 1, 'id'])}
+          disabled={isLoading}
+          maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
+          onClick={this.handleLoadGap}
+        />
+      ) : (
+        <NotificationContainer
+          key={item.get('id')}
+          notification={item}
+          accountId={item.get('account')}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+          unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
+        />
+      ));
+    } else {
+      scrollableContent = null;
+    }
+
+    this.scrollableContent = scrollableContent;
+
+    let scrollContainer;
+
+    if (signedIn) {
+      scrollContainer = (
+        <ScrollableList
+          scrollKey={`notifications-${columnId}`}
+          trackScroll={!pinned}
+          isLoading={isLoading}
+          showLoading={isLoading && notifications.size === 0}
+          hasMore={hasMore}
+          numPending={numPending}
+          prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
+          alwaysPrepend
+          emptyMessage={emptyMessage}
+          onLoadMore={this.handleLoadOlder}
+          onLoadPending={this.handleLoadPending}
+          onScrollToTop={this.handleScrollToTop}
+          onScroll={this.handleScroll}
+          bindToDocument={!multiColumn}
+        >
+          {scrollableContent}
+        </ScrollableList>
+      );
+    } else {
+      scrollContainer = <NotSignedInIndicator />;
+    }
+
+    const extraButtons = [];
+
+    if (canMarkAsRead) {
+      extraButtons.push(
+        <button
+          key='mark-as-read'
+          aria-label={intl.formatMessage(messages.markAsRead)}
+          title={intl.formatMessage(messages.markAsRead)}
+          onClick={this.handleMarkAsRead}
+          className='column-header__button'
+        >
+          <Icon id='check' />
+        </button>,
+      );
+    }
+
+    const notifCleaningButtonClassName = classNames('column-header__button', {
+      'active': notifCleaningActive,
+    });
+
+    const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
+      'collapsed': !notifCleaningActive,
+      'animating': animatingNCD,
+    });
+
+    const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
+
+    extraButtons.push(
+      <button
+        key='notif-cleaning'
+        aria-label={msgEnterNotifCleaning}
+        title={msgEnterNotifCleaning}
+        onClick={this.onEnterCleaningMode}
+        className={notifCleaningButtonClassName}
+      >
+        <Icon id='eraser' />
+      </button>,
+    );
+
+    const notifCleaningDrawer = (
+      <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
+        <div className='column-header__collapsible-inner nopad-drawer'>
+          {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
+        </div>
+      </div>
+    );
+
+    const extraButton = (
+      <>
+        {extraButtons}
+      </>
+    );
+
+    return (
+      <Column
+        bindToDocument={!multiColumn}
+        ref={this.setColumnRef}
+        name='notifications'
+        extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
+        label={intl.formatMessage(messages.title)}
+      >
+        <ColumnHeader
+          icon='bell'
+          active={isUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          localSettings={this.props.localSettings}
+          extraButton={extraButton}
+          appendContent={notifCleaningDrawer}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        {filterBarContainer}
+        {scrollContainer}
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Notifications));
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx
new file mode 100644
index 000000000..51fe023d3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx
@@ -0,0 +1,217 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import classNames from 'classnames';
+import { me, boostModal } from 'flavours/glitch/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+import { replyCompose } from 'flavours/glitch/actions/compose';
+import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { initBoostModal } from 'flavours/glitch/actions/boosts';
+
+const messages = defineMessages({
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+  cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+  open: { id: 'status.open', defaultMessage: 'Expand this status' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { statusId }) => ({
+    status: getStatus(state, { id: statusId }),
+    askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+    showReplyCount: state.getIn(['local_settings', 'show_reply_count']),
+  });
+
+  return mapStateToProps;
+};
+
+class Footer extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    status: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    askReplyConfirmation: PropTypes.bool,
+    showReplyCount: PropTypes.bool,
+    withOpenButton: PropTypes.bool,
+    onClose: PropTypes.func,
+  };
+
+  _performReply = () => {
+    const { dispatch, status, onClose } = this.props;
+    const { router } = this.context;
+
+    if (onClose) {
+      onClose(true);
+    }
+
+    dispatch(replyCompose(status, router.history));
+  };
+
+  handleReplyClick = () => {
+    const { dispatch, askReplyConfirmation, status, intl } = this.props;
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      if (askReplyConfirmation) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: this._performReply,
+        }));
+      } else {
+        this._performReply();
+      }
+    } else {
+      dispatch(openModal('INTERACTION', {
+        type: 'reply',
+        accountId: status.getIn(['account', 'id']),
+        url: status.get('url'),
+      }));
+    }
+  };
+
+  handleFavouriteClick = () => {
+    const { dispatch, status } = this.props;
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      if (status.get('favourited')) {
+        dispatch(unfavourite(status));
+      } else {
+        dispatch(favourite(status));
+      }
+    } else {
+      dispatch(openModal('INTERACTION', {
+        type: 'favourite',
+        accountId: status.getIn(['account', 'id']),
+        url: status.get('url'),
+      }));
+    }
+  };
+
+  _performReblog = (privacy) => {
+    const { dispatch, status } = this.props;
+    dispatch(reblog(status, privacy));
+  };
+
+  handleReblogClick = e => {
+    const { dispatch, status } = this.props;
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      if (status.get('reblogged')) {
+        dispatch(unreblog(status));
+      } else if ((e && e.shiftKey) || !boostModal) {
+        this._performReblog();
+      } else {
+        dispatch(initBoostModal({ status, onReblog: this._performReblog }));
+      }
+    } else {
+      dispatch(openModal('INTERACTION', {
+        type: 'reblog',
+        accountId: status.getIn(['account', 'id']),
+        url: status.get('url'),
+      }));
+    }
+  };
+
+  handleOpenClick = e => {
+    const { router } = this.context;
+
+    if (e.button !== 0 || !router) {
+      return;
+    }
+
+    const { status, onClose } = this.props;
+
+    if (onClose) {
+      onClose();
+    }
+
+    router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
+  };
+
+  render () {
+    const { status, intl, showReplyCount, withOpenButton } = this.props;
+
+    const publicStatus  = ['public', 'unlisted'].includes(status.get('visibility'));
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let replyIcon, replyTitle;
+
+    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);
+    }
+
+    let reblogTitle = '';
+
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
+
+    let replyButton = null;
+    if (showReplyCount) {
+      replyButton = (
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon}
+          onClick={this.handleReplyClick}
+          counter={status.get('replies_count')}
+          obfuscateCount
+        />
+      );
+    } else {
+      replyButton = (
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon}
+          onClick={this.handleReplyClick}
+        />
+      );
+    }
+
+    return (
+      <div className='picture-in-picture__footer'>
+        {replyButton}
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
+        {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />}
+      </div>
+    );
+  }
+
+}
+
+export default connect(makeMapStateToProps)(injectIntl(Footer));
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx
new file mode 100644
index 000000000..b9b90f1d8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { Link } from 'react-router-dom';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+const mapStateToProps = (state, { accountId }) => ({
+  account: state.getIn(['accounts', accountId]),
+});
+
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    statusId: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { account, statusId, onClose, intl } = this.props;
+
+    return (
+      <div className='picture-in-picture__header'>
+        <Link to={`/@${account.get('acct')}/${statusId}`} className='picture-in-picture__header__account'>
+          <Avatar account={account} size={36} />
+          <DisplayName account={account} />
+        </Link>
+
+        <IconButton icon='times' onClick={onClose} title={intl.formatMessage(messages.close)} />
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Header));
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.jsx b/app/javascript/flavours/glitch/features/picture_in_picture/index.jsx
new file mode 100644
index 000000000..e6fb64ff9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
+import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import Header from './components/header';
+import Footer from './components/footer';
+import classNames from 'classnames';
+
+const mapStateToProps = state => ({
+  ...state.get('picture_in_picture'),
+  left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left',
+});
+
+class PictureInPicture extends React.Component {
+
+  static propTypes = {
+    statusId: PropTypes.string,
+    accountId: PropTypes.string,
+    type: PropTypes.string,
+    src: PropTypes.string,
+    muted: PropTypes.bool,
+    volume: PropTypes.number,
+    currentTime: PropTypes.number,
+    poster: PropTypes.string,
+    backgroundColor: PropTypes.string,
+    foregroundColor: PropTypes.string,
+    accentColor: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    left: PropTypes.bool,
+  };
+
+  handleClose = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  };
+
+  render () {
+    const { type, src, currentTime, accountId, statusId, left } = this.props;
+
+    if (!currentTime) {
+      return null;
+    }
+
+    let player;
+
+    if (type === 'video') {
+      player = (
+        <Video
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          autoPlay
+          inline
+          alwaysVisible
+        />
+      );
+    } else if (type === 'audio') {
+      player = (
+        <Audio
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          poster={this.props.poster}
+          backgroundColor={this.props.backgroundColor}
+          foregroundColor={this.props.foregroundColor}
+          accentColor={this.props.accentColor}
+          autoPlay
+        />
+      );
+    }
+
+    return (
+      <div className={classNames('picture-in-picture', { left })}>
+        <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} />
+
+        {player}
+
+        <Footer statusId={statusId} />
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(PictureInPicture);
diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js
new file mode 100644
index 000000000..149d05c32
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import { injectIntl } from 'react-intl';
+import { pinAccount, unpinAccount } from 'flavours/glitch/actions/accounts';
+import Account from 'flavours/glitch/features/list_editor/components/account';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId, added }) => ({
+    account: getAccount(state, accountId),
+    added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added,
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+  onRemove: () => dispatch(unpinAccount(accountId)),
+  onAdd: () => dispatch(pinAccount(accountId)),
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js
new file mode 100644
index 000000000..db586ecf7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { injectIntl } from 'react-intl';
+import {
+  fetchPinnedAccountsSuggestions,
+  clearPinnedAccountsSuggestions,
+  changePinnedAccountsSuggestions,
+} from '../../../actions/accounts';
+import Search from 'flavours/glitch/features/list_editor/components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)),
+  onClear: () => dispatch(clearPinnedAccountsSuggestions()),
+  onChange: value => dispatch(changePinnedAccountsSuggestions(value)),
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));
diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.jsx b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.jsx
new file mode 100644
index 000000000..de3fff8ec
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/glitch/actions/accounts';
+import AccountContainer from './containers/account_container';
+import SearchContainer from './containers/search_container';
+import Motion from 'flavours/glitch/features/ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']),
+  searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onInitialize: () => dispatch(fetchPinnedAccounts()),
+  onClear: () => dispatch(clearPinnedAccountsSuggestions()),
+  onReset: () => dispatch(resetPinnedAccountsEditor()),
+});
+
+class PinnedAccountsEditor extends ImmutablePureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    onInitialize: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onReset: PropTypes.func.isRequired,
+    title: PropTypes.string.isRequired,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    searchAccountIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  componentDidMount () {
+    const { onInitialize } = this.props;
+    onInitialize();
+  }
+
+  componentWillUnmount () {
+    const { onReset } = this.props;
+    onReset();
+  }
+
+  render () {
+    const { accountIds, searchAccountIds, onClear } = this.props;
+    const showSearch = searchAccountIds.size > 0;
+
+    return (
+      <div className='modal-root__modal list-editor'>
+        <h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4>
+
+        <SearchContainer />
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner list-editor__accounts'>
+            {accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
+          </div>
+
+          {showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
+
+          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              (<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                {searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
+              </div>)
+            }
+          </Motion>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(PinnedAccountsEditor));
diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx
new file mode 100644
index 000000000..41be2f7f3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx
@@ -0,0 +1,65 @@
+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';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'pins', 'items']),
+  hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
+});
+
+class PinnedStatuses extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchPinnedStatuses());
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  render () {
+    const { intl, statusIds, hasMore, multiColumn } = this.props;
+
+    return (
+      <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
+        <ColumnBackButtonSlim />
+        <StatusList
+          statusIds={statusIds}
+          scrollKey='pinned_statuses'
+          hasMore={hasMore}
+          bindToDocument={!multiColumn}
+        />
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(PinnedStatuses));
diff --git a/app/javascript/flavours/glitch/features/privacy_policy/index.jsx b/app/javascript/flavours/glitch/features/privacy_policy/index.jsx
new file mode 100644
index 000000000..a43befa73
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/privacy_policy/index.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Helmet } from 'react-helmet';
+import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl';
+import Column from 'flavours/glitch/components/column';
+import api from 'flavours/glitch/api';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+const messages = defineMessages({
+  title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
+});
+
+class PrivacyPolicy extends React.PureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object,
+    multiColumn: PropTypes.bool,
+  };
+
+  state = {
+    content: null,
+    lastUpdated: null,
+    isLoading: true,
+  };
+
+  componentDidMount () {
+    api().get('/api/v1/instance/privacy_policy').then(({ data }) => {
+      this.setState({ content: data.content, lastUpdated: data.updated_at, isLoading: false });
+    }).catch(() => {
+      this.setState({ isLoading: false });
+    });
+  }
+
+  render () {
+    const { intl, multiColumn } = this.props;
+    const { isLoading, content, lastUpdated } = this.state;
+
+    return (
+      <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
+        <div className='scrollable privacy-policy'>
+          <div className='column-title'>
+            <h3><FormattedMessage id='privacy_policy.title' defaultMessage='Privacy Policy' /></h3>
+            <p><FormattedMessage id='privacy_policy.last_updated' defaultMessage='Last updated {date}' values={{ date: isLoading ? <Skeleton width='10ch' /> : <FormattedDate value={lastUpdated} year='numeric' month='short' day='2-digit' /> }} /></p>
+          </div>
+
+          <div
+            className='privacy-policy__body prose'
+            dangerouslySetInnerHTML={{ __html: content }}
+          />
+        </div>
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='all' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default injectIntl(PrivacyPolicy);
diff --git a/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..a44d5c784
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingText from 'flavours/glitch/components/setting_text';
+import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
+
+const messages = defineMessages({
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+});
+
+class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+  };
+
+  render () {
+    const { settings, onChange, intl } = this.props;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
+          <SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} />
+          {!settings.getIn(['other', 'onlyRemote']) && <SettingToggle settings={settings} settingPath={['other', 'allowLocalOnly']} onChange={onChange} label={<FormattedMessage id='community.column_settings.allow_local_only' defaultMessage='Show local-only toots' />} />}
+        </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>
+    );
+  }
+
+}
+
+export default injectIntl(ColumnSettings);
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..97b756658
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { changeColumnParams } from 'flavours/glitch/actions/columns';
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+
+  return {
+    settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'public']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { columnId }) => {
+  return {
+    onChange (key, checked) {
+      if (columnId) {
+        dispatch(changeColumnParams(columnId, key, checked));
+      } else {
+        dispatch(changeSetting(['public', ...key], checked));
+      }
+    },
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.jsx b/app/javascript/flavours/glitch/features/public_timeline/index.jsx
new file mode 100644
index 000000000..737e5723f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/index.jsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectPublicStream } from 'flavours/glitch/actions/streaming';
+import { Helmet } from 'react-helmet';
+import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
+
+const messages = defineMessages({
+  title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+  const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']);
+  const onlyRemote = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyRemote']) : state.getIn(['settings', 'public', 'other', 'onlyRemote']);
+  const allowLocalOnly = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'allowLocalOnly']) : state.getIn(['settings', 'public', 'other', 'allowLocalOnly']);
+  const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'public', 'regex', 'body']);
+  const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]);
+
+  return {
+    hasUnread: !!timelineState && timelineState.get('unread') > 0,
+    onlyMedia,
+    onlyRemote,
+    allowLocalOnly,
+    regex,
+  };
+};
+
+class PublicTimeline extends React.PureComponent {
+
+  static defaultProps = {
+    onlyMedia: false,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasUnread: PropTypes.bool,
+    onlyMedia: PropTypes.bool,
+    onlyRemote: PropTypes.bool,
+    allowLocalOnly: PropTypes.bool,
+    regex: PropTypes.string,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn(onlyRemote ? 'REMOTE' : 'PUBLIC', { other: { onlyMedia, onlyRemote, allowLocalOnly } }));
+    }
+  };
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  componentDidMount () {
+    const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
+    const { signedIn } = this.context.identity;
+
+    dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly }));
+    if (signedIn) {
+      this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly }));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { signedIn } = this.context.identity;
+
+    if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote || prevProps.allowLocalOnly !== this.props.allowLocalOnly) {
+      const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
+
+      if (this.disconnect) {
+        this.disconnect();
+      }
+
+      dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly }));
+
+      if (signedIn) {
+        this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly }));
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleLoadMore = maxId => {
+    const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
+
+    dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote, allowLocalOnly }));
+  };
+
+  render () {
+    const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='federated' label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='globe'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer columnId={columnId} />
+        </ColumnHeader>
+
+        <DismissableBanner id='public_timeline'>
+          <FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' />
+        </DismissableBanner>
+
+        <StatusListContainer
+          timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
+          onLoadMore={this.handleLoadMore}
+          trackScroll={!pinned}
+          scrollKey={`public_timeline-${columnId}`}
+          emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
+          bindToDocument={!multiColumn}
+          regex={this.props.regex}
+        />
+
+        <Helmet>
+          <title>{intl.formatMessage(messages.title)}</title>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(PublicTimeline));
diff --git a/app/javascript/flavours/glitch/features/reblogs/index.jsx b/app/javascript/flavours/glitch/features/reblogs/index.jsx
new file mode 100644
index 000000000..34fe24d3f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/reblogs/index.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { fetchReblogs } from 'flavours/glitch/actions/interactions';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import Icon from 'flavours/glitch/components/icon';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' },
+  refresh: { id: 'refresh', defaultMessage: 'Refresh' },
+});
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
+});
+
+class Reblogs extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    if (!this.props.accountIds) {
+      this.props.dispatch(fetchReblogs(this.props.params.statusId));
+    }
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchReblogs(nextProps.params.statusId));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  setRef = c => {
+    this.column = c;
+  };
+
+  handleRefresh = () => {
+    this.props.dispatch(fetchReblogs(this.props.params.statusId));
+  };
+
+  render () {
+    const { intl, accountIds, multiColumn } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this post yet. When someone does, they will show up here.' />;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='retweet'
+          title={intl.formatMessage(messages.heading)}
+          onClick={this.handleHeaderClick}
+          showBackButton
+          multiColumn={multiColumn}
+          extraButton={(
+            <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
+          )}
+        />
+
+        <ScrollableList
+          scrollKey='reblogs'
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Reblogs));
diff --git a/app/javascript/flavours/glitch/features/report/category.jsx b/app/javascript/flavours/glitch/features/report/category.jsx
new file mode 100644
index 000000000..43e311f3d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/category.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+import Option from './components/option';
+import { List as ImmutableList } from 'immutable';
+
+const messages = defineMessages({
+  dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' },
+  dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' },
+  spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' },
+  spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetitive replies' },
+  violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' },
+  violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' },
+  other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' },
+  other_description: { id: 'report.reasons.other_description', defaultMessage: 'The issue does not fit into other categories' },
+  status: { id: 'report.category.title_status', defaultMessage: 'post' },
+  account: { id: 'report.category.title_account', defaultMessage: 'profile' },
+});
+
+const mapStateToProps = state => ({
+  rules: state.getIn(['server', 'server', 'rules'], ImmutableList()),
+});
+
+class Category extends React.PureComponent {
+
+  static propTypes = {
+    onNextStep: PropTypes.func.isRequired,
+    rules: ImmutablePropTypes.list,
+    category: PropTypes.string,
+    onChangeCategory: PropTypes.func.isRequired,
+    startedFrom: PropTypes.oneOf(['status', 'account']),
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleNextClick = () => {
+    const { onNextStep, category } = this.props;
+
+    switch(category) {
+    case 'dislike':
+      onNextStep('thanks');
+      break;
+    case 'violation':
+      onNextStep('rules');
+      break;
+    default:
+      onNextStep('statuses');
+      break;
+    }
+  };
+
+  handleCategoryToggle = (value, checked) => {
+    const { onChangeCategory } = this.props;
+
+    if (checked) {
+      onChangeCategory(value);
+    }
+  };
+
+  render () {
+    const { category, startedFrom, rules, intl } = this.props;
+
+    const options = rules.size > 0 ? [
+      'spam',
+      'violation',
+      'other',
+    ] : [
+      'spam',
+      'other',
+    ];
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='report.category.title' defaultMessage="Tell us what's going on with this {type}" values={{ type: intl.formatMessage(messages[startedFrom]) }} /></h3>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='report.category.subtitle' defaultMessage='Choose the best match' /></p>
+
+        <div>
+          {options.map(item => (
+            <Option
+              key={item}
+              name='category'
+              value={item}
+              checked={category === item}
+              onToggle={this.handleCategoryToggle}
+              label={intl.formatMessage(messages[item])}
+              description={intl.formatMessage(messages[`${item}_description`])}
+            />
+          ))}
+        </div>
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleNextClick} disabled={category === null}><FormattedMessage id='report.next' defaultMessage='Next' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(Category));
diff --git a/app/javascript/flavours/glitch/features/report/comment.jsx b/app/javascript/flavours/glitch/features/report/comment.jsx
new file mode 100644
index 000000000..afcb7afa4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/comment.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+import Toggle from 'react-toggle';
+
+const messages = defineMessages({
+  placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' },
+});
+
+class Comment extends React.PureComponent {
+
+  static propTypes = {
+    onSubmit: PropTypes.func.isRequired,
+    comment: PropTypes.string.isRequired,
+    onChangeComment: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    isSubmitting: PropTypes.bool,
+    forward: PropTypes.bool,
+    isRemote: PropTypes.bool,
+    domain: PropTypes.string,
+    onChangeForward: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    const { onSubmit } = this.props;
+    onSubmit();
+  };
+
+  handleChange = e => {
+    const { onChangeComment } = this.props;
+    onChangeComment(e.target.value);
+  };
+
+  handleKeyDown = e => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleClick();
+    }
+  };
+
+  handleForwardChange = e => {
+    const { onChangeForward } = this.props;
+    onChangeForward(e.target.checked);
+  };
+
+  render () {
+    const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props;
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3>
+
+        <textarea
+          className='report-dialog-modal__textarea'
+          placeholder={intl.formatMessage(messages.placeholder)}
+          value={comment}
+          onChange={this.handleChange}
+          onKeyDown={this.handleKeyDown}
+          disabled={isSubmitting}
+        />
+
+        {isRemote && (
+          <React.Fragment>
+            <p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>
+
+            <label className='report-dialog-modal__toggle'>
+              <Toggle checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} />
+              <FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} />
+            </label>
+          </React.Fragment>
+        )}
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default injectIntl(Comment);
diff --git a/app/javascript/flavours/glitch/features/report/components/option.jsx b/app/javascript/flavours/glitch/features/report/components/option.jsx
new file mode 100644
index 000000000..6ecfc7a24
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/components/option.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Check from 'flavours/glitch/components/check';
+
+export default class Option extends React.PureComponent {
+
+  static propTypes = {
+    name: PropTypes.string.isRequired,
+    value: PropTypes.string.isRequired,
+    checked: PropTypes.bool,
+    label: PropTypes.node,
+    description: PropTypes.node,
+    onToggle: PropTypes.func,
+    multiple: PropTypes.bool,
+    labelComponent: PropTypes.node,
+  };
+
+  handleKeyPress = e => {
+    const { value, checked, onToggle } = this.props;
+
+    if (e.key === 'Enter' || e.key === ' ') {
+      e.stopPropagation();
+      e.preventDefault();
+      onToggle(value, !checked);
+    }
+  };
+
+  handleChange = e => {
+    const { value, onToggle } = this.props;
+    onToggle(value, e.target.checked);
+  };
+
+  render () {
+    const { name, value, checked, label, labelComponent, description, multiple } = this.props;
+
+    return (
+      <label className='dialog-option poll__option selectable'>
+        <input type={multiple ? 'checkbox' : 'radio'} name={name} value={value} checked={checked} onChange={this.handleChange} />
+
+        <span
+          className={classNames('poll__input', { active: checked, checkbox: multiple })}
+          tabIndex='0'
+          role='radio'
+          onKeyPress={this.handleKeyPress}
+          aria-checked={checked}
+          aria-label={label}
+        >{checked && <Check />}</span>
+
+        {labelComponent ? labelComponent : (
+          <span className='poll__option__text'>
+            <strong>{label}</strong>
+            {description}
+          </span>
+        )}
+      </label>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/report/components/status_check_box.jsx b/app/javascript/flavours/glitch/features/report/components/status_check_box.jsx
new file mode 100644
index 000000000..2231fc0ce
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/components/status_check_box.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusContent from 'flavours/glitch/components/status_content';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import Option from './option';
+import MediaAttachments from 'flavours/glitch/components/media_attachments';
+import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+
+export default class StatusCheckBox extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    status: ImmutablePropTypes.map.isRequired,
+    checked: PropTypes.bool,
+    onToggle: PropTypes.func.isRequired,
+  };
+
+  handleStatusesToggle = (value, checked) => {
+    const { onToggle } = this.props;
+    onToggle(value, checked);
+  };
+
+  render () {
+    const { status, checked } = this.props;
+
+    if (status.get('reblog')) {
+      return null;
+    }
+
+    const labelComponent = (
+      <div className='status-check-box__status poll__option__text'>
+        <div className='detailed-status__display-name'>
+          <div className='detailed-status__display-avatar'>
+            <Avatar account={status.get('account')} size={46} />
+          </div>
+
+          <div><DisplayName account={status.get('account')} /> · <VisibilityIcon visibility={status.get('visibility')} /><RelativeTimestamp timestamp={status.get('created_at')} /></div>
+        </div>
+
+        <StatusContent status={status} media={<MediaAttachments status={status} revealed={false} />} />
+      </div>
+    );
+
+    return (
+      <Option
+        name='status_ids'
+        value={status.get('id')}
+        checked={checked}
+        onToggle={this.handleStatusesToggle}
+        label={status.get('search_index')}
+        labelComponent={labelComponent}
+        multiple
+      />
+    );
+  }
+
+}
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..aa34b3efd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import StatusCheckBox from '../components/status_check_box';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { id }) => ({
+    status: getStatus(state, { id }),
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(StatusCheckBox);
diff --git a/app/javascript/flavours/glitch/features/report/rules.jsx b/app/javascript/flavours/glitch/features/report/rules.jsx
new file mode 100644
index 000000000..72ba75b48
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/rules.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+import Option from './components/option';
+
+const mapStateToProps = state => ({
+  rules: state.getIn(['server', 'server', 'rules']),
+});
+
+class Rules extends React.PureComponent {
+
+  static propTypes = {
+    onNextStep: PropTypes.func.isRequired,
+    rules: ImmutablePropTypes.list,
+    selectedRuleIds: ImmutablePropTypes.set.isRequired,
+    onToggle: PropTypes.func.isRequired,
+  };
+
+  handleNextClick = () => {
+    const { onNextStep } = this.props;
+    onNextStep('statuses');
+  };
+
+  handleRulesToggle = (value, checked) => {
+    const { onToggle } = this.props;
+    onToggle(value, checked);
+  };
+
+  render () {
+    const { rules, selectedRuleIds } = this.props;
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='report.rules.title' defaultMessage='Which rules are being violated?' /></h3>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='report.rules.subtitle' defaultMessage='Select all that apply' /></p>
+
+        <div>
+          {rules.map(item => (
+            <Option
+              key={item.get('id')}
+              name='rule_ids'
+              value={item.get('id')}
+              checked={selectedRuleIds.includes(item.get('id'))}
+              onToggle={this.handleRulesToggle}
+              label={item.get('text')}
+              multiple
+            />
+          ))}
+        </div>
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleNextClick} disabled={selectedRuleIds.size < 1}><FormattedMessage id='report.next' defaultMessage='Next' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Rules);
diff --git a/app/javascript/flavours/glitch/features/report/statuses.jsx b/app/javascript/flavours/glitch/features/report/statuses.jsx
new file mode 100644
index 000000000..a687917ce
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/statuses.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import StatusCheckBox from 'flavours/glitch/features/report/containers/status_check_box_container';
+import { OrderedSet } from 'immutable';
+import { FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+
+const mapStateToProps = (state, { accountId }) => ({
+  availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])),
+  isLoading: state.getIn(['timelines', `account:${accountId}:with_replies`, 'isLoading']),
+});
+
+class Statuses extends React.PureComponent {
+
+  static propTypes = {
+    onNextStep: PropTypes.func.isRequired,
+    accountId: PropTypes.string.isRequired,
+    availableStatusIds: ImmutablePropTypes.set.isRequired,
+    selectedStatusIds: ImmutablePropTypes.set.isRequired,
+    isLoading: PropTypes.bool,
+    onToggle: PropTypes.func.isRequired,
+  };
+
+  handleNextClick = () => {
+    const { onNextStep } = this.props;
+    onNextStep('comment');
+  };
+
+  render () {
+    const { availableStatusIds, selectedStatusIds, onToggle, isLoading } = this.props;
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='report.statuses.title' defaultMessage='Are there any posts that back up this report?' /></h3>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='report.statuses.subtitle' defaultMessage='Select all that apply' /></p>
+
+        <div className='report-dialog-modal__statuses'>
+          {isLoading ? <LoadingIndicator /> : availableStatusIds.union(selectedStatusIds).map(statusId => (
+            <StatusCheckBox
+              id={statusId}
+              key={statusId}
+              checked={selectedStatusIds.includes(statusId)}
+              onToggle={onToggle}
+            />
+          ))}
+        </div>
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleNextClick}><FormattedMessage id='report.next' defaultMessage='Next' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Statuses);
diff --git a/app/javascript/flavours/glitch/features/report/thanks.jsx b/app/javascript/flavours/glitch/features/report/thanks.jsx
new file mode 100644
index 000000000..30c88e2f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/thanks.jsx
@@ -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 Button from 'flavours/glitch/components/button';
+import { connect } from 'react-redux';
+import {
+  unfollowAccount,
+  muteAccount,
+  blockAccount,
+} from 'flavours/glitch/actions/accounts';
+
+const mapStateToProps = () => ({});
+
+class Thanks extends React.PureComponent {
+
+  static propTypes = {
+    submitted: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleCloseClick = () => {
+    const { onClose } = this.props;
+    onClose();
+  };
+
+  handleUnfollowClick = () => {
+    const { dispatch, account, onClose } = this.props;
+    dispatch(unfollowAccount(account.get('id')));
+    onClose();
+  };
+
+  handleMuteClick = () => {
+    const { dispatch, account, onClose } = this.props;
+    dispatch(muteAccount(account.get('id')));
+    onClose();
+  };
+
+  handleBlockClick = () => {
+    const { dispatch, account, onClose } = this.props;
+    dispatch(blockAccount(account.get('id')));
+    onClose();
+  };
+
+  render () {
+    const { account, submitted } = this.props;
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'>{submitted ? <FormattedMessage id='report.thanks.title_actionable' defaultMessage="Thanks for reporting, we'll look into this." /> : <FormattedMessage id='report.thanks.title' defaultMessage="Don't want to see this?" />}</h3>
+        <p className='report-dialog-modal__lead'>{submitted ? <FormattedMessage id='report.thanks.take_action_actionable' defaultMessage='While we review this, you can take action against @{name}:' values={{ name: account.get('username') }} /> : <FormattedMessage id='report.thanks.take_action' defaultMessage='Here are your options for controlling what you see on Mastodon:' />}</p>
+
+        {account.getIn(['relationship', 'following']) && (
+          <React.Fragment>
+            <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='report.unfollow' defaultMessage='Unfollow @{name}' values={{ name: account.get('username') }} /></h4>
+            <p className='report-dialog-modal__lead'><FormattedMessage id='report.unfollow_explanation' defaultMessage='You are following this account. To not see their posts in your home feed anymore, unfollow them.' /></p>
+            <Button secondary onClick={this.handleUnfollowClick}><FormattedMessage id='account.unfollow' defaultMessage='Unfollow' /></Button>
+            <hr />
+          </React.Fragment>
+        )}
+
+        <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.mute' defaultMessage='Mute @{name}' values={{ name: account.get('username') }} /></h4>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='report.mute_explanation' defaultMessage='You will not see their posts. They can still follow you and see your posts and will not know that they are muted.' /></p>
+        <Button secondary onClick={this.handleMuteClick}>{!account.getIn(['relationship', 'muting']) ? <FormattedMessage id='report.mute' defaultMessage='Mute' /> : <FormattedMessage id='account.muted' defaultMessage='Muted' />}</Button>
+
+        <hr />
+
+        <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.block' defaultMessage='Block @{name}' values={{ name: account.get('username') }} /></h4>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='report.block_explanation' defaultMessage='You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.' /></p>
+        <Button secondary onClick={this.handleBlockClick}>{!account.getIn(['relationship', 'blocking']) ? <FormattedMessage id='report.block' defaultMessage='Block' /> : <FormattedMessage id='account.blocked' defaultMessage='Blocked' />}</Button>
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(Thanks);
diff --git a/app/javascript/flavours/glitch/features/standalone/compose/index.jsx b/app/javascript/flavours/glitch/features/standalone/compose/index.jsx
new file mode 100644
index 000000000..c53442435
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/compose/index.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
+import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
+import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
+
+export default class Compose extends React.PureComponent {
+
+  render () {
+    return (
+      <div>
+        <ComposeFormContainer autoFocus />
+        <NotificationsContainer />
+        <ModalContainer />
+        <LoadingBarContainer className='loading-bar' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx
new file mode 100644
index 000000000..d5ab730d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx
@@ -0,0 +1,231 @@
+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/initial_state';
+import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
+import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+  edit: { id: 'status.edit', defaultMessage: 'Edit' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+  cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
+  more: { id: 'status.more', defaultMessage: 'More' },
+  mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
+  muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+  unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  block: { id: 'status.block', defaultMessage: 'Block @{name}' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+  share: { id: 'status.share', defaultMessage: 'Share' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
+  admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
+  admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
+  copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
+  openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
+});
+
+class ActionBar extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReply: PropTypes.func.isRequired,
+    onReblog: PropTypes.func.isRequired,
+    onFavourite: PropTypes.func.isRequired,
+    onBookmark: PropTypes.func.isRequired,
+    onMute: PropTypes.func,
+    onMuteConversation: PropTypes.func,
+    onBlock: PropTypes.func,
+    onDelete: PropTypes.func.isRequired,
+    onEdit: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onReport: PropTypes.func,
+    onPin: PropTypes.func,
+    onEmbed: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleReplyClick = () => {
+    this.props.onReply(this.props.status);
+  };
+
+  handleReblogClick = (e) => {
+    this.props.onReblog(this.props.status, e);
+  };
+
+  handleFavouriteClick = (e) => {
+    this.props.onFavourite(this.props.status, e);
+  };
+
+  handleBookmarkClick = (e) => {
+    this.props.onBookmark(this.props.status, e);
+  };
+
+  handleDeleteClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history);
+  };
+
+  handleRedraftClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history, true);
+  };
+
+  handleEditClick = () => {
+    this.props.onEdit(this.props.status, this.context.router.history);
+  };
+
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  };
+
+  handleMentionClick = () => {
+    this.props.onMention(this.props.status.get('account'), this.context.router.history);
+  };
+
+  handleMuteClick = () => {
+    this.props.onMute(this.props.status.get('account'));
+  };
+
+  handleConversationMuteClick = () => {
+    this.props.onMuteConversation(this.props.status);
+  };
+
+  handleBlockClick = () => {
+    this.props.onBlock(this.props.status);
+  };
+
+  handleReport = () => {
+    this.props.onReport(this.props.status);
+  };
+
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  };
+
+  handleShare = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  };
+
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  };
+
+  handleCopy = () => {
+    const url = this.props.status.get('url');
+    navigator.clipboard.writeText(url);
+  };
+
+  render () {
+    const { status, intl } = this.props;
+    const { signedIn, permissions } = this.context.identity;
+
+    const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
+    const mutingConversation = status.get('muted');
+    const writtenByMe        = status.getIn(['account', 'id']) === me;
+    const isRemote           = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
+
+    let menu = [];
+
+    if (publicStatus) {
+      if (isRemote) {
+        menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+      menu.push(null);
+    }
+
+    if (writtenByMe) {
+      if (pinnableStatus) {
+        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.edit), action: this.handleEditClick });
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+      menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+      if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+        menu.push(null);
+        if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+          if (accountAdminLink !== undefined) {
+            menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
+          }
+          if (statusAdminLink !== undefined) {
+            menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
+          }
+        }
+        if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+          const domain = status.getIn(['account', 'acct']).split('@')[1];
+          menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+        }
+      }
+    }
+
+    const shareButton = ('share' in navigator) && publicStatus && (
+      <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
+    );
+
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let reblogTitle;
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
+
+    return (
+      <div className='detailed-status__action-bar'>
+        <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
+        <div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
+        {shareButton}
+        <div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
+
+        <div className='detailed-status__action-bar-dropdown'>
+          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title={intl.formatMessage(messages.more)} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/flavours/glitch/features/status/components/card.jsx b/app/javascript/flavours/glitch/features/status/components/card.jsx
new file mode 100644
index 000000000..359dbbc20
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/card.jsx
@@ -0,0 +1,282 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Immutable from 'immutable';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import punycode from 'punycode';
+import classnames from 'classnames';
+import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
+import Icon from 'flavours/glitch/components/icon';
+import { useBlurhash } from 'flavours/glitch/initial_state';
+import Blurhash from 'flavours/glitch/components/blurhash';
+import { debounce } from 'lodash';
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+const trim = (text, len) => {
+  const cut = text.indexOf(' ', len);
+
+  if (cut === -1) {
+    return text;
+  }
+
+  return text.slice(0, cut) + (text.length > len ? '…' : '');
+};
+
+const domParser = new DOMParser();
+
+const addAutoPlay = html => {
+  const document = domParser.parseFromString(html, 'text/html').documentElement;
+  const iframe = document.querySelector('iframe');
+
+  if (iframe) {
+    if (iframe.src.indexOf('?') !== -1) {
+      iframe.src += '&';
+    } else {
+      iframe.src += '?';
+    }
+
+    iframe.src += 'autoplay=1&auto_play=1';
+
+    // DOM parser creates html/body elements around original HTML fragment,
+    // so we need to get innerHTML out of the body and not the entire document
+    return document.querySelector('body').innerHTML;
+  }
+
+  return html;
+};
+
+export default class Card extends React.PureComponent {
+
+  static propTypes = {
+    card: ImmutablePropTypes.map,
+    maxDescription: PropTypes.number,
+    onOpenMedia: PropTypes.func.isRequired,
+    compact: PropTypes.bool,
+    defaultWidth: PropTypes.number,
+    cacheWidth: PropTypes.func,
+    sensitive: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    maxDescription: 50,
+    compact: false,
+  };
+
+  state = {
+    width: this.props.defaultWidth || 280,
+    previewLoaded: false,
+    embedded: false,
+    revealed: !this.props.sensitive,
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (!Immutable.is(this.props.card, nextProps.card)) {
+      this.setState({ embedded: false, previewLoaded: false });
+    }
+    if (this.props.sensitive !== nextProps.sensitive) {
+      this.setState({ revealed: !nextProps.sensitive });
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  _setDimensions () {
+    const width = this.node.offsetWidth;
+
+    if (this.props.cacheWidth) {
+      this.props.cacheWidth(width);
+    }
+
+    this.setState({ width });
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  handlePhotoClick = () => {
+    const { card, onOpenMedia } = this.props;
+
+    onOpenMedia(
+      Immutable.fromJS([
+        {
+          type: 'image',
+          url: card.get('embed_url'),
+          description: card.get('title'),
+          meta: {
+            original: {
+              width: card.get('width'),
+              height: card.get('height'),
+            },
+          },
+        },
+      ]),
+      0,
+    );
+  };
+
+  handleEmbedClick = () => {
+    const { card } = this.props;
+
+    if (card.get('type') === 'photo') {
+      this.handlePhotoClick();
+    } else {
+      this.setState({ embedded: true });
+    }
+  };
+
+  setRef = c => {
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  };
+
+  handleImageLoad = () => {
+    this.setState({ previewLoaded: true });
+  };
+
+  handleReveal = e => {
+    e.preventDefault();
+    e.stopPropagation();
+    this.setState({ revealed: true });
+  };
+
+  renderVideo () {
+    const { card }  = this.props;
+    const content   = { __html: addAutoPlay(card.get('html')) };
+    const { width } = this.state;
+    const ratio     = card.get('width') / card.get('height');
+    const height    = width / ratio;
+
+    return (
+      <div
+        ref={this.setRef}
+        className='status-card__image status-card-video'
+        dangerouslySetInnerHTML={content}
+        style={{ height }}
+      />
+    );
+  }
+
+  render () {
+    const { card, maxDescription, compact, defaultWidth } = this.props;
+    const { width, embedded, revealed } = this.state;
+
+    if (card === null) {
+      return null;
+    }
+
+    const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
+    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
+    const interactive = card.get('type') !== 'link';
+    const className   = classnames('status-card', { horizontal, compact, interactive });
+    const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
+    const language    = card.get('language') || '';
+    const ratio       = card.get('width') / card.get('height');
+    const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+    const description = (
+      <div className='status-card__content' lang={language}>
+        {title}
+        {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
+        <span className='status-card__host'>{provider}</span>
+      </div>
+    );
+
+    let embed     = '';
+    let canvas = (
+      <Blurhash
+        className={classnames('status-card__image-preview', {
+          'status-card__image-preview--hidden': revealed && this.state.previewLoaded,
+        })}
+        hash={card.get('blurhash')}
+        dummy={!useBlurhash}
+      />
+    );
+    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let spoilerButton = (
+      <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
+        <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+      </button>
+    );
+    spoilerButton = (
+      <div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+        {spoilerButton}
+      </div>
+    );
+
+    if (interactive) {
+      if (embedded) {
+        embed = this.renderVideo();
+      } else {
+        let iconVariant = 'play';
+
+        if (card.get('type') === 'photo') {
+          iconVariant = 'search-plus';
+        }
+
+        embed = (
+          <div className='status-card__image'>
+            {canvas}
+            {thumbnail}
+
+            {revealed && (
+              <div className='status-card__actions'>
+                <div>
+                  <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
+                  {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+                </div>
+              </div>
+            )}
+            {!revealed && spoilerButton}
+          </div>
+        );
+      }
+
+      return (
+        <div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
+          {embed}
+          {!compact && description}
+        </div>
+      );
+    } else if (card.get('image')) {
+      embed = (
+        <div className='status-card__image'>
+          {canvas}
+          {thumbnail}
+        </div>
+      );
+    } else {
+      embed = (
+        <div className='status-card__image'>
+          <Icon id='file-text' />
+        </div>
+      );
+    }
+
+    return (
+      <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
+        {embed}
+        {description}
+      </a>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
new file mode 100644
index 000000000..cfe6c965e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
@@ -0,0 +1,339 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import StatusContent from 'flavours/glitch/components/status_content';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import { Link } from 'react-router-dom';
+import { injectIntl, FormattedDate } from 'react-intl';
+import Card from './card';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
+import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+import scheduleIdleTask from '../../ui/util/schedule_idle_task';
+import classNames from 'classnames';
+import PollContainer from 'flavours/glitch/containers/poll_container';
+import Icon from 'flavours/glitch/components/icon';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
+import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
+
+class DetailedStatus extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    settings: ImmutablePropTypes.map.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
+    onOpenVideo: PropTypes.func.isRequired,
+    onToggleHidden: PropTypes.func,
+    onTranslate: PropTypes.func.isRequired,
+    expanded: PropTypes.bool,
+    measureHeight: PropTypes.bool,
+    onHeightChange: PropTypes.func,
+    domain: PropTypes.string.isRequired,
+    compact: PropTypes.bool,
+    showMedia: PropTypes.bool,
+    pictureInPicture: ImmutablePropTypes.contains({
+      inUse: PropTypes.bool,
+      available: PropTypes.bool,
+    }),
+    onToggleMediaVisibility: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    height: null,
+  };
+
+  handleAccountClick = (e) => {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
+      e.preventDefault();
+      let state = { ...this.context.router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
+    }
+
+    e.stopPropagation();
+  };
+
+  parseClick = (e, destination) => {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
+      e.preventDefault();
+      let state = { ...this.context.router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(destination, state);
+    }
+
+    e.stopPropagation();
+  };
+
+  handleOpenVideo = (options) => {
+    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
+  };
+
+  _measureHeight (heightJustChanged) {
+    if (this.props.measureHeight && this.node) {
+      scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
+
+      if (this.props.onHeightChange && heightJustChanged) {
+        this.props.onHeightChange();
+      }
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+    this._measureHeight();
+  };
+
+  componentDidUpdate (prevProps, prevState) {
+    this._measureHeight(prevState.height !== this.state.height);
+  }
+
+  handleChildUpdate = () => {
+    this._measureHeight();
+  };
+
+  handleModalLink = e => {
+    e.preventDefault();
+
+    let href;
+
+    if (e.target.nodeName !== 'A') {
+      href = e.target.parentNode.href;
+    } else {
+      href = e.target.href;
+    }
+
+    window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+  };
+
+  handleTranslate = () => {
+    const { onTranslate, status } = this.props;
+    onTranslate(status);
+  };
+
+  render () {
+    const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
+    const { expanded, onToggleHidden, settings, pictureInPicture, intl } = this.props;
+    const outerStyle = { boxSizing: 'border-box' };
+    const { compact } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    let applicationLink = '';
+    let reblogLink = '';
+    let reblogIcon = 'retweet';
+    let favouriteLink = '';
+    let edited = '';
+
+    //  Depending on user settings, some media are considered as parts of the
+    //  contents (affected by CW) while other will be displayed outside of the
+    //  CW.
+    let contentMedia = [];
+    let contentMediaIcons = [];
+    let extraMedia = [];
+    let extraMediaIcons = [];
+    let media = contentMedia;
+    let mediaIcons = contentMediaIcons;
+
+    if (settings.getIn(['content_warnings', 'media_outside'])) {
+      media = extraMedia;
+      mediaIcons = extraMediaIcons;
+    }
+
+    if (this.props.measureHeight) {
+      outerStyle.height = `${this.state.height}px`;
+    }
+
+    if (pictureInPicture.get('inUse')) {
+      media.push(<PictureInPicturePlaceholder />);
+      mediaIcons.push('video-camera');
+    } else if (status.get('media_attachments').size > 0) {
+      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+        media.push(<AttachmentList media={status.get('media_attachments')} />);
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media.push(
+          <Audio
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            lang={status.get('language')}
+            duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+            poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+            backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
+            foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
+            accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
+            sensitive={status.get('sensitive')}
+            visible={this.props.showMedia}
+            blurhash={attachment.get('blurhash')}
+            height={150}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />,
+        );
+        mediaIcons.push('music');
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        const attachment = status.getIn(['media_attachments', 0]);
+        media.push(
+          <Video
+            preview={attachment.get('preview_url')}
+            frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
+            blurhash={attachment.get('blurhash')}
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            lang={status.get('language')}
+            inline
+            sensitive={status.get('sensitive')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            preventPlayback={!expanded}
+            onOpenVideo={this.handleOpenVideo}
+            autoplay
+            visible={this.props.showMedia}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />,
+        );
+        mediaIcons.push('video-camera');
+      } else {
+        media.push(
+          <MediaGallery
+            standalone
+            sensitive={status.get('sensitive')}
+            media={status.get('media_attachments')}
+            lang={status.get('language')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            hidden={!expanded}
+            onOpenMedia={this.props.onOpenMedia}
+            visible={this.props.showMedia}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />,
+        );
+        mediaIcons.push('picture-o');
+      }
+    } else if (status.get('card')) {
+      media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
+      mediaIcons.push('link');
+    }
+
+    if (status.get('poll')) {
+      contentMedia.push(<PollContainer pollId={status.get('poll')} lang={status.get('language')} />);
+      contentMediaIcons.push('tasks');
+    }
+
+    if (status.get('application')) {
+      applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
+    }
+
+    const visibilityLink = <React.Fragment> · <VisibilityIcon visibility={status.get('visibility')} /></React.Fragment>;
+
+    if (status.get('visibility') === 'direct') {
+      reblogIcon = 'envelope';
+    } else if (status.get('visibility') === 'private') {
+      reblogIcon = 'lock';
+    }
+
+    if (!['unlisted', 'public'].includes(status.get('visibility'))) {
+      reblogLink = null;
+    } else if (this.context.router) {
+      reblogLink = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
+            <Icon id={reblogIcon} />
+            <span className='detailed-status__reblogs'>
+              <AnimatedNumber value={status.get('reblogs_count')} />
+            </span>
+          </Link>
+        </React.Fragment>
+      );
+    } else {
+      reblogLink = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
+            <Icon id={reblogIcon} />
+            <span className='detailed-status__reblogs'>
+              <AnimatedNumber value={status.get('reblogs_count')} />
+            </span>
+          </a>
+        </React.Fragment>
+      );
+    }
+
+    if (this.context.router) {
+      favouriteLink = (
+        <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
+          <Icon id='star' />
+          <span className='detailed-status__favorites'>
+            <AnimatedNumber value={status.get('favourites_count')} />
+          </span>
+        </Link>
+      );
+    } else {
+      favouriteLink = (
+        <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <Icon id='star' />
+          <span className='detailed-status__favorites'>
+            <AnimatedNumber value={status.get('favourites_count')} />
+          </span>
+        </a>
+      );
+    }
+
+    if (status.get('edited_at')) {
+      edited = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
+        </React.Fragment>
+      );
+    }
+
+    return (
+      <div style={outerStyle}>
+        <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+            <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
+            <DisplayName account={status.get('account')} localDomain={this.props.domain} />
+          </a>
+
+          <StatusContent
+            status={status}
+            media={contentMedia}
+            extraMedia={extraMedia}
+            mediaIcons={contentMediaIcons}
+            expanded={expanded}
+            collapsed={false}
+            onExpandedToggle={onToggleHidden}
+            onTranslate={this.handleTranslate}
+            parseClick={this.parseClick}
+            onUpdate={this.handleChildUpdate}
+            tagLinks={settings.get('tag_misleading_links')}
+            rewriteMentions={settings.get('rewrite_mentions')}
+            disabled
+          />
+
+          <div className='detailed-status__meta'>
+            <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
+              <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+            </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(DetailedStatus);
diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
new file mode 100644
index 000000000..e5e065987
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
@@ -0,0 +1,161 @@
+import { connect } from 'react-redux';
+import DetailedStatus from '../components/detailed_status';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  reblog,
+  favourite,
+  unreblog,
+  unfavourite,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  hideStatus,
+  revealStatus,
+} from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { initBoostModal } from 'flavours/glitch/actions/boosts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl } from 'react-intl';
+import { boostModal, deleteModal } from 'flavours/glitch/initial_state';
+import { showAlertForError } from 'flavours/glitch/actions/alerts';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props),
+    domain: state.getIn(['meta', 'domain']),
+    settings: state.get('local_settings'),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onReply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  onModalReblog (status, privacy) {
+    dispatch(reblog(status, privacy));
+  },
+
+  onReblog (status, e) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !boostModal) {
+        this.onModalReblog(status);
+      } else {
+        dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
+      }
+    }
+  },
+
+  onFavourite (status) {
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      dispatch(favourite(status));
+    }
+  },
+
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', {
+      url: status.get('url'),
+      onError: error => dispatch(showAlertForError(error)),
+    }));
+  },
+
+  onDelete (status, history, withRedraft = false) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onOpenMedia (media, index) {
+    dispatch(openModal('MEDIA', { media, index }));
+  },
+
+  onOpenVideo (media, options) {
+    dispatch(openModal('VIDEO', { media, options }));
+  },
+
+  onBlock (status) {
+    const account = status.get('account');
+    dispatch(initBlockModal(account));
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
+  },
+
+  onMute (account) {
+    dispatch(initMuteModal(account));
+  },
+
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx
new file mode 100644
index 000000000..a59da5e10
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/index.jsx
@@ -0,0 +1,726 @@
+import Immutable from 'immutable';
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { createSelector } from 'reselect';
+import {
+  fetchStatus,
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  editStatus,
+  hideStatus,
+  revealStatus,
+  translateStatus,
+  undoStatusTranslation,
+} from 'flavours/glitch/actions/statuses';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from 'flavours/glitch/features/ui/components/column';
+import {
+  favourite,
+  unfavourite,
+  bookmark,
+  unbookmark,
+  reblog,
+  unreblog,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { initBoostModal } from 'flavours/glitch/actions/boosts';
+import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import ColumnHeader from '../../components/column_header';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/initial_state';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
+import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
+import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status';
+import Icon from 'flavours/glitch/components/icon';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
+  revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
+  hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
+  detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+  tootHeading: { id: 'account.posts_with_replies', defaultMessage: 'Posts and replies' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+  const getPictureInPicture = makeGetPictureInPicture();
+
+  const getAncestorsIds = createSelector([
+    (_, { id }) => id,
+    state => state.getIn(['contexts', 'inReplyTos']),
+  ], (statusId, inReplyTos) => {
+    let ancestorsIds = Immutable.List();
+    ancestorsIds = ancestorsIds.withMutations(mutable => {
+      let id = statusId;
+
+      while (id && !mutable.includes(id)) {
+        mutable.unshift(id);
+        id = inReplyTos.get(id);
+      }
+    });
+
+    return ancestorsIds;
+  });
+
+  const getDescendantsIds = createSelector([
+    (_, { id }) => id,
+    state => state.getIn(['contexts', 'replies']),
+    state => state.get('statuses'),
+  ], (statusId, contextReplies, statuses) => {
+    let descendantsIds = [];
+    const ids = [statusId];
+
+    while (ids.length > 0) {
+      let id        = ids.pop();
+      const replies = contextReplies.get(id);
+
+      if (statusId !== id) {
+        descendantsIds.push(id);
+      }
+
+      if (replies) {
+        replies.reverse().forEach(reply => {
+          if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
+        });
+      }
+    }
+
+    let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
+    if (insertAt !== -1) {
+      descendantsIds.forEach((id, idx) => {
+        if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
+          descendantsIds.splice(idx, 1);
+          descendantsIds.splice(insertAt, 0, id);
+          insertAt += 1;
+        }
+      });
+    }
+
+    return Immutable.List(descendantsIds);
+  });
+
+  const mapStateToProps = (state, props) => {
+    const status = getStatus(state, { id: props.params.statusId });
+
+    let ancestorsIds   = Immutable.List();
+    let descendantsIds = Immutable.List();
+
+    if (status) {
+      ancestorsIds   = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
+      descendantsIds = getDescendantsIds(state, { id: status.get('id') });
+    }
+
+    return {
+      isLoading: state.getIn(['statuses', props.params.statusId, 'isLoading']),
+      status,
+      ancestorsIds,
+      descendantsIds,
+      settings: state.get('local_settings'),
+      askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
+      domain: state.getIn(['meta', 'domain']),
+      pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const truncate = (str, num) => {
+  if (str.length > num) {
+    return str.slice(0, num) + '…';
+  } else {
+    return str;
+  }
+};
+
+const titleFromStatus = status => {
+  const displayName = status.getIn(['account', 'display_name']);
+  const username = status.getIn(['account', 'username']);
+  const prefix = displayName.trim().length === 0 ? username : displayName;
+  const text = status.get('search_index');
+
+  return `${prefix}: "${truncate(text, 30)}"`;
+};
+
+class Status extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    status: ImmutablePropTypes.map,
+    isLoading: PropTypes.bool,
+    settings: ImmutablePropTypes.map.isRequired,
+    ancestorsIds: ImmutablePropTypes.list,
+    descendantsIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+    askReplyConfirmation: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
+    pictureInPicture: ImmutablePropTypes.contains({
+      inUse: PropTypes.bool,
+      available: PropTypes.bool,
+    }),
+  };
+
+  state = {
+    fullscreen: false,
+    isExpanded: undefined,
+    threadExpanded: undefined,
+    statusId: undefined,
+    loadedStatusId: undefined,
+    showMedia: undefined,
+    revealBehindCW: undefined,
+  };
+
+  componentDidMount () {
+    attachFullscreenListener(this.onFullScreenChange);
+    this.props.dispatch(fetchStatus(this.props.params.statusId));
+
+    const { status, ancestorsIds } = this.props;
+
+    if (status && ancestorsIds && ancestorsIds.size > 0) {
+      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+      window.requestAnimationFrame(() => {
+        element.scrollIntoView(true);
+      });
+    }
+  }
+
+  static getDerivedStateFromProps(props, state) {
+    let update = {};
+    let updated = false;
+
+    if (props.params.statusId && state.statusId !== props.params.statusId) {
+      props.dispatch(fetchStatus(props.params.statusId));
+      update.threadExpanded = undefined;
+      update.statusId = props.params.statusId;
+      updated = true;
+    }
+
+    const revealBehindCW = props.settings.getIn(['media', 'reveal_behind_cw']);
+    if (revealBehindCW !== state.revealBehindCW) {
+      update.revealBehindCW = revealBehindCW;
+      if (revealBehindCW) update.showMedia = defaultMediaVisibility(props.status, props.settings);
+      updated = true;
+    }
+
+    if (props.status && state.loadedStatusId !== props.status.get('id')) {
+      update.showMedia = defaultMediaVisibility(props.status, props.settings);
+      update.loadedStatusId = props.status.get('id');
+      update.isExpanded = autoUnfoldCW(props.settings, props.status);
+      updated = true;
+    }
+
+    return updated ? update : null;
+  }
+
+  handleToggleHidden = () => {
+    const { status } = this.props;
+
+    if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
+      if (status.get('hidden')) {
+        this.props.dispatch(revealStatus(status.get('id')));
+      } else {
+        this.props.dispatch(hideStatus(status.get('id')));
+      }
+    } else if (this.props.status.get('spoiler_text')) {
+      this.setExpansion(!this.state.isExpanded);
+    }
+  };
+
+  handleToggleMediaVisibility = () => {
+    this.setState({ showMedia: !this.state.showMedia });
+  };
+
+  handleModalFavourite = (status) => {
+    this.props.dispatch(favourite(status));
+  };
+
+  handleFavouriteClick = (status, e) => {
+    const { dispatch } = this.props;
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      if (status.get('favourited')) {
+        dispatch(unfavourite(status));
+      } else {
+        if ((e && e.shiftKey) || !favouriteModal) {
+          this.handleModalFavourite(status);
+        } else {
+          dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite }));
+        }
+      }
+    } else {
+      dispatch(openModal('INTERACTION', {
+        type: 'favourite',
+        accountId: status.getIn(['account', 'id']),
+        url: status.get('url'),
+      }));
+    }
+  };
+
+  handlePin = (status) => {
+    if (status.get('pinned')) {
+      this.props.dispatch(unpin(status));
+    } else {
+      this.props.dispatch(pin(status));
+    }
+  };
+
+  handleReplyClick = (status) => {
+    const { askReplyConfirmation, dispatch, intl } = this.props;
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      if (askReplyConfirmation) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
+          onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
+        }));
+      } else {
+        dispatch(replyCompose(status, this.context.router.history));
+      }
+    } else {
+      dispatch(openModal('INTERACTION', {
+        type: 'reply',
+        accountId: status.getIn(['account', 'id']),
+        url: status.get('url'),
+      }));
+    }
+  };
+
+  handleModalReblog = (status, privacy) => {
+    const { dispatch } = this.props;
+
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      dispatch(reblog(status, privacy));
+    }
+  };
+
+  handleReblogClick = (status, e) => {
+    const { settings, dispatch } = this.props;
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
+      if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
+        dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
+      } else if ((e && e.shiftKey) || !boostModal) {
+        this.handleModalReblog(status);
+      } else {
+        dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
+      }
+    } else {
+      dispatch(openModal('INTERACTION', {
+        type: 'reblog',
+        accountId: status.getIn(['account', 'id']),
+        url: status.get('url'),
+      }));
+    }
+  };
+
+  handleBookmarkClick = (status) => {
+    if (status.get('bookmarked')) {
+      this.props.dispatch(unbookmark(status));
+    } else {
+      this.props.dispatch(bookmark(status));
+    }
+  };
+
+  handleDeleteClick = (status, history, withRedraft = false) => {
+    const { dispatch, intl } = this.props;
+
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  };
+
+  handleEditClick = (status, history) => {
+    this.props.dispatch(editStatus(status.get('id'), history));
+  };
+
+  handleDirectClick = (account, router) => {
+    this.props.dispatch(directCompose(account, router));
+  };
+
+  handleMentionClick = (account, router) => {
+    this.props.dispatch(mentionCompose(account, router));
+  };
+
+  handleOpenMedia = (media, index) => {
+    this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index }));
+  };
+
+  handleOpenVideo = (media, options) => {
+    this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
+  };
+
+  handleHotkeyOpenMedia = e => {
+    const { status } = this.props;
+
+    e.preventDefault();
+
+    if (status.get('media_attachments').size > 0) {
+      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
+      } else {
+        this.handleOpenMedia(status.get('media_attachments'), 0);
+      }
+    }
+  };
+
+  handleMuteClick = (account) => {
+    this.props.dispatch(initMuteModal(account));
+  };
+
+  handleConversationMuteClick = (status) => {
+    if (status.get('muted')) {
+      this.props.dispatch(unmuteStatus(status.get('id')));
+    } else {
+      this.props.dispatch(muteStatus(status.get('id')));
+    }
+  };
+
+  handleToggleAll = () => {
+    const { status, ancestorsIds, descendantsIds, settings } = this.props;
+    const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
+    let { isExpanded } = this.state;
+
+    if (settings.getIn(['content_warnings', 'shared_state']))
+      isExpanded = !status.get('hidden');
+
+    if (!isExpanded) {
+      this.props.dispatch(revealStatus(statusIds));
+    } else {
+      this.props.dispatch(hideStatus(statusIds));
+    }
+
+    this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded });
+  };
+
+  handleTranslate = status => {
+    const { dispatch } = this.props;
+
+    if (status.get('translation')) {
+      dispatch(undoStatusTranslation(status.get('id')));
+    } else {
+      dispatch(translateStatus(status.get('id')));
+    }
+  };
+
+  handleBlockClick = (status) => {
+    const { dispatch } = this.props;
+    const account = status.get('account');
+    dispatch(initBlockModal(account));
+  };
+
+  handleReport = (status) => {
+    this.props.dispatch(initReport(status.get('account'), status));
+  };
+
+  handleEmbed = (status) => {
+    this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+  };
+
+  handleHotkeyToggleSensitive = () => {
+    this.handleToggleMediaVisibility();
+  };
+
+  handleHotkeyMoveUp = () => {
+    this.handleMoveUp(this.props.status.get('id'));
+  };
+
+  handleHotkeyMoveDown = () => {
+    this.handleMoveDown(this.props.status.get('id'));
+  };
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.handleReplyClick(this.props.status);
+  };
+
+  handleHotkeyFavourite = () => {
+    this.handleFavouriteClick(this.props.status);
+  };
+
+  handleHotkeyBoost = () => {
+    this.handleReblogClick(this.props.status);
+  };
+
+  handleHotkeyBookmark = () => {
+    this.handleBookmarkClick(this.props.status);
+  };
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.handleMentionClick(this.props.status);
+  };
+
+  handleHotkeyOpenProfile = () => {
+    let state = { ...this.context.router.history.location.state };
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
+  };
+
+  handleMoveUp = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size - 1, true);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index, true);
+      } else {
+        this._selectChild(index - 1, true);
+      }
+    }
+  };
+
+  handleMoveDown = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size + 1, false);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index + 2, false);
+      } else {
+        this._selectChild(index + 1, false);
+      }
+    }
+  };
+
+  _selectChild (index, align_top) {
+    const container = this.node;
+    const element = container.querySelectorAll('.focusable')[index];
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  };
+
+  renderChildren (list) {
+    return list.map(id => (
+      <StatusContainer
+        key={id}
+        id={id}
+        expanded={this.state.threadExpanded}
+        onMoveUp={this.handleMoveUp}
+        onMoveDown={this.handleMoveDown}
+        contextType='thread'
+      />
+    ));
+  }
+
+  setExpansion = value => {
+    this.setState({ isExpanded: value });
+  };
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  setColumnRef = c => {
+    this.column = c;
+  };
+
+  componentDidUpdate (prevProps) {
+    if (this.props.params.statusId && (this.props.params.statusId !== prevProps.params.statusId || prevProps.ancestorsIds.size < this.props.ancestorsIds.size)) {
+      const { status, ancestorsIds } = this.props;
+
+      if (status && ancestorsIds && ancestorsIds.size > 0) {
+        const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+        window.requestAnimationFrame(() => {
+          element.scrollIntoView(true);
+        });
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    detachFullscreenListener(this.onFullScreenChange);
+  }
+
+  onFullScreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  };
+
+  render () {
+    let ancestors, descendants;
+    const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
+    const { fullscreen } = this.state;
+
+    if (isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    if (status === null) {
+      return (
+        <Column>
+          <ColumnBackButton multiColumn={multiColumn} />
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
+
+    if (ancestorsIds && ancestorsIds.size > 0) {
+      ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
+    }
+
+    if (descendantsIds && descendantsIds.size > 0) {
+      descendants = <div>{this.renderChildren(descendantsIds)}</div>;
+    }
+
+    const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
+    const isIndexable = !status.getIn(['account', 'noindex']);
+
+    const handlers = {
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      bookmark: this.handleHotkeyBookmark,
+      mention: this.handleHotkeyMention,
+      openProfile: this.handleHotkeyOpenProfile,
+      toggleSpoiler: this.handleToggleHidden,
+      toggleSensitive: this.handleHotkeyToggleSensitive,
+      openMedia: this.handleHotkeyOpenMedia,
+    };
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}>
+        <ColumnHeader
+          icon='comment'
+          title={intl.formatMessage(messages.tootHeading)}
+          onClick={this.handleHeaderClick}
+          showBackButton
+          multiColumn={multiColumn}
+          extraButton={(
+            <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button>
+          )}
+        />
+
+        <ScrollContainer scrollKey='thread'>
+          <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
+            {ancestors}
+
+            <HotKeys handlers={handlers}>
+              <div className='focusable' tabIndex='0' aria-label={textForScreenReader(intl, status, false, isExpanded)}>
+                <DetailedStatus
+                  key={`details-${status.get('id')}`}
+                  status={status}
+                  settings={settings}
+                  onOpenVideo={this.handleOpenVideo}
+                  onOpenMedia={this.handleOpenMedia}
+                  expanded={isExpanded}
+                  onToggleHidden={this.handleToggleHidden}
+                  onTranslate={this.handleTranslate}
+                  domain={domain}
+                  showMedia={this.state.showMedia}
+                  onToggleMediaVisibility={this.handleToggleMediaVisibility}
+                  pictureInPicture={pictureInPicture}
+                />
+
+                <ActionBar
+                  key={`action-bar-${status.get('id')}`}
+                  status={status}
+                  onReply={this.handleReplyClick}
+                  onFavourite={this.handleFavouriteClick}
+                  onReblog={this.handleReblogClick}
+                  onBookmark={this.handleBookmarkClick}
+                  onDelete={this.handleDeleteClick}
+                  onEdit={this.handleEditClick}
+                  onDirect={this.handleDirectClick}
+                  onMention={this.handleMentionClick}
+                  onMute={this.handleMuteClick}
+                  onMuteConversation={this.handleConversationMuteClick}
+                  onBlock={this.handleBlockClick}
+                  onReport={this.handleReport}
+                  onPin={this.handlePin}
+                  onEmbed={this.handleEmbed}
+                />
+              </div>
+            </HotKeys>
+
+            {descendants}
+          </div>
+        </ScrollContainer>
+
+        <Helmet>
+          <title>{titleFromStatus(status)}</title>
+          <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default injectIntl(connect(makeMapStateToProps)(Status));
diff --git a/app/javascript/flavours/glitch/features/subscribed_languages_modal/index.jsx b/app/javascript/flavours/glitch/features/subscribed_languages_modal/index.jsx
new file mode 100644
index 000000000..85144a191
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/subscribed_languages_modal/index.jsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable';
+import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
+import Option from 'flavours/glitch/features/report/components/option';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
+import { followAccount } from 'flavours/glitch/actions/accounts';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+const getAccountLanguages = createSelector([
+  (state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
+  state => state.get('statuses'),
+], (statusIds, statuses) =>
+  new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
+
+const mapStateToProps = (state, { accountId }) => ({
+  acct: state.getIn(['accounts', accountId, 'acct']),
+  availableLanguages: getAccountLanguages(state, accountId),
+  selectedLanguages: ImmutableSet(state.getIn(['relationships', accountId, 'languages']) || ImmutableList()),
+});
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+
+  onSubmit (languages) {
+    dispatch(followAccount(accountId, { languages }));
+  },
+
+});
+
+class SubscribedLanguagesModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    acct: PropTypes.string.isRequired,
+    availableLanguages: ImmutablePropTypes.setOf(PropTypes.string),
+    selectedLanguages: ImmutablePropTypes.setOf(PropTypes.string),
+    onClose: PropTypes.func.isRequired,
+    languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+    intl: PropTypes.object.isRequired,
+    submit: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    languages: preloadedLanguages,
+  };
+
+  state = {
+    selectedLanguages: this.props.selectedLanguages,
+  };
+
+  handleLanguageToggle = (value, checked) => {
+    const { selectedLanguages } = this.state;
+
+    if (checked) {
+      this.setState({ selectedLanguages: selectedLanguages.add(value) });
+    } else {
+      this.setState({ selectedLanguages: selectedLanguages.delete(value) });
+    }
+  };
+
+  handleSubmit = () => {
+    this.props.onSubmit(this.state.selectedLanguages.toArray());
+    this.props.onClose();
+  };
+
+  renderItem (value) {
+    const language = this.props.languages.find(language => language[0] === value);
+    const checked = this.state.selectedLanguages.includes(value);
+
+    if (!language) {
+      return null;
+    }
+
+    return (
+      <Option
+        key={value}
+        name='languages'
+        value={value}
+        label={language[1]}
+        checked={checked}
+        onToggle={this.handleLanguageToggle}
+        multiple
+      />
+    );
+  }
+
+  render () {
+    const { acct, availableLanguages, selectedLanguages, intl, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal report-dialog-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
+          <FormattedMessage id='subscribed_languages.target' defaultMessage='Change subscribed languages for {target}' values={{ target: <strong>{acct}</strong> }} />
+        </div>
+
+        <div className='report-dialog-modal__container'>
+          <p className='report-dialog-modal__lead'><FormattedMessage id='subscribed_languages.lead' defaultMessage='Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.' /></p>
+
+          <div>
+            {availableLanguages.union(selectedLanguages).delete(null).map(value => this.renderItem(value))}
+          </div>
+
+          <div className='flex-spacer' />
+
+          <div className='report-dialog-modal__actions'>
+            <Button disabled={is(this.state.selectedLanguages, this.props.selectedLanguages)} onClick={this.handleSubmit}><FormattedMessage id='subscribed_languages.save' defaultMessage='Save changes' /></Button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SubscribedLanguagesModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx
new file mode 100644
index 000000000..c6e3ee37c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx
@@ -0,0 +1,91 @@
+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 IconButton from 'flavours/glitch/components/icon_button';
+
+export default class ActionsModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    onClick: PropTypes.func,
+    actions: PropTypes.arrayOf(PropTypes.shape({
+      active: PropTypes.bool,
+      href: PropTypes.string,
+      icon: PropTypes.string,
+      meta: PropTypes.string,
+      name: PropTypes.string,
+      text: PropTypes.string,
+    })),
+    renderItemContents: PropTypes.func,
+  };
+
+  renderAction = (action, i) => {
+    if (action === null) {
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
+    }
+
+    const { icon = null, text, meta = null, active = false, href = '#' } = action;
+    let contents = this.props.renderItemContents && this.props.renderItemContents(action, i);
+
+    if (!contents) {
+      contents = (
+        <React.Fragment>
+          {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />}
+          <div>
+            <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
+            <div>{meta}</div>
+          </div>
+        </React.Fragment>
+      );
+    }
+
+    return (
+      <li key={`${text}-${i}`}>
+        <a href={href} target='_blank' rel='noopener noreferrer' onClick={this.props.onClick} data-index={i} className={classNames('link', { active })}>
+          {contents}
+        </a>
+      </li>
+    );
+  };
+
+  render () {
+    const status = this.props.status && (
+      <div className='status light'>
+        <div className='boost-modal__status-header'>
+          <div className='boost-modal__status-time'>
+            <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
+              <RelativeTimestamp timestamp={this.props.status.get('created_at')} />
+            </a>
+          </div>
+
+          <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name' rel='noopener noreferrer'>
+            <div className='status__avatar'>
+              <Avatar account={this.props.status.get('account')} size={48} />
+            </div>
+
+            <DisplayName account={this.props.status.get('account')} />
+          </a>
+        </div>
+
+        <StatusContent status={this.props.status} />
+      </div>
+    );
+
+    return (
+      <div className='modal-root__modal actions-modal'>
+        {status}
+
+        <ul className={classNames({ 'with-status': !!status })}>
+          {this.props.actions.map(this.renderAction)}
+        </ul>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/audio_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/audio_modal.jsx
new file mode 100644
index 000000000..0aeabd94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/audio_modal.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Audio from 'flavours/glitch/features/audio';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
+
+const mapStateToProps = (state, { statusId }) => ({
+  language: state.getIn(['statuses', statusId, 'language']),
+  accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
+});
+
+class AudioModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    statusId: PropTypes.string.isRequired,
+    language: PropTypes.string,
+    accountStaticAvatar: PropTypes.string.isRequired,
+    options: PropTypes.shape({
+      autoPlay: PropTypes.bool,
+    }),
+    onClose: PropTypes.func.isRequired,
+    onChangeBackgroundColor: PropTypes.func.isRequired,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  render () {
+    const { media, language, accountStaticAvatar, statusId, onClose } = this.props;
+    const options = this.props.options || {};
+
+    return (
+      <div className='modal-root__modal audio-modal'>
+        <div className='audio-modal__container'>
+          <Audio
+            src={media.get('url')}
+            alt={media.get('description')}
+            lang={language}
+            duration={media.getIn(['meta', 'original', 'duration'], 0)}
+            height={150}
+            poster={media.get('preview_url') || accountStaticAvatar}
+            backgroundColor={media.getIn(['meta', 'colors', 'background'])}
+            foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
+            accentColor={media.getIn(['meta', 'colors', 'accent'])}
+            autoPlay={options.autoPlay}
+          />
+        </div>
+
+        <div className='media-modal__overlay'>
+          {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, null, null, { forwardRef: true })(AudioModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/block_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/block_modal.jsx
new file mode 100644
index 000000000..a9506aa69
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/block_modal.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from '../../../selectors';
+import Button from '../../../components/button';
+import { closeModal } from '../../../actions/modal';
+import { blockAccount } from '../../../actions/accounts';
+import { initReport } from '../../../actions/reports';
+
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = state => ({
+    account: getAccount(state, state.getIn(['blocks', 'new', 'account_id'])),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => {
+  return {
+    onConfirm(account) {
+      dispatch(blockAccount(account.get('id')));
+    },
+
+    onBlockAndReport(account) {
+      dispatch(blockAccount(account.get('id')));
+      dispatch(initReport(account));
+    },
+
+    onClose() {
+      dispatch(closeModal());
+    },
+  };
+};
+
+class BlockModal extends React.PureComponent {
+
+  static propTypes = {
+    account: PropTypes.object.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onBlockAndReport: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm(this.props.account);
+  };
+
+  handleSecondary = () => {
+    this.props.onClose();
+    this.props.onBlockAndReport(this.props.account);
+  };
+
+  handleCancel = () => {
+    this.props.onClose();
+  };
+
+  setRef = (c) => {
+    this.button = c;
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='modal-root__modal block-modal'>
+        <div className='block-modal__container'>
+          <p>
+            <FormattedMessage
+              id='confirmations.block.message'
+              defaultMessage='Are you sure you want to block {name}?'
+              values={{ name: <strong>@{account.get('acct')}</strong> }}
+            />
+          </p>
+        </div>
+
+        <div className='block-modal__action-bar'>
+          <Button onClick={this.handleCancel} className='block-modal__cancel-button'>
+            <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+          </Button>
+          <Button onClick={this.handleSecondary} className='confirmation-modal__secondary-button'>
+            <FormattedMessage id='confirmations.block.block_and_report' defaultMessage='Block & Report' />
+          </Button>
+          <Button onClick={this.handleClick} ref={this.setRef}>
+            <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(BlockModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/boost_modal.jsx
new file mode 100644
index 000000000..d9523a26e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.jsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+import StatusContent from 'flavours/glitch/components/status_content';
+import Avatar from 'flavours/glitch/components/avatar';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import DisplayName from 'flavours/glitch/components/display_name';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import Icon from 'flavours/glitch/components/icon';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown';
+import classNames from 'classnames';
+import { changeBoostPrivacy } from 'flavours/glitch/actions/boosts';
+import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+
+const messages = defineMessages({
+  cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+});
+
+const mapStateToProps = state => {
+  return {
+    privacy: state.getIn(['boosts', 'new', 'privacy']),
+  };
+};
+
+const mapDispatchToProps = dispatch => {
+  return {
+    onChangeBoostPrivacy(value) {
+      dispatch(changeBoostPrivacy(value));
+    },
+  };
+};
+
+class BoostModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReblog: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    missingMediaDescription: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleReblog = () => {
+    this.props.onReblog(this.props.status, this.props.privacy);
+    this.props.onClose();
+  };
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      let state = { ...this.context.router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
+    }
+  };
+
+  _findContainer = () => {
+    return document.getElementsByClassName('modal-root__container')[0];
+  };
+
+  setRef = (c) => {
+    this.button = c;
+  };
+
+  render () {
+    const { status, missingMediaDescription, privacy, intl } = this.props;
+    const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
+
+    return (
+      <div className='modal-root__modal boost-modal'>
+        <div className='boost-modal__container'>
+          <div className={classNames('status', `status-${status.get('visibility')}`, 'light')}>
+            <div className='boost-modal__status-header'>
+              <div className='boost-modal__status-time'>
+                <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
+                  <VisibilityIcon visibility={status.get('visibility')} />
+                  <RelativeTimestamp timestamp={status.get('created_at')} /></a>
+              </div>
+
+              <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  <Avatar account={status.get('account')} size={48} />
+                </div>
+
+                <DisplayName account={status.get('account')} />
+              </a>
+            </div>
+
+            <StatusContent status={status} />
+
+            {status.get('media_attachments').size > 0 && (
+              <AttachmentList
+                compact
+                media={status.get('media_attachments')}
+              />
+            )}
+          </div>
+        </div>
+
+        <div className='boost-modal__action-bar'>
+          <div>
+            { missingMediaDescription ?
+              <FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' />
+              :
+              <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} />
+            }
+          </div>
+
+          {status.get('visibility') !== 'private' && !status.get('reblogged') && (
+            <PrivacyDropdown
+              noDirect
+              value={privacy}
+              container={this._findContainer}
+              onChange={this.props.onChangeBoostPrivacy}
+            />
+          )}
+          <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(BoostModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle.jsx b/app/javascript/flavours/glitch/features/ui/components/bundle.jsx
new file mode 100644
index 000000000..27b13ecfe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const emptyComponent = () => null;
+const noop = () => { };
+
+class Bundle extends React.Component {
+
+  static propTypes = {
+    fetchComponent: PropTypes.func.isRequired,
+    loading: PropTypes.func,
+    error: PropTypes.func,
+    children: PropTypes.func.isRequired,
+    renderDelay: PropTypes.number,
+    onFetch: PropTypes.func,
+    onFetchSuccess: PropTypes.func,
+    onFetchFail: PropTypes.func,
+  };
+
+  static defaultProps = {
+    loading: emptyComponent,
+    error: emptyComponent,
+    renderDelay: 0,
+    onFetch: noop,
+    onFetchSuccess: noop,
+    onFetchFail: noop,
+  };
+
+  static cache = {};
+
+  state = {
+    mod: undefined,
+    forceRender: false,
+  };
+
+  componentWillMount() {
+    this.load(this.props);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.fetchComponent !== this.props.fetchComponent) {
+      this.load(nextProps);
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.timeout) {
+      clearTimeout(this.timeout);
+    }
+  }
+
+  load = (props) => {
+    const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
+
+    if (fetchComponent === undefined) {
+      this.setState({ mod: null });
+      return Promise.resolve();
+    }
+
+    onFetch();
+
+    if (Bundle.cache[fetchComponent.name]) {
+      const mod = Bundle.cache[fetchComponent.name];
+
+      this.setState({ mod: mod.default });
+      onFetchSuccess();
+      return Promise.resolve();
+    }
+
+    this.setState({ mod: undefined });
+
+    if (renderDelay !== 0) {
+      this.timestamp = new Date();
+      this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
+    }
+
+    return fetchComponent()
+      .then((mod) => {
+        Bundle.cache[fetchComponent.name] = mod;
+        this.setState({ mod: mod.default });
+        onFetchSuccess();
+      })
+      .catch((error) => {
+        this.setState({ mod: null });
+        onFetchFail(error);
+      });
+  };
+
+  render() {
+    const { loading: Loading, error: Error, children, renderDelay } = this.props;
+    const { mod, forceRender } = this.state;
+    const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
+
+    if (mod === undefined) {
+      return (elapsed >= renderDelay || forceRender) ? <Loading /> : null;
+    }
+
+    if (mod === null) {
+      return <Error onRetry={this.load} />;
+    }
+
+    return children(mod);
+  }
+
+}
+
+export default Bundle;
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx
new file mode 100644
index 000000000..eaabbc460
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx
@@ -0,0 +1,163 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Column from 'flavours/glitch/components/column';
+import Button from 'flavours/glitch/components/button';
+import { Helmet } from 'react-helmet';
+import { Link } from 'react-router-dom';
+import classNames from 'classnames';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+
+class GIF extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    staticSrc: PropTypes.string.isRequired,
+    className: PropTypes.string,
+    animate: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    animate: autoPlayGif,
+  };
+
+  state = {
+    hovering: false,
+  };
+
+  handleMouseEnter = () => {
+    const { animate } = this.props;
+
+    if (!animate) {
+      this.setState({ hovering: true });
+    }
+  };
+
+  handleMouseLeave = () => {
+    const { animate } = this.props;
+
+    if (!animate) {
+      this.setState({ hovering: false });
+    }
+  };
+
+  render () {
+    const { src, staticSrc, className, animate } = this.props;
+    const { hovering } = this.state;
+
+    return (
+      <img
+        className={className}
+        src={(hovering || animate) ? src : staticSrc}
+        alt=''
+        role='presentation'
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+      />
+    );
+  }
+
+}
+
+class CopyButton extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node.isRequired,
+    value: PropTypes.string.isRequired,
+  };
+
+  state = {
+    copied: false,
+  };
+
+  handleClick = () => {
+    const { value } = this.props;
+    navigator.clipboard.writeText(value);
+    this.setState({ copied: true });
+    this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
+  };
+
+  componentWillUnmount () {
+    if (this.timeout) clearTimeout(this.timeout);
+  }
+
+  render () {
+    const { children } = this.props;
+    const { copied } = this.state;
+
+    return (
+      <Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button>
+    );
+  }
+
+}
+
+class BundleColumnError extends React.PureComponent {
+
+  static propTypes = {
+    errorType: PropTypes.oneOf(['routing', 'network', 'error']),
+    onRetry: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    stacktrace: PropTypes.string,
+  };
+
+  static defaultProps = {
+    errorType: 'routing',
+  };
+
+  handleRetry = () => {
+    const { onRetry } = this.props;
+
+    if (onRetry) {
+      onRetry();
+    }
+  };
+
+  render () {
+    const { errorType, multiColumn, stacktrace } = this.props;
+
+    let title, body;
+
+    switch(errorType) {
+    case 'routing':
+      title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />;
+      body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />;
+      break;
+    case 'network':
+      title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />;
+      body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />;
+      break;
+    case 'error':
+      title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />;
+      body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />;
+      break;
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn}>
+        <div className='error-column'>
+          <GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' />
+
+          <div className='error-column__message'>
+            <h1>{title}</h1>
+            <p>{body}</p>
+
+            <div className='error-column__message__actions'>
+              {errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>}
+              {errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>}
+              <Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link>
+            </div>
+          </div>
+        </div>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
+
+export default injectIntl(BundleColumnError);
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.jsx b/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.jsx
new file mode 100644
index 000000000..b79105450
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.jsx
@@ -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.jsx b/app/javascript/flavours/glitch/features/ui/components/column.jsx
new file mode 100644
index 000000000..cc2abc43a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+import { debounce } from 'lodash';
+import { scrollTop } from 'flavours/glitch/scroll';
+import { isMobile } from 'flavours/glitch/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,
+    bindToDocument: PropTypes.bool,
+  };
+
+  handleHeaderClick = () => {
+    const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
+
+    if (!scrollable) {
+      return;
+    }
+
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  };
+
+  scrollTop () {
+    const scrollable = this.props.bindToDocument ? document.scrollingElement : 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.jsx b/app/javascript/flavours/glitch/features/ui/components/column_header.jsx
new file mode 100644
index 000000000..151476f8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_header.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class ColumnHeader extends React.PureComponent {
+
+  static propTypes = {
+    icon: PropTypes.string,
+    type: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func,
+    columnHeaderId: PropTypes.string,
+  };
+
+  handleClick = () => {
+    this.props.onClick();
+  };
+
+  render () {
+    const { icon, type, active, columnHeaderId } = this.props;
+    let iconElement = '';
+
+    if (icon) {
+      iconElement = <Icon id={icon} fixedWidth className='column-header__icon' />;
+    }
+
+    return (
+      <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
+        <button onClick={this.handleClick}>
+          {iconElement}
+          {type}
+        </button>
+      </h1>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.jsx b/app/javascript/flavours/glitch/features/ui/components/column_link.jsx
new file mode 100644
index 000000000..dcdac077f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_link.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { NavLink } from 'react-router-dom';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+
+const ColumnLink = ({ icon, text, to, onClick, href, method, badge, transparent, ...other }) => {
+  const className = classNames('column-link', { 'column-link--transparent': transparent });
+  const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
+  const iconElement = typeof icon === 'string' ? <Icon id={icon} fixedWidth className='column-link__icon' /> : icon;
+
+  if (href) {
+    return (
+      <a href={href} className={className} data-method={method} title={text} {...other}>
+        {iconElement}
+        <span>{text}</span>
+        {badgeElement}
+      </a>
+    );
+  } else if (to) {
+    return (
+      <NavLink to={to} className={className} title={text} {...other}>
+        {iconElement}
+        <span>{text}</span>
+        {badgeElement}
+      </NavLink>
+    );
+  } else {
+    const handleOnClick = (e) => {
+      e.preventDefault();
+      e.stopPropagation();
+      return onClick(e);
+    };
+    return (
+      <a href='#' onClick={onClick && handleOnClick} className={className} title={text} {...other} tabIndex='0'>
+        {iconElement}
+        <span>{text}</span>
+        {badgeElement}
+      </a>
+    );
+  }
+};
+
+ColumnLink.propTypes = {
+  icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+  text: PropTypes.string.isRequired,
+  to: PropTypes.string,
+  onClick: PropTypes.func,
+  href: PropTypes.string,
+  method: PropTypes.string,
+  badge: PropTypes.node,
+  transparent: PropTypes.bool,
+};
+
+export default ColumnLink;
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_loading.jsx b/app/javascript/flavours/glitch/features/ui/components/column_loading.jsx
new file mode 100644
index 000000000..b07385397
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_loading.jsx
@@ -0,0 +1,32 @@
+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,
+    multiColumn: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    title: '',
+    icon: '',
+  };
+
+  render() {
+    let { title, icon, multiColumn } = this.props;
+
+    return (
+      <Column>
+        <ColumnHeader icon={icon} title={title} multiColumn={multiColumn} focusable={false} placeholder />
+        <div className='scrollable' />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_subheading.jsx b/app/javascript/flavours/glitch/features/ui/components/column_subheading.jsx
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_subheading.jsx
@@ -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.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
new file mode 100644
index 000000000..3b3b0d58f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
@@ -0,0 +1,183 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import BundleContainer from '../containers/bundle_container';
+import ColumnLoading from './column_loading';
+import DrawerLoading from './drawer_loading';
+import BundleColumnError from './bundle_column_error';
+import {
+  Compose,
+  Notifications,
+  HomeTimeline,
+  CommunityTimeline,
+  PublicTimeline,
+  HashtagTimeline,
+  DirectTimeline,
+  FavouritedStatuses,
+  BookmarkedStatuses,
+  ListTimeline,
+  Directory,
+} from '../../ui/util/async-components';
+import ComposePanel from './compose_panel';
+import NavigationPanel from './navigation_panel';
+
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { scrollRight } from 'flavours/glitch/scroll';
+
+const componentMap = {
+  'COMPOSE': Compose,
+  'HOME': HomeTimeline,
+  'NOTIFICATIONS': Notifications,
+  'PUBLIC': PublicTimeline,
+  'REMOTE': PublicTimeline,
+  'COMMUNITY': CommunityTimeline,
+  'HASHTAG': HashtagTimeline,
+  'DIRECT': DirectTimeline,
+  'FAVOURITES': FavouritedStatuses,
+  'BOOKMARKS': BookmarkedStatuses,
+  'LIST': ListTimeline,
+  'DIRECTORY': Directory,
+};
+
+export default class ColumnsArea extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    columns: ImmutablePropTypes.list.isRequired,
+    singleColumn: PropTypes.bool,
+    children: PropTypes.node,
+    navbarUnder: PropTypes.bool,
+    openSettings: PropTypes.func,
+  };
+
+  // Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS
+  mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
+
+  state = {
+    renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
+  };
+
+  componentDidMount() {
+    if (!this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
+    }
+
+    if (this.mediaQuery) {
+      if (this.mediaQuery.addEventListener) {
+        this.mediaQuery.addEventListener('change', this.handleLayoutChange);
+      } else {
+        this.mediaQuery.addListener(this.handleLayoutChange);
+      }
+      this.setState({ renderComposePanel: !this.mediaQuery.matches });
+    }
+
+    this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
+  }
+
+  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, supportsPassiveEvents ? { passive: true } : false);
+    }
+  }
+
+  componentWillUnmount () {
+    if (!this.props.singleColumn) {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+
+    if (this.mediaQuery) {
+      if (this.mediaQuery.removeEventListener) {
+        this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
+      } else {
+        this.mediaQuery.removeListener(this.handleLayoutChange);
+      }
+    }
+  }
+
+  handleChildrenContentChange() {
+    if (!this.props.singleColumn) {
+      const modifier = this.isRtlLayout ? -1 : 1;
+      this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
+    }
+  }
+
+  handleLayoutChange = (e) => {
+    this.setState({ renderComposePanel: !e.matches });
+  };
+
+  handleWheel = () => {
+    if (typeof this._interruptScrollAnimation !== 'function') {
+      return;
+    }
+
+    this._interruptScrollAnimation();
+  };
+
+  setRef = (node) => {
+    this.node = node;
+  };
+
+  renderLoading = columnId => () => {
+    return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading multiColumn />;
+  };
+
+  renderError = (props) => {
+    return <BundleColumnError multiColumn errorType='network' {...props} />;
+  };
+
+  render () {
+    const { columns, children, singleColumn, navbarUnder, openSettings } = this.props;
+    const { renderComposePanel } = this.state;
+
+    if (singleColumn) {
+      return (
+        <div className='columns-area__panels'>
+          <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
+            <div className='columns-area__panels__pane__inner'>
+              {renderComposePanel && <ComposePanel />}
+            </div>
+          </div>
+
+          <div className='columns-area__panels__main'>
+            <div className='tabs-bar__wrapper'><div id='tabs-bar__portal' /></div>
+            <div className='columns-area columns-area--mobile'>{children}</div>
+          </div>
+
+          <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
+            <div className='columns-area__panels__pane__inner'>
+              <NavigationPanel onOpenSettings={openSettings} />
+            </div>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div className='columns-area' ref={this.setRef}>
+        {columns.map(column => {
+          const params = column.get('params', null) === null ? null : column.get('params').toJS();
+          const other  = params && params.other ? params.other : {};
+
+          return (
+            <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
+              {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
+            </BundleContainer>
+          );
+        })}
+
+        {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx
new file mode 100644
index 000000000..cc3a16d17
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { closeModal } from 'flavours/glitch/actions/modal';
+import emojify from 'flavours/glitch/features/emoji/emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import InlineAccount from 'flavours/glitch/components/inline_account';
+import IconButton from 'flavours/glitch/components/icon_button';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import MediaAttachments from 'flavours/glitch/components/media_attachments';
+
+const mapStateToProps = (state, { statusId }) => ({
+  language: state.getIn(['statuses', statusId, 'language']),
+  versions: state.getIn(['history', statusId, 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClose() {
+    dispatch(closeModal());
+  },
+
+});
+
+class CompareHistoryModal extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    index: PropTypes.number.isRequired,
+    statusId: PropTypes.string.isRequired,
+    language: PropTypes.string.isRequired,
+    versions: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { index, versions, language, onClose } = this.props;
+    const currentVersion = versions.get(index);
+
+    const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => {
+      obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+      return obj;
+    }, {});
+
+    const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
+    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
+
+    const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
+    const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
+
+    const label = currentVersion.get('original') ? (
+      <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
+    ) : (
+      <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
+    );
+
+    return (
+      <div className='modal-root__modal compare-history-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' icon='times' onClick={onClose} size={20} />
+          {label}
+        </div>
+
+        <div className='compare-history-modal__container'>
+          <div className='status__content'>
+            {currentVersion.get('spoiler_text').length > 0 && (
+              <React.Fragment>
+                <div className='translate' dangerouslySetInnerHTML={spoilerContent} lang={language} />
+                <hr />
+              </React.Fragment>
+            )}
+
+            <div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} lang={language} />
+
+            {!!currentVersion.get('poll') && (
+              <div className='poll'>
+                <ul>
+                  {currentVersion.getIn(['poll', 'options']).map(option => (
+                    <li key={option.get('title')}>
+                      <span className='poll__input disabled' />
+
+                      <span
+                        className='poll__option__text translate'
+                        dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }}
+                        lang={language}
+                      />
+                    </li>
+                  ))}
+                </ul>
+              </div>
+            )}
+
+            <MediaAttachments status={currentVersion} lang={language} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(CompareHistoryModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/compose_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/compose_panel.jsx
new file mode 100644
index 000000000..1dedf92ca
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
+import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container';
+import LinkFooter from './link_footer';
+import ServerBanner from 'flavours/glitch/components/server_banner';
+import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
+
+class ComposePanel extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(mountCompose());
+  }
+
+  componentWillUnmount () {
+    const { dispatch } = this.props;
+    dispatch(unmountCompose());
+  }
+
+  render() {
+    const { signedIn } = this.context.identity;
+
+    return (
+      <div className='compose-panel'>
+        <SearchContainer openInRoute />
+
+        {!signedIn && (
+          <React.Fragment>
+            <ServerBanner />
+            <div className='flex-spacer' />
+          </React.Fragment>
+        )}
+
+        {signedIn && (
+          <React.Fragment>
+            <NavigationContainer />
+            <ComposeFormContainer singleColumn />
+          </React.Fragment>
+        )}
+
+        <LinkFooter />
+      </div>
+    );
+  }
+
+}
+
+export default connect()(ComposePanel);
diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.jsx
new file mode 100644
index 000000000..08f55c125
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+
+class ConfirmationModal extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+    confirm: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    secondary: PropTypes.string,
+    onSecondary: PropTypes.func,
+    closeWhenConfirm: PropTypes.bool,
+    onDoNotAsk: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  static defaultProps = {
+    closeWhenConfirm: true,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    if (this.props.closeWhenConfirm) {
+      this.props.onClose();
+    }
+    this.props.onConfirm();
+    if (this.props.onDoNotAsk && this.doNotAskCheckbox.checked) {
+      this.props.onDoNotAsk();
+    }
+  };
+
+  handleSecondary = () => {
+    this.props.onClose();
+    this.props.onSecondary();
+  };
+
+  handleCancel = () => {
+    this.props.onClose();
+  };
+
+  setRef = (c) => {
+    this.button = c;
+  };
+
+  setDoNotAskRef = (c) => {
+    this.doNotAskCheckbox = c;
+  };
+
+  render () {
+    const { message, confirm, secondary, onDoNotAsk } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+          {message}
+        </div>
+
+        <div>
+          { onDoNotAsk && (
+            <div className='confirmation-modal__do_not_ask_again'>
+              <input type='checkbox' id='confirmation-modal__do_not_ask_again-checkbox' ref={this.setDoNotAskRef} />
+              <label for='confirmation-modal__do_not_ask_again-checkbox'>
+                <FormattedMessage id='confirmation_modal.do_not_ask_again' defaultMessage='Do not ask for confirmation again' />
+              </label>
+            </div>
+          )}
+          <div className='confirmation-modal__action-bar'>
+            <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'>
+              <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+            </Button>
+            {secondary !== undefined && (
+              <Button text={secondary} onClick={this.handleSecondary} className='confirmation-modal__secondary-button' />
+            )}
+            <Button text={confirm} onClick={this.handleClick} ref={this.setRef} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ConfirmationModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx
new file mode 100644
index 000000000..5a1c1ee1b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { preferenceLink } from 'flavours/glitch/utils/backend_links';
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+import illustration from 'flavours/glitch/images/logo_warn_glitch.svg';
+
+const messages = defineMessages({
+  discardChanges: { id: 'confirmations.deprecated_settings.confirm', defaultMessage: 'Use Mastodon preferences' },
+  user_setting_expand_spoilers: { id: 'settings.enable_content_warnings_auto_unfold', defaultMessage: 'Automatically unfold content-warnings' },
+  user_setting_disable_swiping: { id: 'settings.swipe_to_change_columns', defaultMessage: 'Allow swiping to change columns (Mobile only)' },
+});
+
+class DeprecatedSettingsModal extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.list.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onConfirm();
+    this.props.onClose();
+  };
+
+  setRef = (c) => {
+    this.button = c;
+  };
+
+  render () {
+    const { settings, intl } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+
+          <img src={illustration} className='modal-warning' alt='' />
+
+          <FormattedMessage
+            id='confirmations.deprecated_settings.message'
+            defaultMessage='Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:'
+            values={{
+              app_settings: (
+                <strong className='deprecated-settings-label'>
+                  <Icon id='cogs' /> <FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' />
+                </strong>
+              ),
+              preferences: (
+                <strong className='deprecated-settings-label'>
+                  <Icon id='cog' /> <FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' />
+                </strong>
+              ),
+            }}
+          />
+
+          <div className='deprecated-settings-info'>
+            <ul>
+              { settings.map((setting_name) => (
+                <li>
+                  <a href={preferenceLink(setting_name)}><FormattedMessage {...messages[setting_name]} /></a>
+                </li>
+              )) }
+            </ul>
+          </div>
+        </div>
+
+        <div>
+          <div className='confirmation-modal__action-bar'>
+            <div />
+            <Button text={intl.formatMessage(messages.discardChanges)} onClick={this.handleClick} ref={this.setRef} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(DeprecatedSettingsModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.jsx b/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.jsx
new file mode 100644
index 000000000..0ba79d648
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { disabledAccountId, movedToAccountId, domain } from 'flavours/glitch/initial_state';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { logOut } from 'flavours/glitch/utils/log_out';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = (state) => ({
+  disabledAcct: state.getIn(['accounts', disabledAccountId, 'acct']),
+  movedToAcct: movedToAccountId ? state.getIn(['accounts', movedToAccountId, 'acct']) : undefined,
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+class DisabledAccountBanner extends React.PureComponent {
+
+  static propTypes = {
+    disabledAcct: PropTypes.string.isRequired,
+    movedToAcct: PropTypes.string,
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleLogOutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  };
+
+  render () {
+    const { disabledAcct, movedToAcct } = this.props;
+
+    const disabledAccountLink = (
+      <Link to={`/@${disabledAcct}`}>
+        {disabledAcct}@{domain}
+      </Link>
+    );
+
+    return (
+      <div className='sign-in-banner'>
+        <p>
+          {movedToAcct ? (
+            <FormattedMessage
+              id='moved_to_account_banner.text'
+              defaultMessage='Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.'
+              values={{
+                disabledAccount: disabledAccountLink,
+                movedToAccount: <Link to={`/@${movedToAcct}`}>{movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${domain}`}</Link>,
+              }}
+            />
+          ) : (
+            <FormattedMessage
+              id='disabled_account_banner.text'
+              defaultMessage='Your account {disabledAccount} is currently disabled.'
+              values={{
+                disabledAccount: disabledAccountLink,
+              }}
+            />
+          )}
+        </p>
+        <a href='/auth/edit' className='button button--block'>
+          <FormattedMessage id='disabled_account_banner.account_settings' defaultMessage='Account settings' />
+        </a>
+        <button type='button' className='button button--block button-tertiary' onClick={this.handleLogOutClick}>
+          <FormattedMessage id='confirmations.logout.confirm' defaultMessage='Log out' />
+        </button>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(DisabledAccountBanner));
diff --git a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx
new file mode 100644
index 000000000..162957ad8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx
@@ -0,0 +1,615 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Button from 'flavours/glitch/components/button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Atrament from 'atrament'; // the doodling library
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { doodleSet, uploadCompose } from 'flavours/glitch/actions/compose';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { debounce, mapValues } from 'lodash';
+import classNames from 'classnames';
+
+// palette nicked from MyPaint, CC0
+const palette = [
+  ['rgb(  0,    0,    0)', 'Black'],
+  ['rgb( 38,   38,   38)', 'Gray 15'],
+  ['rgb( 77,   77,   77)', 'Grey 30'],
+  ['rgb(128,  128,  128)', 'Grey 50'],
+  ['rgb(171,  171,  171)', 'Grey 67'],
+  ['rgb(217,  217,  217)', 'Grey 85'],
+  ['rgb(255,  255,  255)', 'White'],
+  ['rgb(128,    0,    0)', 'Maroon'],
+  ['rgb(209,    0,    0)', 'English-red'],
+  ['rgb(255,   54,   34)', 'Tomato'],
+  ['rgb(252,   60,    3)', 'Orange-red'],
+  ['rgb(255,  140,  105)', 'Salmon'],
+  ['rgb(252,  232,   32)', 'Cadium-yellow'],
+  ['rgb(243,  253,   37)', 'Lemon yellow'],
+  ['rgb(121,    5,   35)', 'Dark crimson'],
+  ['rgb(169,   32,   62)', 'Deep carmine'],
+  ['rgb(255,  140,    0)', 'Orange'],
+  ['rgb(255,  168,   18)', 'Dark tangerine'],
+  ['rgb(217,  144,   88)', 'Persian orange'],
+  ['rgb(194,  178,  128)', 'Sand'],
+  ['rgb(255,  229,  180)', 'Peach'],
+  ['rgb(100,   54,   46)', 'Bole'],
+  ['rgb(108,   41,   52)', 'Dark cordovan'],
+  ['rgb(163,   65,   44)', 'Chestnut'],
+  ['rgb(228,  136,  100)', 'Dark salmon'],
+  ['rgb(255,  195,  143)', 'Apricot'],
+  ['rgb(255,  219,  188)', 'Unbleached silk'],
+  ['rgb(242,  227,  198)', 'Straw'],
+  ['rgb( 53,   19,   13)', 'Bistre'],
+  ['rgb( 84,   42,   14)', 'Dark chocolate'],
+  ['rgb(102,   51,   43)', 'Burnt sienna'],
+  ['rgb(184,   66,    0)', 'Sienna'],
+  ['rgb(216,  153,   12)', 'Yellow ochre'],
+  ['rgb(210,  180,  140)', 'Tan'],
+  ['rgb(232,  204,  144)', 'Dark wheat'],
+  ['rgb(  0,   49,   83)', 'Prussian blue'],
+  ['rgb( 48,   69,  119)', 'Dark grey blue'],
+  ['rgb(  0,   71,  171)', 'Cobalt blue'],
+  ['rgb( 31,  117,  254)', 'Blue'],
+  ['rgb(120,  180,  255)', 'Bright french blue'],
+  ['rgb(171,  200,  255)', 'Bright steel blue'],
+  ['rgb(208,  231,  255)', 'Ice blue'],
+  ['rgb( 30,   51,   58)', 'Medium jungle green'],
+  ['rgb( 47,   79,   79)', 'Dark slate grey'],
+  ['rgb( 74,  104,   93)', 'Dark grullo green'],
+  ['rgb(  0,  128,  128)', 'Teal'],
+  ['rgb( 67,  170,  176)', 'Turquoise'],
+  ['rgb(109,  174,  199)', 'Cerulean frost'],
+  ['rgb(173,  217,  186)', 'Tiffany green'],
+  ['rgb( 22,   34,   29)', 'Gray-asparagus'],
+  ['rgb( 36,   48,   45)', 'Medium dark teal'],
+  ['rgb( 74,  104,   93)', 'Xanadu'],
+  ['rgb(119,  198,  121)', 'Mint'],
+  ['rgb(175,  205,  182)', 'Timberwolf'],
+  ['rgb(185,  245,  246)', 'Celeste'],
+  ['rgb(193,  255,  234)', 'Aquamarine'],
+  ['rgb( 29,   52,   35)', 'Cal Poly Pomona'],
+  ['rgb(  1,   68,   33)', 'Forest green'],
+  ['rgb( 42,  128,    0)', 'Napier green'],
+  ['rgb(128,  128,    0)', 'Olive'],
+  ['rgb( 65,  156,  105)', 'Sea green'],
+  ['rgb(189,  246,   29)', 'Green-yellow'],
+  ['rgb(231,  244,  134)', 'Bright chartreuse'],
+  ['rgb(138,   23,  137)', 'Purple'],
+  ['rgb( 78,   39,  138)', 'Violet'],
+  ['rgb(193,   75,  110)', 'Dark thulian pink'],
+  ['rgb(222,   49,   99)', 'Cerise'],
+  ['rgb(255,   20,  147)', 'Deep pink'],
+  ['rgb(255,  102,  204)', 'Rose pink'],
+  ['rgb(255,  203,  219)', 'Pink'],
+  ['rgb(255,  255,  255)', 'White'],
+  ['rgb(229,   17,    1)', 'RGB Red'],
+  ['rgb(  0,  255,    0)', 'RGB Green'],
+  ['rgb(  0,    0,  255)', 'RGB Blue'],
+  ['rgb(  0,  255,  255)', 'CMYK Cyan'],
+  ['rgb(255,    0,  255)', 'CMYK Magenta'],
+  ['rgb(255,  255,    0)', 'CMYK Yellow'],
+];
+
+// re-arrange to the right order for display
+let palReordered = [];
+for (let row = 0; row < 7; row++) {
+  for (let col = 0; col < 11; col++) {
+    palReordered.push(palette[col * 7 + row]);
+  }
+  palReordered.push(null); // null indicates a <br />
+}
+
+// Utility for converting base64 image to binary for upload
+// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
+function dataURLtoFile(dataurl, filename) {
+  let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
+    bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
+  while(n--){
+    u8arr[n] = bstr.charCodeAt(n);
+  }
+  return new File([u8arr], filename, { type: mime });
+}
+/** Doodle canvas size options */
+const DOODLE_SIZES = {
+  normal: [500, 500, 'Square 500'],
+  tootbanner: [702, 330, 'Tootbanner'],
+  s640x480: [640, 480, '640×480 - 480p'],
+  s800x600: [800, 600, '800×600 - SVGA'],
+  s720x480: [720, 405, '720x405 - 16:9'],
+};
+
+
+const mapStateToProps = state => ({
+  options: state.getIn(['compose', 'doodle']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  /** Set options in the redux store */
+  setOpt: (opts) => dispatch(doodleSet(opts)),
+  /** Submit doodle for upload */
+  submit: (file) => dispatch(uploadCompose([file])),
+});
+
+/**
+ * Doodling dialog with drawing canvas
+ *
+ * Keyboard shortcuts:
+ * - Delete: Clear screen, fill with background color
+ * - Backspace, Ctrl+Z: Undo one step
+ * - Ctrl held while drawing: Use background color
+ * - Shift held while clicking screen: Use fill tool
+ *
+ * Palette:
+ * - Left mouse button: pick foreground
+ * - Ctrl + left mouse button: pick background
+ * - Right mouse button: pick background
+ */
+class DoodleModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    options: ImmutablePropTypes.map,
+    onClose: PropTypes.func.isRequired,
+    setOpt: PropTypes.func.isRequired,
+    submit: PropTypes.func.isRequired,
+  };
+
+  //region Option getters/setters
+
+  /** Foreground color */
+  get fg () {
+    return this.props.options.get('fg');
+  }
+  set fg (value) {
+    this.props.setOpt({ fg: value });
+  }
+
+  /** Background color */
+  get bg () {
+    return this.props.options.get('bg');
+  }
+  set bg (value) {
+    this.props.setOpt({ bg: value });
+  }
+
+  /** Swap Fg and Bg for drawing */
+  get swapped () {
+    return this.props.options.get('swapped');
+  }
+  set swapped (value) {
+    this.props.setOpt({ swapped: value });
+  }
+
+  /** Mode - 'draw' or 'fill' */
+  get mode () {
+    return this.props.options.get('mode');
+  }
+  set mode (value) {
+    this.props.setOpt({ mode: value });
+  }
+
+  /** Base line weight */
+  get weight () {
+    return this.props.options.get('weight');
+  }
+  set weight (value) {
+    this.props.setOpt({ weight: value });
+  }
+
+  /** Drawing opacity */
+  get opacity () {
+    return this.props.options.get('opacity');
+  }
+  set opacity (value) {
+    this.props.setOpt({ opacity: value });
+  }
+
+  /** Adaptive stroke - change width with speed */
+  get adaptiveStroke () {
+    return this.props.options.get('adaptiveStroke');
+  }
+  set adaptiveStroke (value) {
+    this.props.setOpt({ adaptiveStroke: value });
+  }
+
+  /** Smoothing (for mouse drawing) */
+  get smoothing () {
+    return this.props.options.get('smoothing');
+  }
+  set smoothing (value) {
+    this.props.setOpt({ smoothing: value });
+  }
+
+  /** Size preset */
+  get size () {
+    return this.props.options.get('size');
+  }
+  set size (value) {
+    this.props.setOpt({ size: value });
+  }
+
+  //endregion
+
+  /** Key up handler */
+  handleKeyUp = (e) => {
+    if (e.target.nodeName === 'INPUT') return;
+
+    if (e.key === 'Delete') {
+      e.preventDefault();
+      this.handleClearBtn();
+      return;
+    }
+
+    if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
+      e.preventDefault();
+      this.undo();
+    }
+
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = false;
+      this.swapped = false;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = false;
+      this.mode = 'draw';
+    }
+  };
+
+  /** Key down handler */
+  handleKeyDown = (e) => {
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = true;
+      this.swapped = true;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = true;
+      this.mode = 'fill';
+    }
+  };
+
+  /**
+   * Component installed in the DOM, do some initial set-up
+   */
+  componentDidMount () {
+    this.controlHeld = false;
+    this.shiftHeld = false;
+    this.swapped = false;
+    window.addEventListener('keyup', this.handleKeyUp, false);
+    window.addEventListener('keydown', this.handleKeyDown, false);
+  }
+
+  /**
+   * Tear component down
+   */
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp, false);
+    window.removeEventListener('keydown', this.handleKeyDown, false);
+    if (this.sketcher) this.sketcher.destroy();
+  }
+
+  /**
+   * Set reference to the canvas element.
+   * This is called during component init
+   *
+   * @param elem - canvas element
+   */
+  setCanvasRef = (elem) => {
+    this.canvas = elem;
+    if (elem) {
+      elem.addEventListener('dirty', () => {
+        this.saveUndo();
+        this.sketcher._dirty = false;
+      });
+
+      elem.addEventListener('click', () => {
+        // sketcher bug - does not fire dirty on fill
+        if (this.mode === 'fill') {
+          this.saveUndo();
+        }
+      });
+
+      // prevent context menu
+      elem.addEventListener('contextmenu', (e) => {
+        e.preventDefault();
+      });
+
+      elem.addEventListener('mousedown', (e) => {
+        if (e.button === 2) {
+          this.swapped = true;
+        }
+      });
+
+      elem.addEventListener('mouseup', (e) => {
+        if (e.button === 2) {
+          this.swapped = this.controlHeld;
+        }
+      });
+
+      this.initSketcher(elem);
+      this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
+    }
+  };
+
+  /**
+   * Set up the sketcher instance
+   *
+   * @param canvas - canvas element. Null if we're just resizing
+   */
+  initSketcher (canvas = null) {
+    const sizepreset = DOODLE_SIZES[this.size];
+
+    if (this.sketcher) this.sketcher.destroy();
+    this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
+
+    if (canvas) {
+      this.ctx = this.sketcher.context;
+      this.updateSketcherSettings();
+    }
+
+    this.clearScreen();
+  }
+
+  /**
+   * Done button handler
+   */
+  onDoneButton = () => {
+    const dataUrl = this.sketcher.toImage();
+    const file = dataURLtoFile(dataUrl, 'doodle.png');
+    this.props.submit(file);
+    this.props.onClose(); // close dialog
+  };
+
+  /**
+   * Cancel button handler
+   */
+  onCancelButton = () => {
+    if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
+      return;
+    }
+
+    this.props.onClose(); // close dialog
+  };
+
+  /**
+   * Update sketcher options based on state
+   */
+  updateSketcherSettings () {
+    if (!this.sketcher) return;
+
+    if (this.oldSize !== this.size) this.initSketcher();
+
+    this.sketcher.color = (this.swapped ? this.bg : this.fg);
+    this.sketcher.opacity = this.opacity;
+    this.sketcher.weight = this.weight;
+    this.sketcher.mode = this.mode;
+    this.sketcher.smoothing = this.smoothing;
+    this.sketcher.adaptiveStroke = this.adaptiveStroke;
+
+    this.oldSize = this.size;
+  }
+
+  /**
+   * Fill screen with background color
+   */
+  clearScreen = () => {
+    this.ctx.fillStyle = this.bg;
+    this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
+    this.undos = [];
+
+    this.doSaveUndo();
+  };
+
+  /**
+   * Undo one step
+   */
+  undo = () => {
+    if (this.undos.length > 1) {
+      this.undos.pop();
+      const buf = this.undos.pop();
+
+      this.sketcher.clear();
+      this.ctx.putImageData(buf, 0, 0);
+      this.doSaveUndo();
+    }
+  };
+
+  /**
+   * Save canvas content into the undo buffer immediately
+   */
+  doSaveUndo = () => {
+    this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
+  };
+
+  /**
+   * Called on each canvas change.
+   * Saves canvas content to the undo buffer after some period of inactivity.
+   */
+  saveUndo = debounce(() => {
+    this.doSaveUndo();
+  }, 100);
+
+  /**
+   * Palette left click.
+   * Selects Fg color (or Bg, if Control/Meta is held)
+   *
+   * @param e - event
+   */
+  onPaletteClick = (e) => {
+    const c = e.target.dataset.color;
+
+    if (this.controlHeld) {
+      this.bg = c;
+    } else {
+      this.fg = c;
+    }
+
+    e.target.blur();
+    e.preventDefault();
+  };
+
+  /**
+   * Palette right click.
+   * Selects Bg color
+   *
+   * @param e - event
+   */
+  onPaletteRClick = (e) => {
+    this.bg = e.target.dataset.color;
+    e.target.blur();
+    e.preventDefault();
+  };
+
+  /**
+   * Handle click on the Draw mode button
+   *
+   * @param e - event
+   */
+  setModeDraw = (e) => {
+    this.mode = 'draw';
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on the Fill mode button
+   *
+   * @param e - event
+   */
+  setModeFill = (e) => {
+    this.mode = 'fill';
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on Smooth checkbox
+   *
+   * @param e - event
+   */
+  tglSmooth = (e) => {
+    this.smoothing = !this.smoothing;
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on Adaptive checkbox
+   *
+   * @param e - event
+   */
+  tglAdaptive = (e) => {
+    this.adaptiveStroke = !this.adaptiveStroke;
+    e.target.blur();
+  };
+
+  /**
+   * Handle change of the Weight input field
+   *
+   * @param e - event
+   */
+  setWeight = (e) => {
+    this.weight = +e.target.value || 1;
+  };
+
+  /**
+   * Set size - clalback from the select box
+   *
+   * @param e - event
+   */
+  changeSize = (e) => {
+    let newSize = e.target.value;
+    if (newSize === this.oldSize) return;
+
+    if (this.undos.length > 1 && !confirm('Change canvas size? This will erase your current drawing!')) {
+      return;
+    }
+
+    this.size = newSize;
+  };
+
+  handleClearBtn = () => {
+    if (this.undos.length > 1 && !confirm('Clear canvas? This will erase your current drawing!')) {
+      return;
+    }
+
+    this.clearScreen();
+  };
+
+  /**
+   * Render the component
+   */
+  render () {
+    this.updateSketcherSettings();
+
+    return (
+      <div className='modal-root__modal doodle-modal'>
+        <div className='doodle-modal__container'>
+          <canvas ref={this.setCanvasRef} />
+        </div>
+
+        <div className='doodle-modal__action-bar'>
+          <div className='doodle-toolbar'>
+            <Button text='Done' onClick={this.onDoneButton} />
+            <Button text='Cancel' onClick={this.onCancelButton} />
+          </div>
+          <div className='filler' />
+          <div className='doodle-toolbar with-inputs'>
+            <div>
+              <label htmlFor='dd_smoothing'>Smoothing</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_adaptive'>Adaptive</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_weight'>Weight</label>
+              <span className='val'>
+                <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
+              </span>
+            </div>
+            <div>
+              <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
+                { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
+                  <option key={k} value={k}>{val[2]}</option>,
+                )) }
+              </select>
+            </div>
+          </div>
+          <div className='doodle-toolbar'>
+            <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
+            <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
+            <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
+            <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
+          </div>
+          <div className='doodle-palette'>
+            {
+              palReordered.map((c, i) =>
+                c === null ?
+                  <br key={i} /> :
+                  <button
+                    key={i}
+                    style={{ backgroundColor: c[0] }}
+                    onClick={this.onPaletteClick}
+                    onContextMenu={this.onPaletteRClick}
+                    data-color={c[0]}
+                    title={c[1]}
+                    className={classNames({
+                      'foreground': this.fg === c[0],
+                      'background': this.bg === c[0],
+                    })}
+                  />,
+              )
+            }
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DoodleModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/drawer_loading.jsx b/app/javascript/flavours/glitch/features/ui/components/drawer_loading.jsx
new file mode 100644
index 000000000..08b0d2347
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/drawer_loading.jsx
@@ -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.jsx b/app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx
new file mode 100644
index 000000000..4f1173fd5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import api from 'flavours/glitch/api';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+class EmbedModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    url: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onError: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    loading: false,
+    oembed: null,
+  };
+
+  componentDidMount () {
+    const { url } = this.props;
+
+    this.setState({ loading: true });
+
+    api().post('/api/web/embed', { url }).then(res => {
+      this.setState({ loading: false, oembed: res.data });
+
+      const iframeDocument = this.iframe.contentWindow.document;
+
+      iframeDocument.open();
+      iframeDocument.write(res.data.html);
+      iframeDocument.close();
+
+      iframeDocument.body.style.margin = 0;
+      this.iframe.width  = iframeDocument.body.scrollWidth;
+      this.iframe.height = iframeDocument.body.scrollHeight;
+    }).catch(error => {
+      this.props.onError(error);
+    });
+  }
+
+  setIframeRef = c =>  {
+    this.iframe = c;
+  };
+
+  handleTextareaClick = (e) => {
+    e.target.select();
+  };
+
+  render () {
+    const { intl, onClose } = this.props;
+    const { oembed } = this.state;
+
+    return (
+      <div className='modal-root__modal report-modal embed-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          <FormattedMessage id='status.embed' defaultMessage='Embed' />
+        </div>
+
+        <div className='report-modal__container embed-modal__container' style={{ display: 'block' }}>
+          <p className='hint'>
+            <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
+          </p>
+
+          <input
+            type='text'
+            className='embed-modal__html'
+            readOnly
+            value={oembed && oembed.html || ''}
+            onClick={this.handleTextareaClick}
+          />
+
+          <p className='hint'>
+            <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
+          </p>
+
+          <iframe
+            className='embed-modal__iframe'
+            frameBorder='0'
+            ref={this.setIframeRef}
+            sandbox='allow-same-origin'
+            title='preview'
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(EmbedModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.jsx
new file mode 100644
index 000000000..fa6f11792
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+import StatusContent from 'flavours/glitch/components/status_content';
+import Avatar from 'flavours/glitch/components/avatar';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import DisplayName from 'flavours/glitch/components/display_name';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import Icon from 'flavours/glitch/components/icon';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+
+const messages = defineMessages({
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+});
+
+class FavouriteModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onFavourite: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleFavourite = () => {
+    this.props.onFavourite(this.props.status);
+    this.props.onClose();
+  };
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      let state = { ...this.context.router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
+    }
+  };
+
+  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={classNames('status', `status-${status.get('visibility')}`, 'light')}>
+            <div className='boost-modal__status-header'>
+              <div className='boost-modal__status-time'>
+                <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
+                  <VisibilityIcon visibility={status.get('visibility')} />
+                  <RelativeTimestamp timestamp={status.get('created_at')} />
+                </a>
+              </div>
+
+              <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  <Avatar account={status.get('account')} size={48} />
+                </div>
+
+                <DisplayName account={status.get('account')} />
+
+              </a>
+            </div>
+
+            <StatusContent status={status} />
+
+            {status.get('media_attachments').size > 0 && (
+              <AttachmentList
+                compact
+                media={status.get('media_attachments')}
+              />
+            )}
+          </div>
+        </div>
+
+        <div className='boost-modal__action-bar'>
+          <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='star' /></span> }} /></div>
+          <Button text={intl.formatMessage(messages.favourite)} onClick={this.handleFavourite} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(FavouriteModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx
new file mode 100644
index 000000000..2d49312e5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/filter_modal.jsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { fetchStatus } from 'flavours/glitch/actions/statuses';
+import { fetchFilters, createFilter, createFilterStatus } from 'flavours/glitch/actions/filters';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import IconButton from 'flavours/glitch/components/icon_button';
+import SelectFilter from 'flavours/glitch/features/filters/select_filter';
+import AddedToFilter from 'flavours/glitch/features/filters/added_to_filter';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+class FilterModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    contextType: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    step: 'select',
+    filterId: null,
+    isSubmitting: false,
+    isSubmitted: false,
+  };
+
+  handleNewFilterSuccess = (result) => {
+    this.handleSelectFilter(result.id);
+  };
+
+  handleSuccess = () => {
+    const { dispatch, statusId } = this.props;
+    dispatch(fetchStatus(statusId, true));
+    this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
+  };
+
+  handleFail = () => {
+    this.setState({ isSubmitting: false });
+  };
+
+  handleNextStep = step => {
+    this.setState({ step });
+  };
+
+  handleSelectFilter = (filterId) => {
+    const { dispatch, statusId } = this.props;
+
+    this.setState({ isSubmitting: true, filterId });
+
+    dispatch(createFilterStatus({
+      filter_id: filterId,
+      status_id: statusId,
+    }, this.handleSuccess, this.handleFail));
+  };
+
+  handleNewFilter = (title) => {
+    const { dispatch } = this.props;
+
+    this.setState({ isSubmitting: true });
+
+    dispatch(createFilter({
+      title,
+      context: ['home', 'notifications', 'public', 'thread', 'account'],
+      action: 'warn',
+    }, this.handleNewFilterSuccess, this.handleFail));
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(fetchFilters());
+  }
+
+  render () {
+    const {
+      intl,
+      statusId,
+      contextType,
+      onClose,
+    } = this.props;
+
+    const {
+      step,
+      filterId,
+    } = this.state;
+
+    let stepComponent;
+
+    switch(step) {
+    case 'select':
+      stepComponent = (
+        <SelectFilter
+          contextType={contextType}
+          onSelectFilter={this.handleSelectFilter}
+          onNewFilter={this.handleNewFilter}
+        />
+      );
+      break;
+    case 'create':
+      stepComponent = null;
+      break;
+    case 'submitted':
+      stepComponent = (
+        <AddedToFilter
+          contextType={contextType}
+          filterId={filterId}
+          statusId={statusId}
+          onClose={onClose}
+        />
+      );
+    }
+
+    return (
+      <div className='modal-root__modal report-dialog-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
+          <FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' />
+        </div>
+
+        <div className='report-dialog-modal__container'>
+          {stepComponent}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(injectIntl(FilterModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx
new file mode 100644
index 000000000..a5637d31c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx
@@ -0,0 +1,420 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from 'flavours/glitch/actions/compose';
+import Video, { getPointerPosition } from 'flavours/glitch/features/video';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
+import Audio from 'flavours/glitch/features/audio';
+import Textarea from 'react-textarea-autosize';
+import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
+import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
+import { length } from 'stringz';
+import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
+import GIFV from 'flavours/glitch/components/gifv';
+import { me } from 'flavours/glitch/initial_state';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
+// eslint-disable-next-line import/extensions
+import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
+import { assetHost } from 'flavours/glitch/utils/config';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+  applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
+  placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
+  chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
+  discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
+  discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
+});
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+  account: state.getIn(['accounts', me]),
+  isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
+  description: state.getIn(['compose', 'media_modal', 'description']),
+  lang: state.getIn(['compose', 'language']),
+  focusX: state.getIn(['compose', 'media_modal', 'focusX']),
+  focusY: state.getIn(['compose', 'media_modal', 'focusY']),
+  dirty: state.getIn(['compose', 'media_modal', 'dirty']),
+  is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onSave: (description, x, y) => {
+    dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+  },
+
+  onChangeDescription: (description) => {
+    dispatch(onChangeMediaDescription(description));
+  },
+
+  onChangeFocus: (focusX, focusY) => {
+    dispatch(onChangeMediaFocus(focusX, focusY));
+  },
+
+  onSelectThumbnail: files => {
+    dispatch(uploadThumbnail(id, files[0]));
+  },
+
+});
+
+const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
+  .replace(/\n/g, ' ')
+  .replace(/\*\*\*\*\*\*/g, '\n\n');
+
+class ImageLoader extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+  };
+
+  state = {
+    loading: true,
+  };
+
+  componentDidMount() {
+    const image = new Image();
+    image.addEventListener('load', () => this.setState({ loading: false }));
+    image.src = this.props.src;
+  }
+
+  render () {
+    const { loading } = this.state;
+
+    if (loading) {
+      return <canvas width={this.props.width} height={this.props.height} />;
+    } else {
+      return <img {...this.props} alt='' />;
+    }
+  }
+
+}
+
+class FocalPointModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    isUploadingThumbnail: PropTypes.bool,
+    onSave: PropTypes.func.isRequired,
+    onChangeDescription: PropTypes.func.isRequired,
+    onChangeFocus: PropTypes.func.isRequired,
+    onSelectThumbnail: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    dragging: false,
+    dirty: false,
+    progress: 0,
+    loading: true,
+    ocrStatus: '',
+  };
+
+  componentWillUnmount () {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove);
+    document.addEventListener('mouseup', this.handleMouseUp);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  };
+
+  handleTouchStart = e => {
+    document.addEventListener('touchmove', this.handleMouseMove);
+    document.addEventListener('touchend', this.handleTouchEnd);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  };
+
+  handleMouseMove = e => {
+    this.updatePosition(e);
+  };
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+
+    this.setState({ dragging: false });
+  };
+
+  handleTouchEnd = () => {
+    document.removeEventListener('touchmove', this.handleMouseMove);
+    document.removeEventListener('touchend', this.handleTouchEnd);
+
+    this.setState({ dragging: false });
+  };
+
+  updatePosition = e => {
+    const { x, y } = getPointerPosition(this.node, e);
+    const focusX   = (x - .5) *  2;
+    const focusY   = (y - .5) * -2;
+
+    this.props.onChangeFocus(focusX, focusY);
+  };
+
+  handleChange = e => {
+    this.props.onChangeDescription(e.target.value);
+  };
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.props.onChangeDescription(e.target.value);
+      this.handleSubmit();
+    }
+  };
+
+  handleSubmit = () => {
+    this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
+  };
+
+  getCloseConfirmationMessage = () => {
+    const { intl, dirty } = this.props;
+
+    if (dirty) {
+      return {
+        message: intl.formatMessage(messages.discardMessage),
+        confirm: intl.formatMessage(messages.discardConfirm),
+      };
+    } else {
+      return null;
+    }
+  };
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  handleTextDetection = () => {
+    this._detectText();
+  };
+
+  _detectText = (refreshCache = false) => {
+    const { media } = this.props;
+
+    this.setState({ detecting: true });
+
+    fetchTesseract().then(({ createWorker }) => {
+      const worker = createWorker({
+        workerPath: tesseractWorkerPath,
+        corePath: tesseractCorePath,
+        langPath: `${assetHost}/ocr/lang-data/`,
+        logger: ({ status, progress }) => {
+          if (status === 'recognizing text') {
+            this.setState({ ocrStatus: 'detecting', progress });
+          } else {
+            this.setState({ ocrStatus: 'preparing', progress });
+          }
+        },
+        cacheMethod: refreshCache ? 'refresh' : 'write',
+      });
+
+      let media_url = media.get('url');
+
+      if (window.URL && URL.createObjectURL) {
+        try {
+          media_url = URL.createObjectURL(media.get('file'));
+        } catch (error) {
+          console.error(error);
+        }
+      }
+
+      return (async () => {
+        await worker.load();
+        await worker.loadLanguage('eng');
+        await worker.initialize('eng');
+        const { data: { text } } = await worker.recognize(media_url);
+        this.setState({ detecting: false });
+        this.props.onChangeDescription(removeExtraLineBreaks(text));
+        await worker.terminate();
+      })().catch((e) => {
+        if (refreshCache) {
+          throw e;
+        } else {
+          this._detectText(true);
+        }
+      });
+    }).catch((e) => {
+      console.error(e);
+      this.setState({ detecting: false });
+    });
+  };
+
+  handleThumbnailChange = e => {
+    if (e.target.files.length > 0) {
+      this.props.onSelectThumbnail(e.target.files);
+    }
+  };
+
+  setFileInputRef = c => {
+    this.fileInput = c;
+  };
+
+  handleFileInputClick = () => {
+    this.fileInput.click();
+  };
+
+  render () {
+    const { media, intl, account, onClose, isUploadingThumbnail, description, lang, focusX, focusY, dirty, is_changing_upload } = this.props;
+    const { dragging, detecting, progress, ocrStatus } = this.state;
+    const x = (focusX /  2) + .5;
+    const y = (focusY / -2) + .5;
+
+    const width  = media.getIn(['meta', 'original', 'width']) || null;
+    const height = media.getIn(['meta', 'original', 'height']) || null;
+    const focals = ['image', 'gifv'].includes(media.get('type'));
+    const thumbnailable = ['audio', 'video'].includes(media.get('type'));
+
+    const previewRatio  = 16/9;
+    const previewWidth  = 200;
+    const previewHeight = previewWidth / previewRatio;
+
+    let descriptionLabel = null;
+
+    if (media.get('type') === 'audio') {
+      descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />;
+    } else if (media.get('type') === 'video') {
+      descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />;
+    } else {
+      descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />;
+    }
+
+    let ocrMessage = '';
+    if (ocrStatus === 'detecting') {
+      ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />;
+    } else {
+      ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />;
+    }
+
+    return (
+      <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
+          <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
+        </div>
+
+        <div className='report-modal__container'>
+          <div className='report-modal__comment'>
+            {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
+
+            {thumbnailable && (
+              <React.Fragment>
+                <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
+
+                <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
+
+                <label>
+                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
+
+                  <input
+                    id='upload-modal__thumbnail'
+                    ref={this.setFileInputRef}
+                    type='file'
+                    accept='image/png,image/jpeg'
+                    onChange={this.handleThumbnailChange}
+                    style={{ display: 'none' }}
+                    disabled={isUploadingThumbnail || is_changing_upload}
+                  />
+                </label>
+
+                <hr className='setting-divider' />
+              </React.Fragment>
+            )}
+
+            <label className='setting-text-label' htmlFor='upload-modal__description'>
+              {descriptionLabel}
+            </label>
+
+            <div className='setting-text__wrapper'>
+              <Textarea
+                id='upload-modal__description'
+                className='setting-text light'
+                value={detecting ? '…' : description}
+                lang={lang}
+                onChange={this.handleChange}
+                onKeyDown={this.handleKeyDown}
+                disabled={detecting || is_changing_upload}
+                autoFocus
+              />
+
+              <div className='setting-text__modifiers'>
+                <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} />
+              </div>
+            </div>
+
+            <div className='setting-text__toolbar'>
+              <button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
+              <CharacterCounter max={1500} text={detecting ? '' : description} />
+            </div>
+
+            <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} />
+          </div>
+
+          <div className='focal-point-modal__content'>
+            {focals && (
+              <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
+                {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
+                {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />}
+
+                <div className='focal-point__preview'>
+                  <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
+                  <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
+                </div>
+
+                <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
+                <div className='focal-point__overlay' />
+              </div>
+            )}
+
+            {media.get('type') === 'video' && (
+              <Video
+                preview={media.get('preview_url')}
+                frameRate={media.getIn(['meta', 'original', 'frame_rate'])}
+                blurhash={media.get('blurhash')}
+                src={media.get('url')}
+                detailed
+                inline
+                editable
+              />
+            )}
+
+            {media.get('type') === 'audio' && (
+              <Audio
+                src={media.get('url')}
+                duration={media.getIn(['meta', 'original', 'duration'], 0)}
+                height={150}
+                poster={media.get('preview_url') || account.get('avatar_static')}
+                backgroundColor={media.getIn(['meta', 'colors', 'background'])}
+                foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
+                accentColor={media.getIn(['meta', 'colors', 'accent'])}
+                editable
+              />
+            )}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps, null, {
+  forwardRef: true,
+})(injectIntl(FocalPointModal, { withRef: true }));
diff --git a/app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.jsx b/app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.jsx
new file mode 100644
index 000000000..f3e3b78ed
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
+import { connect } from 'react-redux';
+import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+import { List as ImmutableList } from 'immutable';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  text: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+  count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
+});
+
+class FollowRequestsColumnLink extends React.Component {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    count: PropTypes.number.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(fetchFollowRequests());
+  }
+
+  render () {
+    const { count, intl } = this.props;
+
+    if (count === 0) {
+      return null;
+    }
+
+    return (
+      <ColumnLink
+        transparent
+        to='/follow_requests'
+        icon={<IconWithBadge className='column-link__icon' id='user-plus' count={count} />}
+        text={intl.formatMessage(messages.text)}
+      />
+    );
+  }
+
+}
+
+export default injectIntl(connect(mapStateToProps)(FollowRequestsColumnLink));
diff --git a/app/javascript/flavours/glitch/features/ui/components/header.jsx b/app/javascript/flavours/glitch/features/ui/components/header.jsx
new file mode 100644
index 000000000..f7bab2487
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/header.jsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import Logo from 'flavours/glitch/components/logo';
+import { Link, withRouter } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import { registrationsOpen, me } from 'flavours/glitch/initial_state';
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const Account = connect(state => ({
+  account: state.getIn(['accounts', me]),
+}))(({ account }) => (
+  <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} title={account.get('acct')}>
+    <Avatar account={account} size={35} />
+  </Permalink>
+));
+
+const mapDispatchToProps = (dispatch) => ({
+  openClosedRegistrationsModal() {
+    dispatch(openModal('CLOSED_REGISTRATIONS'));
+  },
+});
+
+class Header extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    openClosedRegistrationsModal: PropTypes.func,
+    location: PropTypes.object,
+  };
+
+  render () {
+    const { signedIn } = this.context.identity;
+    const { location, openClosedRegistrationsModal } = this.props;
+
+    let content;
+
+    if (signedIn) {
+      content = (
+        <>
+          {location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish_form' defaultMessage='Publish' /></Link>}
+          <Account />
+        </>
+      );
+    } else {
+      let signupButton;
+
+      if (registrationsOpen) {
+        signupButton = (
+          <a href='/auth/sign_up' className='button button-tertiary'>
+            <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+          </a>
+        );
+      } else {
+        signupButton = (
+          <button className='button button-tertiary' onClick={openClosedRegistrationsModal}>
+            <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+          </button>
+        );
+      }
+
+      content = (
+        <>
+          <a href='/auth/sign_in' className='button'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
+          {signupButton}
+        </>
+      );
+    }
+
+    return (
+      <div className='ui__header'>
+        <Link to='/' className='ui__header__logo'><Logo /></Link>
+
+        <div className='ui__header__links'>
+          {content}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default withRouter(connect(null, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.jsx b/app/javascript/flavours/glitch/features/ui/components/image_loader.jsx
new file mode 100644
index 000000000..9093eab28
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.jsx
@@ -0,0 +1,171 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { LoadingBar } from 'react-redux-loading-bar';
+import ZoomableImage from './zoomable_image';
+
+export default class ImageLoader extends PureComponent {
+
+  static propTypes = {
+    alt: PropTypes.string,
+    lang: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    previewSrc: PropTypes.string,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    onClick: PropTypes.func,
+    zoomButtonHidden: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    alt: '',
+    lang: '',
+    width: null,
+    height: null,
+  };
+
+  state = {
+    loading: true,
+    error: false,
+    width: null,
+  };
+
+  removers = [];
+  canvas = null;
+
+  get canvasContext() {
+    if (!this.canvas) {
+      return null;
+    }
+    this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
+    return this._canvasContext;
+  }
+
+  componentDidMount () {
+    this.loadImage(this.props);
+  }
+
+  UNSAFE_componentWillReceiveProps (nextProps) {
+    if (this.props.src !== nextProps.src) {
+      this.loadImage(nextProps);
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeEventListeners();
+  }
+
+  loadImage (props) {
+    this.removeEventListeners();
+    this.setState({ loading: true, error: false });
+    Promise.all([
+      props.previewSrc && this.loadPreviewCanvas(props),
+      this.hasSize() && this.loadOriginalImage(props),
+    ].filter(Boolean))
+      .then(() => {
+        this.setState({ loading: false, error: false });
+        this.clearPreviewCanvas();
+      })
+      .catch(() => this.setState({ loading: false, error: true }));
+  }
+
+  loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
+    const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      this.canvasContext.drawImage(image, 0, 0, width, height);
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
+    image.src = previewSrc;
+    this.removers.push(removeEventListeners);
+  });
+
+  clearPreviewCanvas () {
+    const { width, height } = this.canvas;
+    this.canvasContext.clearRect(0, 0, width, height);
+  }
+
+  loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
+    const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
+    image.src = src;
+    this.removers.push(removeEventListeners);
+  });
+
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
+  }
+
+  hasSize () {
+    const { width, height } = this.props;
+    return typeof width === 'number' && typeof height === 'number';
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+    if (c) this.setState({ width: c.offsetWidth });
+  };
+
+  render () {
+    const { alt, lang, src, width, height, onClick } = this.props;
+    const { loading } = this.state;
+
+    const className = classNames('image-loader', {
+      'image-loader--loading': loading,
+      'image-loader--amorphous': !this.hasSize(),
+    });
+
+    return (
+      <div className={className}>
+        {loading ? (
+          <>
+            <div className='loading-bar__container' style={{ width: this.state.width || width }}>
+              <LoadingBar className='loading-bar' loading={1} />
+            </div>
+            <canvas
+              className='image-loader__preview-canvas'
+              ref={this.setCanvasRef}
+              width={width}
+              height={height}
+            />
+          </>
+        ) : (
+          <ZoomableImage
+            alt={alt}
+            lang={lang}
+            src={src}
+            onClick={onClick}
+            width={width}
+            height={height}
+            zoomButtonHidden={this.props.zoomButtonHidden}
+          />
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/image_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/image_modal.jsx
new file mode 100644
index 000000000..5198b8809
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/image_modal.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import ImageLoader from './image_loader';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+class ImageModal extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    navigationHidden: false,
+  };
+
+  toggleNavigation = () => {
+    this.setState(prevState => ({
+      navigationHidden: !prevState.navigationHidden,
+    }));
+  };
+
+  render () {
+    const { intl, src, alt, onClose } = this.props;
+    const { navigationHidden } = this.state;
+
+    const navigationClassName = classNames('media-modal__navigation', {
+      'media-modal__navigation--hidden': navigationHidden,
+    });
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div className='media-modal__closer' role='presentation' onClick={onClose} >
+          <ImageLoader
+            src={src}
+            width={400}
+            height={400}
+            alt={alt}
+            onClick={this.toggleNavigation}
+          />
+        </div>
+
+        <div className={navigationClassName}>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ImageModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx b/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx
new file mode 100644
index 000000000..e813a72b0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx
@@ -0,0 +1,102 @@
+import { connect } from 'react-redux';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'flavours/glitch/initial_state';
+import { logOut } from 'flavours/glitch/utils/log_out';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { PERMISSION_INVITE_USERS } from 'flavours/glitch/permissions';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+class LinkFooter extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  };
+
+  render () {
+    const { signedIn, permissions } = this.context.identity;
+
+    const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
+    const canProfileDirectory = profileDirectory;
+
+    const DividingCircle = <span aria-hidden>{' · '}</span>;
+
+    return (
+      <div className='link-footer'>
+        <p>
+          <strong>{domain}</strong>:
+          {' '}
+          <Link to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
+          {statusPageUrl && (
+            <>
+              {DividingCircle}
+              <a href={statusPageUrl} target='_blank' rel='noopener'><FormattedMessage id='footer.status' defaultMessage='Status' /></a>
+            </>
+          )}
+          {canInvite && (
+            <>
+              {DividingCircle}
+              <a href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a>
+            </>
+          )}
+          {canProfileDirectory && (
+            <>
+              {DividingCircle}
+              <Link to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link>
+            </>
+          )}
+          {DividingCircle}
+          <Link to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
+        </p>
+
+        <p>
+          <strong>Mastodon</strong>:
+          {' '}
+          <a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
+          {DividingCircle}
+          <a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a>
+          {DividingCircle}
+          <Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link>
+          {DividingCircle}
+          <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
+          {DividingCircle}
+          v{version}
+        </p>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(connect(null, mapDispatchToProps)(LinkFooter));
diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/list_panel.jsx
new file mode 100644
index 000000000..489f3a1b4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { createSelector } from 'reselect';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import ColumnLink from './column_link';
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+  if (!lists) {
+    return lists;
+  }
+
+  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
+});
+
+const mapStateToProps = state => ({
+  lists: getOrderedLists(state),
+});
+
+class ListPanel extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    lists: ImmutablePropTypes.list,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchLists());
+  }
+
+  render () {
+    const { lists } = this.props;
+
+    if (!lists || lists.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='list-panel'>
+        <hr />
+
+        {lists.map(list => (
+          <ColumnLink icon='list-ul' key={list.get('id')} strict text={list.get('title')} to={`/lists/${list.get('id')}`} transparent />
+        ))}
+      </div>
+    );
+  }
+
+}
+
+export default withRouter(connect(mapStateToProps)(ListPanel));
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx
new file mode 100644
index 000000000..a3811e91d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.jsx
@@ -0,0 +1,261 @@
+import React from 'react';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+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';
+import Icon from 'flavours/glitch/components/icon';
+import GIFV from 'flavours/glitch/components/gifv';
+import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
+import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
+import { disableSwiping } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+const mapStateToProps = (state, { statusId }) => ({
+  language: state.getIn(['statuses', statusId, 'language']),
+});
+
+class MediaModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.list.isRequired,
+    statusId: PropTypes.string,
+    index: PropTypes.number.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    onChangeBackgroundColor: PropTypes.func.isRequired,
+    currentTime: PropTypes.number,
+    autoPlay: PropTypes.bool,
+    volume: PropTypes.number,
+  };
+
+  state = {
+    index: null,
+    navigationHidden: false,
+    zoomButtonHidden: false,
+  };
+
+  handleSwipe = (index) => {
+    this.setState({ index: index % this.props.media.size });
+  };
+
+  handleTransitionEnd = () => {
+    this.setState({
+      zoomButtonHidden: false,
+    });
+  };
+
+  handleNextClick = () => {
+    this.setState({
+      index: (this.getIndex() + 1) % this.props.media.size,
+      zoomButtonHidden: true,
+    });
+  };
+
+  handlePrevClick = () => {
+    this.setState({
+      index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
+      zoomButtonHidden: true,
+    });
+  };
+
+  handleChangeIndex = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
+
+    this.setState({
+      index: index % this.props.media.size,
+      zoomButtonHidden: true,
+    });
+  };
+
+  handleKeyDown = (e) => {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      e.preventDefault();
+      e.stopPropagation();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      e.preventDefault();
+      e.stopPropagation();
+      break;
+    }
+  };
+
+  componentDidMount () {
+    window.addEventListener('keydown', this.handleKeyDown, false);
+    this._sendBackgroundColor();
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keydown', this.handleKeyDown);
+    this.props.onChangeBackgroundColor(null);
+  }
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  }
+
+  toggleNavigation = () => {
+    this.setState(prevState => ({
+      navigationHidden: !prevState.navigationHidden,
+    }));
+  };
+
+  componentDidUpdate (prevProps, prevState) {
+    if (prevState.index !== this.state.index) {
+      this._sendBackgroundColor();
+    }
+  }
+
+  _sendBackgroundColor () {
+    const { media, onChangeBackgroundColor } = this.props;
+    const index = this.getIndex();
+    const blurhash = media.getIn([index, 'blurhash']);
+
+    if (blurhash) {
+      const backgroundColor = getAverageFromBlurhash(blurhash);
+      onChangeBackgroundColor(backgroundColor);
+    }
+  }
+
+  render () {
+    const { media, language, statusId, intl, onClose } = this.props;
+    const { navigationHidden } = this.state;
+
+    const index = this.getIndex();
+
+    const leftNav  = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>;
+    const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav  media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>;
+
+    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')}
+            lang={language}
+            key={image.get('url')}
+            onClick={this.toggleNavigation}
+            zoomButtonHidden={this.state.zoomButtonHidden}
+          />
+        );
+      } else if (image.get('type') === 'video') {
+        const { currentTime, autoPlay, volume } = this.props;
+
+        return (
+          <Video
+            preview={image.get('preview_url')}
+            blurhash={image.get('blurhash')}
+            src={image.get('url')}
+            width={image.get('width')}
+            height={image.get('height')}
+            frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
+            currentTime={currentTime || 0}
+            autoPlay={autoPlay || false}
+            volume={volume || 1}
+            onCloseVideo={onClose}
+            detailed
+            alt={image.get('description')}
+            lang={language}
+            key={image.get('url')}
+          />
+        );
+      } else if (image.get('type') === 'gifv') {
+        return (
+          <GIFV
+            src={image.get('url')}
+            width={width}
+            height={height}
+            key={image.get('preview_url')}
+            alt={image.get('description')}
+            lang={language}
+            onClick={this.toggleNavigation}
+          />
+        );
+      }
+
+      return null;
+    }).toArray();
+
+    // you can't use 100vh, because the viewport height is taller
+    // than the visible part of the document in some mobile
+    // browsers when it's address bar is visible.
+    // https://developers.google.com/web/updates/2016/12/url-bar-resizing
+    const swipeableViewsStyle = {
+      width: '100%',
+      height: '100%',
+    };
+
+    const containerStyle = {
+      alignItems: 'center', // center vertically
+    };
+
+    const navigationClassName = classNames('media-modal__navigation', {
+      'media-modal__navigation--hidden': navigationHidden,
+    });
+
+    let pagination;
+
+    if (media.size > 1) {
+      pagination = media.map((item, i) => (
+        <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
+          {i + 1}
+        </button>
+      ));
+    }
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div className='media-modal__closer' role='presentation' onClick={onClose} >
+          <ReactSwipeableViews
+            style={swipeableViewsStyle}
+            containerStyle={containerStyle}
+            onChangeIndex={this.handleSwipe}
+            onTransitionEnd={this.handleTransitionEnd}
+            index={index}
+            disabled={disableSwiping}
+          >
+            {content}
+          </ReactSwipeableViews>
+        </div>
+
+        <div className={navigationClassName}>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
+
+          {leftNav}
+          {rightNav}
+
+          <div className='media-modal__overlay'>
+            {pagination && <ul className='media-modal__pagination'>{pagination}</ul>}
+            {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, null, null, { forwardRef: true })(injectIntl(MediaModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_loading.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_loading.jsx
new file mode 100644
index 000000000..b1c322154
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_loading.jsx
@@ -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.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
new file mode 100644
index 000000000..d04a2d53a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
+import Base from 'flavours/glitch/components/modal_root';
+import BundleContainer from '../containers/bundle_container';
+import BundleModalError from './bundle_modal_error';
+import ModalLoading from './modal_loading';
+import ActionsModal from './actions_modal';
+import MediaModal from './media_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import FavouriteModal from './favourite_modal';
+import AudioModal from './audio_modal';
+import DoodleModal from './doodle_modal';
+import ConfirmationModal from './confirmation_modal';
+import FocalPointModal from './focal_point_modal';
+import DeprecatedSettingsModal from './deprecated_settings_modal';
+import ImageModal from './image_modal';
+import {
+  OnboardingModal,
+  MuteModal,
+  BlockModal,
+  ReportModal,
+  SettingsModal,
+  EmbedModal,
+  ListEditor,
+  ListAdder,
+  PinnedAccountsEditor,
+  CompareHistoryModal,
+  FilterModal,
+  InteractionModal,
+  SubscribedLanguagesModal,
+  ClosedRegistrationsModal,
+} from 'flavours/glitch/features/ui/util/async-components';
+import { Helmet } from 'react-helmet';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': () => Promise.resolve({ default: MediaModal }),
+  'ONBOARDING': OnboardingModal,
+  'VIDEO': () => Promise.resolve({ default: VideoModal }),
+  'AUDIO': () => Promise.resolve({ default: AudioModal }),
+  'IMAGE': () => Promise.resolve({ default: ImageModal }),
+  'BOOST': () => Promise.resolve({ default: BoostModal }),
+  'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }),
+  'DOODLE': () => Promise.resolve({ default: DoodleModal }),
+  'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+  'MUTE': MuteModal,
+  'BLOCK': BlockModal,
+  'REPORT': ReportModal,
+  'SETTINGS': SettingsModal,
+  'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
+  'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+  'EMBED': EmbedModal,
+  'LIST_EDITOR': ListEditor,
+  'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
+  'LIST_ADDER': ListAdder,
+  'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
+  'COMPARE_HISTORY': CompareHistoryModal,
+  'FILTER': FilterModal,
+  'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
+  'INTERACTION': InteractionModal,
+  'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
+};
+
+export default class ModalRoot extends React.PureComponent {
+
+  static propTypes = {
+    type: PropTypes.string,
+    props: PropTypes.object,
+    onClose: PropTypes.func.isRequired,
+    ignoreFocus: PropTypes.bool,
+  };
+
+  state = {
+    backgroundColor: null,
+  };
+
+  componentDidUpdate () {
+    if (this.props.type) {
+      document.body.classList.add('with-modals--active');
+      document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+    } else {
+      document.body.classList.remove('with-modals--active');
+      document.documentElement.style.marginRight = 0;
+    }
+  }
+
+  setBackgroundColor = color => {
+    this.setState({ backgroundColor: color });
+  };
+
+  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} />;
+  };
+
+  handleClose = (ignoreFocus = false) => {
+    const { onClose } = this.props;
+    let message = null;
+    try {
+      message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.();
+    } catch (_) {
+      // injectIntl defines `getWrappedInstance` but errors out if `withRef`
+      // isn't set.
+      // This would be much smoother with react-intl 3+ and `forwardRef`.
+    }
+    onClose(message, ignoreFocus);
+  };
+
+  setModalRef = (c) => {
+    this._modal = c;
+  };
+
+  // prevent closing of modal when clicking the overlay
+  noop = () => {};
+
+  render () {
+    const { type, props, ignoreFocus } = this.props;
+    const { backgroundColor } = this.state;
+    const visible = !!type;
+
+    return (
+      <Base backgroundColor={backgroundColor} onClose={props && props.noClose ? this.noop : this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}>
+        {visible && (
+          <>
+            <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+              {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
+            </BundleContainer>
+
+            <Helmet>
+              <meta name='robots' content='noindex' />
+            </Helmet>
+          </>
+        )}
+      </Base>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/mute_modal.jsx
new file mode 100644
index 000000000..a74ebfb05
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.jsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { defineMessages, 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, changeMuteDuration } from 'flavours/glitch/actions/mutes';
+
+const messages = defineMessages({
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+  indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
+});
+
+const mapStateToProps = state => {
+  return {
+    account: state.getIn(['mutes', 'new', 'account']),
+    notifications: state.getIn(['mutes', 'new', 'notifications']),
+    muteDuration: state.getIn(['mutes', 'new', 'duration']),
+  };
+};
+
+const mapDispatchToProps = dispatch => {
+  return {
+    onConfirm(account, notifications, muteDuration) {
+      dispatch(muteAccount(account.get('id'), notifications, muteDuration));
+    },
+
+    onClose() {
+      dispatch(closeModal());
+    },
+
+    onToggleNotifications() {
+      dispatch(toggleHideNotifications());
+    },
+
+    onChangeMuteDuration(e) {
+      dispatch(changeMuteDuration(e.target.value));
+    },
+  };
+};
+
+class MuteModal extends React.PureComponent {
+
+  static propTypes = {
+    account: PropTypes.object.isRequired,
+    notifications: PropTypes.bool.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    onToggleNotifications: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    muteDuration: PropTypes.number.isRequired,
+    onChangeMuteDuration: PropTypes.func.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
+  };
+
+  handleCancel = () => {
+    this.props.onClose();
+  };
+
+  setRef = (c) => {
+    this.button = c;
+  };
+
+  toggleNotifications = () => {
+    this.props.onToggleNotifications();
+  };
+
+  changeMuteDuration = (e) => {
+    this.props.onChangeMuteDuration(e);
+  };
+
+  render () {
+    const { account, notifications, muteDuration, intl } = this.props;
+
+    return (
+      <div className='modal-root__modal mute-modal'>
+        <div className='mute-modal__container'>
+          <p>
+            <FormattedMessage
+              id='confirmations.mute.message'
+              defaultMessage='Are you sure you want to mute {name}?'
+              values={{ name: <strong>@{account.get('acct')}</strong> }}
+            />
+          </p>
+          <p className='mute-modal__explanation'>
+            <FormattedMessage
+              id='confirmations.mute.explanation'
+              defaultMessage='This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.'
+            />
+          </p>
+          <div className='setting-toggle'>
+            <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} />
+            <label className='setting-toggle__label' htmlFor='mute-modal__hide-notifications-checkbox'>
+              <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
+            </label>
+          </div>
+          <div>
+            <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
+
+            {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+            <select value={muteDuration} onChange={this.changeMuteDuration}>
+              <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
+              <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+              <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+              <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+              <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+              <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+              <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+              <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+            </select>
+          </div>
+        </div>
+
+        <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>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(MuteModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx
new file mode 100644
index 000000000..6e8744ef0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { timelinePreview, showTrends } from 'flavours/glitch/initial_state';
+import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
+import DisabledAccountBanner from './disabled_account_banner';
+import FollowRequestsColumnLink from './follow_requests_column_link';
+import ListPanel from './list_panel';
+import NotificationsCounterIcon from './notifications_counter_icon';
+import SignInBanner from './sign_in_banner';
+import { preferencesLink, relationshipsLink } from 'flavours/glitch/utils/backend_links';
+import NavigationPortal from 'flavours/glitch/components/navigation_portal';
+
+const messages = defineMessages({
+  home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+  notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+  explore: { id: 'explore.title', defaultMessage: 'Explore' },
+  local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' },
+  federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' },
+  direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
+  about: { id: 'navigation_bar.about', defaultMessage: 'About' },
+  search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
+  app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+});
+
+class NavigationPanel extends React.Component {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    identity: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    onOpenSettings: PropTypes.func,
+  };
+
+  render() {
+    const { intl, onOpenSettings } = this.props;
+    const { signedIn, disabledAccountId } = this.context.identity;
+
+    return (
+      <div className='navigation-panel'>
+        {signedIn && (
+          <React.Fragment>
+            <ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} />
+            <ColumnLink transparent to='/notifications' icon={<NotificationsCounterIcon className='column-link__icon' />} text={intl.formatMessage(messages.notifications)} />
+            <FollowRequestsColumnLink />
+          </React.Fragment>
+        )}
+
+        {showTrends ? (
+          <ColumnLink transparent to='/explore' icon='hashtag' text={intl.formatMessage(messages.explore)} />
+        ) : (
+          <ColumnLink transparent to='/search' icon='search' text={intl.formatMessage(messages.search)} />
+        )}
+
+        {(signedIn || timelinePreview) && (
+          <>
+            <ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
+            <ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
+          </>
+        )}
+
+        {!signedIn && (
+          <div className='navigation-panel__sign-in-banner'>
+            <hr />
+            { disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
+          </div>
+        )}
+
+        {signedIn && (
+          <React.Fragment>
+            <ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} />
+            <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
+            <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
+            <ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
+
+            <ListPanel />
+
+            <hr />
+
+            {!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' text={intl.formatMessage(messages.preferences)} />}
+            <ColumnLink transparent onClick={onOpenSettings} icon='cogs' text={intl.formatMessage(messages.app_settings)} />
+          </React.Fragment>
+        )}
+
+        <div className='navigation-panel__legal'>
+          <hr />
+          <ColumnLink transparent to='/about' icon='ellipsis-h' text={intl.formatMessage(messages.about)} />
+        </div>
+
+        <NavigationPortal />
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(NavigationPanel);
diff --git a/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
new file mode 100644
index 000000000..6b52ef9b4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+
+const mapStateToProps = state => ({
+  count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0,
+  id: 'bell',
+});
+
+export default connect(mapStateToProps)(IconWithBadge);
diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.jsx
new file mode 100644
index 000000000..df84a1571
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.jsx
@@ -0,0 +1,321 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ReactSwipeableViews from 'react-swipeable-views';
+import classNames from 'classnames';
+import Permalink from 'flavours/glitch/components/permalink';
+import ComposeForm from 'flavours/glitch/features/compose/components/compose_form';
+import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar';
+import Search from 'flavours/glitch/features/compose/components/search';
+import ColumnHeader from './column_header';
+import { me, source_url } from 'flavours/glitch/initial_state';
+
+const noop = () => { };
+
+const messages = defineMessages({
+  home_title: { id: 'column.home', defaultMessage: 'Home' },
+  notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
+  federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const PageOne = ({ acct, domain }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-one'>
+    <div style={{ flex: '0 0 auto' }}>
+      <div className='onboarding-modal__page-one__elephant-friend' />
+    </div>
+
+    <div>
+      <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1>
+      <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p>
+      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p>
+    </div>
+  </div>
+);
+
+PageOne.propTypes = {
+  acct: PropTypes.string.isRequired,
+  domain: PropTypes.string.isRequired,
+};
+
+const PageTwo = ({ intl, myAccount }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-two'>
+    <div className='figure non-interactive'>
+      <div className='pseudo-drawer'>
+        <DrawerAccount account={myAccount} />
+        <ComposeForm
+          privacy='public'
+          text='Awoo! #introductions'
+          spoilerText=''
+          suggestions={[]}
+        />
+      </div>
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
+  </div>
+);
+
+PageTwo.propTypes = {
+  intl: PropTypes.object.isRequired,
+  myAccount: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ intl, myAccount }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-three'>
+    <div className='figure non-interactive'>
+      <Search
+        value=''
+        onChange={noop}
+        onSubmit={noop}
+        onClear={noop}
+        onShow={noop}
+      />
+
+      <div className='pseudo-drawer'>
+        <DrawerAccount account={myAccount} />
+      </div>
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/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={`/@${admin.get('acct')}`}>@{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/mastodon/mastodon' target='_blank' rel='noopener'>Mastodon</a>,
+            github: <a href={source_url} target='_blank' rel='noopener'>GitHub</a>,
+          }}
+        />
+      </p>
+      <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://joinmastodon.org/apps' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
+      <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p>
+    </div>
+  );
+};
+
+PageSix.propTypes = {
+  admin: ImmutablePropTypes.map,
+  domain: PropTypes.string.isRequired,
+};
+
+const mapStateToProps = state => ({
+  myAccount: state.getIn(['accounts', me]),
+  admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+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>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(OnboardingModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/report_modal.jsx
new file mode 100644
index 000000000..79b495877
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.jsx
@@ -0,0 +1,221 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { submitReport } from 'flavours/glitch/actions/reports';
+import { expandAccountTimeline } from 'flavours/glitch/actions/timelines';
+import { fetchServer } from 'flavours/glitch/actions/server';
+import { fetchRelationships } from 'flavours/glitch/actions/accounts';
+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 { OrderedSet } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Category from 'flavours/glitch/features/report/category';
+import Statuses from 'flavours/glitch/features/report/statuses';
+import Rules from 'flavours/glitch/features/report/rules';
+import Comment from 'flavours/glitch/features/report/comment';
+import Thanks from 'flavours/glitch/features/report/thanks';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+class ReportModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    statusId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    step: 'category',
+    selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []),
+    comment: '',
+    category: null,
+    selectedRuleIds: OrderedSet(),
+    forward: true,
+    isSubmitting: false,
+    isSubmitted: false,
+  };
+
+  handleSubmit = () => {
+    const { dispatch, accountId } = this.props;
+    const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state;
+
+    this.setState({ isSubmitting: true });
+
+    dispatch(submitReport({
+      account_id: accountId,
+      status_ids: selectedStatusIds.toArray(),
+      comment,
+      forward,
+      category,
+      rule_ids: selectedRuleIds.toArray(),
+    }, this.handleSuccess, this.handleFail));
+  };
+
+  handleSuccess = () => {
+    this.setState({ isSubmitting: false, isSubmitted: true, step: 'thanks' });
+  };
+
+  handleFail = () => {
+    this.setState({ isSubmitting: false });
+  };
+
+  handleStatusToggle = (statusId, checked) => {
+    const { selectedStatusIds } = this.state;
+
+    if (checked) {
+      this.setState({ selectedStatusIds: selectedStatusIds.add(statusId) });
+    } else {
+      this.setState({ selectedStatusIds: selectedStatusIds.remove(statusId) });
+    }
+  };
+
+  handleRuleToggle = (ruleId, checked) => {
+    const { selectedRuleIds } = this.state;
+
+    if (checked) {
+      this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) });
+    } else {
+      this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) });
+    }
+  };
+
+  handleChangeCategory = category => {
+    this.setState({ category });
+  };
+
+  handleChangeComment = comment => {
+    this.setState({ comment });
+  };
+
+  handleChangeForward = forward => {
+    this.setState({ forward });
+  };
+
+  handleNextStep = step => {
+    this.setState({ step });
+  };
+
+  componentDidMount () {
+    const { dispatch, accountId } = this.props;
+
+    dispatch(fetchRelationships([accountId]));
+    dispatch(expandAccountTimeline(accountId, { withReplies: true }));
+    dispatch(fetchServer());
+  }
+
+  render () {
+    const {
+      accountId,
+      account,
+      intl,
+      onClose,
+    } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    const {
+      step,
+      selectedStatusIds,
+      selectedRuleIds,
+      comment,
+      forward,
+      category,
+      isSubmitting,
+      isSubmitted,
+    } = this.state;
+
+    const domain   = account.get('acct').split('@')[1];
+    const isRemote = !!domain;
+
+    let stepComponent;
+
+    switch(step) {
+    case 'category':
+      stepComponent = (
+        <Category
+          onNextStep={this.handleNextStep}
+          startedFrom={this.props.statusId ? 'status' : 'account'}
+          category={category}
+          onChangeCategory={this.handleChangeCategory}
+        />
+      );
+      break;
+    case 'rules':
+      stepComponent = (
+        <Rules
+          onNextStep={this.handleNextStep}
+          selectedRuleIds={selectedRuleIds}
+          onToggle={this.handleRuleToggle}
+        />
+      );
+      break;
+    case 'statuses':
+      stepComponent = (
+        <Statuses
+          onNextStep={this.handleNextStep}
+          accountId={accountId}
+          selectedStatusIds={selectedStatusIds}
+          onToggle={this.handleStatusToggle}
+        />
+      );
+      break;
+    case 'comment':
+      stepComponent = (
+        <Comment
+          onSubmit={this.handleSubmit}
+          isSubmitting={isSubmitting}
+          isRemote={isRemote}
+          comment={comment}
+          forward={forward}
+          domain={domain}
+          onChangeComment={this.handleChangeComment}
+          onChangeForward={this.handleChangeForward}
+        />
+      );
+      break;
+    case 'thanks':
+      stepComponent = (
+        <Thanks
+          submitted={isSubmitted}
+          account={account}
+          onClose={onClose}
+        />
+      );
+    }
+
+    return (
+      <div className='modal-root__modal report-dialog-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
+          <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
+        </div>
+
+        <div className='report-dialog-modal__container'>
+          {stepComponent}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(makeMapStateToProps)(injectIntl(ReportModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.jsx b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.jsx
new file mode 100644
index 000000000..c0d62aca0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.jsx
@@ -0,0 +1,40 @@
+import React, { useCallback } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { useDispatch } from 'react-redux';
+import { registrationsOpen } from 'flavours/glitch/initial_state';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const SignInBanner = () => {
+  const dispatch = useDispatch();
+
+  const openClosedRegistrationsModal = useCallback(
+    () => dispatch(openModal('CLOSED_REGISTRATIONS')),
+    [dispatch],
+  );
+
+  let signupButton;
+
+  if (registrationsOpen) {
+    signupButton = (
+      <a href='/auth/sign_up' className='button button--block button-tertiary'>
+        <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+      </a>
+    );
+  } else {
+    signupButton = (
+      <button className='button button--block button-tertiary' onClick={openClosedRegistrationsModal}>
+        <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+      </button>
+    );
+  }
+
+  return (
+    <div className='sign-in-banner'>
+      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
+      <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
+      {signupButton}
+    </div>
+  );
+};
+
+export default SignInBanner;
diff --git a/app/javascript/flavours/glitch/features/ui/components/upload_area.jsx b/app/javascript/flavours/glitch/features/ui/components/upload_area.jsx
new file mode 100644
index 000000000..0e07b67f8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/upload_area.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/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.jsx b/app/javascript/flavours/glitch/features/ui/components/video_modal.jsx
new file mode 100644
index 000000000..4cde0ebad
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
+import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
+
+const mapStateToProps = (state, { statusId }) => ({
+  language: state.getIn(['statuses', statusId, 'language']),
+});
+
+class VideoModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    statusId: PropTypes.string,
+    language: PropTypes.string,
+    options: PropTypes.shape({
+      startTime: PropTypes.number,
+      autoPlay: PropTypes.bool,
+      defaultVolume: PropTypes.number,
+    }),
+    onClose: PropTypes.func.isRequired,
+    onChangeBackgroundColor: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { media, onChangeBackgroundColor, onClose } = this.props;
+
+    const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
+
+    if (backgroundColor) {
+      onChangeBackgroundColor(backgroundColor);
+    }
+  }
+
+  render () {
+    const { media, statusId, language, onClose } = this.props;
+    const options = this.props.options || {};
+
+    return (
+      <div className='modal-root__modal video-modal'>
+        <div className='video-modal__container'>
+          <Video
+            preview={media.get('preview_url')}
+            frameRate={media.getIn(['meta', 'original', 'frame_rate'])}
+            blurhash={media.get('blurhash')}
+            src={media.get('url')}
+            currentTime={options.startTime}
+            autoPlay={options.autoPlay}
+            volume={options.defaultVolume}
+            onCloseVideo={onClose}
+            autoFocus
+            detailed
+            alt={media.get('description')}
+            lang={language}
+          />
+        </div>
+
+        <div className='media-modal__overlay'>
+          {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, null, null, { forwardRef: true })(VideoModal);
diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.jsx b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.jsx
new file mode 100644
index 000000000..47401cfe4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.jsx
@@ -0,0 +1,454 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' },
+  expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' },
+});
+
+const MIN_SCALE = 1;
+const MAX_SCALE = 4;
+const NAV_BAR_HEIGHT = 66;
+
+const getMidpoint = (p1, p2) => ({
+  x: (p1.clientX + p2.clientX) / 2,
+  y: (p1.clientY + p2.clientY) / 2,
+});
+
+const getDistance = (p1, p2) =>
+  Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
+
+const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
+
+// Normalizing mousewheel speed across browsers
+// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
+const normalizeWheel = event => {
+  // Reasonable defaults
+  const PIXEL_STEP = 10;
+  const LINE_HEIGHT = 40;
+  const PAGE_HEIGHT = 800;
+
+  let sX = 0,
+    sY = 0, // spinX, spinY
+    pX = 0,
+    pY = 0; // pixelX, pixelY
+
+  // Legacy
+  if ('detail' in event) {
+    sY = event.detail;
+  }
+  if ('wheelDelta' in event) {
+    sY = -event.wheelDelta / 120;
+  }
+  if ('wheelDeltaY' in event) {
+    sY = -event.wheelDeltaY / 120;
+  }
+  if ('wheelDeltaX' in event) {
+    sX = -event.wheelDeltaX / 120;
+  }
+
+  // side scrolling on FF with DOMMouseScroll
+  if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
+    sX = sY;
+    sY = 0;
+  }
+
+  pX = sX * PIXEL_STEP;
+  pY = sY * PIXEL_STEP;
+
+  if ('deltaY' in event) {
+    pY = event.deltaY;
+  }
+  if ('deltaX' in event) {
+    pX = event.deltaX;
+  }
+
+  if ((pX || pY) && event.deltaMode) {
+    if (event.deltaMode === 1) { // delta in LINE units
+      pX *= LINE_HEIGHT;
+      pY *= LINE_HEIGHT;
+    } else { // delta in PAGE units
+      pX *= PAGE_HEIGHT;
+      pY *= PAGE_HEIGHT;
+    }
+  }
+
+  // Fall-back if spin cannot be determined
+  if (pX && !sX) {
+    sX = (pX < 1) ? -1 : 1;
+  }
+  if (pY && !sY) {
+    sY = (pY < 1) ? -1 : 1;
+  }
+
+  return {
+    spinX: sX,
+    spinY: sY,
+    pixelX: pX,
+    pixelY: pY,
+  };
+};
+
+class ZoomableImage extends React.PureComponent {
+
+  static propTypes = {
+    alt: PropTypes.string,
+    lang: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    onClick: PropTypes.func,
+    zoomButtonHidden: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  static defaultProps = {
+    alt: '',
+    lang: '',
+    width: null,
+    height: null,
+  };
+
+  state = {
+    scale: MIN_SCALE,
+    zoomMatrix: {
+      type: null, // 'width' 'height'
+      fullScreen: null, // bool
+      rate: null, // full screen scale rate
+      clientWidth: null,
+      clientHeight: null,
+      offsetWidth: null,
+      offsetHeight: null,
+      clientHeightFixed: null,
+      scrollTop: null,
+      scrollLeft: null,
+      translateX: null,
+      translateY: null,
+    },
+    zoomState: 'expand', // 'expand' 'compress'
+    navigationHidden: false,
+    dragPosition: { top: 0, left: 0, x: 0, y: 0 },
+    dragged: false,
+    lockScroll: { x: 0, y: 0 },
+    lockTranslate: { x: 0, y: 0 },
+  };
+
+  removers = [];
+  container = null;
+  image = null;
+  lastTouchEndTime = 0;
+  lastDistance = 0;
+
+  componentDidMount () {
+    let handler = this.handleTouchStart;
+    this.container.addEventListener('touchstart', handler);
+    this.removers.push(() => this.container.removeEventListener('touchstart', handler));
+    handler = this.handleTouchMove;
+    // on Chrome 56+, touch event listeners will default to passive
+    // https://www.chromestatus.com/features/5093566007214080
+    this.container.addEventListener('touchmove', handler, { passive: false });
+    this.removers.push(() => this.container.removeEventListener('touchend', handler));
+
+    handler = this.mouseDownHandler;
+    this.container.addEventListener('mousedown', handler);
+    this.removers.push(() => this.container.removeEventListener('mousedown', handler));
+
+    handler = this.mouseWheelHandler;
+    this.container.addEventListener('wheel', handler);
+    this.removers.push(() => this.container.removeEventListener('wheel', handler));
+    // Old Chrome
+    this.container.addEventListener('mousewheel', handler);
+    this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
+    // Old Firefox
+    this.container.addEventListener('DOMMouseScroll', handler);
+    this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
+
+    this.initZoomMatrix();
+  }
+
+  componentWillUnmount () {
+    this.removeEventListeners();
+  }
+
+  componentDidUpdate () {
+    this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
+
+    if (this.state.scale === MIN_SCALE) {
+      this.container.style.removeProperty('cursor');
+    }
+  }
+
+  UNSAFE_componentWillReceiveProps () {
+    // reset when slide to next image
+    if (this.props.zoomButtonHidden) {
+      this.setState({
+        scale: MIN_SCALE,
+        lockTranslate: { x: 0, y: 0 },
+      }, () => {
+        this.container.scrollLeft = 0;
+        this.container.scrollTop = 0;
+      });
+    }
+  }
+
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
+  }
+
+  mouseWheelHandler = e => {
+    e.preventDefault();
+
+    const event = normalizeWheel(e);
+
+    if (this.state.zoomMatrix.type === 'width') {
+      // full width, scroll vertical
+      this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
+    } else {
+      // full height, scroll horizontal
+      this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
+    }
+
+    // lock horizontal scroll
+    this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
+  };
+
+  mouseDownHandler = e => {
+    this.container.style.cursor = 'grabbing';
+    this.container.style.userSelect = 'none';
+
+    this.setState({ dragPosition: {
+      left: this.container.scrollLeft,
+      top: this.container.scrollTop,
+      // Get the current mouse position
+      x: e.clientX,
+      y: e.clientY,
+    } });
+
+    this.image.addEventListener('mousemove', this.mouseMoveHandler);
+    this.image.addEventListener('mouseup', this.mouseUpHandler);
+  };
+
+  mouseMoveHandler = e => {
+    const dx = e.clientX - this.state.dragPosition.x;
+    const dy = e.clientY - this.state.dragPosition.y;
+
+    this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
+    this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
+
+    this.setState({ dragged: true });
+  };
+
+  mouseUpHandler = () => {
+    this.container.style.cursor = 'grab';
+    this.container.style.removeProperty('user-select');
+
+    this.image.removeEventListener('mousemove', this.mouseMoveHandler);
+    this.image.removeEventListener('mouseup', this.mouseUpHandler);
+  };
+
+  handleTouchStart = e => {
+    if (e.touches.length !== 2) return;
+
+    this.lastDistance = getDistance(...e.touches);
+  };
+
+  handleTouchMove = e => {
+    const { scrollTop, scrollHeight, clientHeight } = this.container;
+    if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
+      // prevent propagating event to MediaModal
+      e.stopPropagation();
+      return;
+    }
+    if (e.touches.length !== 2) return;
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    const distance = getDistance(...e.touches);
+    const midpoint = getMidpoint(...e.touches);
+    const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
+    const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
+
+    this.zoom(scale, midpoint);
+
+    this.lastMidpoint = midpoint;
+    this.lastDistance = distance;
+  };
+
+  zoom(nextScale, midpoint) {
+    const { scale, zoomMatrix } = this.state;
+    const { scrollLeft, scrollTop } = this.container;
+
+    // math memo:
+    // x = (scrollLeft + midpoint.x) / scrollWidth
+    // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
+    // scrollWidth = clientWidth * scale
+    // scrollWidth' = clientWidth * nextScale
+    // Solve x = x' for nextScrollLeft
+    const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
+    const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
+
+    this.setState({ scale: nextScale }, () => {
+      this.container.scrollLeft = nextScrollLeft;
+      this.container.scrollTop = nextScrollTop;
+      // reset the translateX/Y constantly
+      if (nextScale < zoomMatrix.rate) {
+        this.setState({
+          lockTranslate: {
+            x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
+            y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
+          },
+        });
+      }
+    });
+  }
+
+  handleClick = e => {
+    // don't propagate event to MediaModal
+    e.stopPropagation();
+    const dragged = this.state.dragged;
+    this.setState({ dragged: false });
+    if (dragged) return;
+    const handler = this.props.onClick;
+    if (handler) handler();
+    this.setState({ navigationHidden: !this.state.navigationHidden });
+  };
+
+  handleMouseDown = e => {
+    e.preventDefault();
+  };
+
+  initZoomMatrix = () => {
+    const { width, height } = this.props;
+    const { clientWidth, clientHeight } = this.container;
+    const { offsetWidth, offsetHeight } = this.image;
+    const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
+
+    const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
+    const fullScreen = type === 'width' ?  width > clientWidth : height > clientHeightFixed;
+    const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
+    const scrollTop = type === 'width' ?  (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
+    const scrollLeft = (clientWidth - offsetWidth) / 2;
+    const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
+    const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
+
+    this.setState({
+      zoomMatrix: {
+        type: type,
+        fullScreen: fullScreen,
+        rate: rate,
+        clientWidth: clientWidth,
+        clientHeight: clientHeight,
+        offsetWidth: offsetWidth,
+        offsetHeight: offsetHeight,
+        clientHeightFixed: clientHeightFixed,
+        scrollTop: scrollTop,
+        scrollLeft: scrollLeft,
+        translateX: translateX,
+        translateY: translateY,
+      },
+    });
+  };
+
+  handleZoomClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    const { scale, zoomMatrix } = this.state;
+
+    if ( scale >= zoomMatrix.rate ) {
+      this.setState({
+        scale: MIN_SCALE,
+        lockScroll: {
+          x: 0,
+          y: 0,
+        },
+        lockTranslate: {
+          x: 0,
+          y: 0,
+        },
+      }, () => {
+        this.container.scrollLeft = 0;
+        this.container.scrollTop = 0;
+      });
+    } else {
+      this.setState({
+        scale: zoomMatrix.rate,
+        lockScroll: {
+          x: zoomMatrix.scrollLeft,
+          y: zoomMatrix.scrollTop,
+        },
+        lockTranslate: {
+          x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
+          y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
+        },
+      }, () => {
+        this.container.scrollLeft = zoomMatrix.scrollLeft;
+        this.container.scrollTop = zoomMatrix.scrollTop;
+      });
+    }
+
+    this.container.style.cursor = 'grab';
+    this.container.style.removeProperty('user-select');
+  };
+
+  setContainerRef = c => {
+    this.container = c;
+  };
+
+  setImageRef = c => {
+    this.image = c;
+  };
+
+  render () {
+    const { alt, lang, src, width, height, intl } = this.props;
+    const { scale, lockTranslate } = this.state;
+    const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
+    const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
+    const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
+
+    return (
+      <React.Fragment>
+        <IconButton
+          className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
+          title={zoomButtonTitle}
+          icon={this.state.zoomState}
+          onClick={this.handleZoomClick}
+          size={40}
+          style={{
+            fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */
+          }}
+        />
+        <div
+          className='zoomable-image'
+          ref={this.setContainerRef}
+          style={{ overflow }}
+        >
+          <img
+            role='presentation'
+            ref={this.setImageRef}
+            alt={alt}
+            title={alt}
+            lang={lang}
+            src={src}
+            width={width}
+            height={height}
+            style={{
+              transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
+              transformOrigin: '0 0',
+            }}
+            draggable={false}
+            onClick={this.handleClick}
+            onMouseDown={this.handleMouseDown}
+          />
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default injectIntl(ZoomableImage);
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..1107be740
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnsArea from '../components/columns_area';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const mapStateToProps = state => ({
+  columns: state.getIn(['settings', 'columns']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  openSettings (e) {
+    e.preventDefault();
+    e.stopPropagation();
+    dispatch(openModal('SETTINGS', {}));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(ColumnsArea);
diff --git a/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js b/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..63e994f92
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js
@@ -0,0 +1,8 @@
+import { connect }    from 'react-redux';
+import LoadingBar from 'react-redux-loading-bar';
+
+const mapStateToProps = (state, ownProps) => ({
+  loading: state.get('loadingBar')[ownProps.scope || 'default'],
+});
+
+export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..560c34f01
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import ModalRoot from '../components/modal_root';
+
+const mapStateToProps = state => ({
+  ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
+  type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
+  props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onClose (confirmationMessage, ignoreFocus = false) {
+    if (confirmationMessage) {
+      dispatch(
+        openModal('CONFIRM', {
+          message: confirmationMessage.message,
+          confirm: confirmationMessage.confirm,
+          onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })),
+        }),
+      );
+    } else {
+      dispatch(closeModal(undefined, { ignoreFocus }));
+    }
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..82278a3be
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
@@ -0,0 +1,29 @@
+import { injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import { dismissAlert } from 'flavours/glitch/actions/alerts';
+import { getAlerts } from 'flavours/glitch/selectors';
+
+const mapStateToProps = (state, { intl }) => {
+  const notifications = getAlerts(state);
+
+  notifications.forEach(notification => ['title', 'message'].forEach(key => {
+    const value = notification[key];
+
+    if (typeof value === 'object') {
+      notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
+    }
+  }));
+
+  return { notifications };
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: alert => {
+      dispatch(dismissAlert(alert));
+    },
+  };
+};
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..3cd0707f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -0,0 +1,86 @@
+import { connect } from 'react-redux';
+import StatusList from 'flavours/glitch/components/status_list';
+import { scrollTopTimeline, loadPending } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'lodash';
+import { me } from 'flavours/glitch/initial_state';
+
+const getRegex = createSelector([
+  (state, { regex }) => regex,
+], (rawRegex) => {
+  let regex = null;
+
+  try {
+    regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
+  } catch (e) {
+    // Bad regex, don't affect filters
+  }
+  return regex;
+});
+
+const makeGetStatusIds = (pending = false) => createSelector([
+  (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
+  (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
+  (state)           => state.get('statuses'),
+  getRegex,
+], (columnSettings, statusIds, statuses, regex) => {
+  return statusIds.filter(id => {
+    if (id === null) return true;
+
+    const statusForId = statuses.get(id);
+    let showStatus    = true;
+
+    if (statusForId.get('account') === me) return true;
+
+    if (columnSettings.getIn(['shows', 'reblog']) === false) {
+      showStatus = showStatus && statusForId.get('reblog') === null;
+    }
+
+    if (columnSettings.getIn(['shows', 'reply']) === false) {
+      showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+    }
+
+    if (columnSettings.getIn(['shows', 'direct']) === false) {
+      showStatus = showStatus && statusForId.get('visibility') !== 'direct';
+    }
+
+    if (showStatus && regex) {
+      const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
+      showStatus = !regex.test(searchIndex);
+    }
+
+    return showStatus;
+  });
+});
+
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+  const getPendingStatusIds = makeGetStatusIds(true);
+
+  const mapStateToProps = (state, { timelineId, regex }) => ({
+    statusIds: getStatusIds(state, { type: timelineId, regex }),
+    isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
+    isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
+    hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
+    numPending: getPendingStatusIds(state, { type: timelineId }).size,
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { timelineId }) => ({
+
+  onScrollToTop: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, true));
+  }, 100),
+
+  onScroll: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, false));
+  }, 100),
+
+  onLoadPending: () => dispatch(loadPending(timelineId)),
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx
new file mode 100644
index 000000000..fa35f689d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/index.jsx
@@ -0,0 +1,684 @@
+import React from 'react';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import ModalContainer from './containers/modal_container';
+import { connect } from 'react-redux';
+import { Redirect, Route, withRouter } from 'react-router-dom';
+import { layoutFromWindow } from 'flavours/glitch/is_mobile';
+import { debounce } from 'lodash';
+import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
+import { fetchServer, fetchServerTranslationLanguages } from 'flavours/glitch/actions/server';
+import { clearHeight } from 'flavours/glitch/actions/height_cache';
+import { changeLayout } from 'flavours/glitch/actions/app';
+import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
+import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
+import BundleColumnError from './components/bundle_column_error';
+import UploadArea from './components/upload_area';
+import PermaLink from 'flavours/glitch/components/permalink';
+import ColumnsAreaContainer from './containers/columns_area_container';
+import classNames from 'classnames';
+import Favico from 'favico.js';
+import PictureInPicture from 'flavours/glitch/features/picture_in_picture';
+import {
+  Compose,
+  Status,
+  GettingStarted,
+  KeyboardShortcuts,
+  PublicTimeline,
+  CommunityTimeline,
+  AccountTimeline,
+  AccountGallery,
+  HomeTimeline,
+  Followers,
+  Following,
+  Reblogs,
+  Favourites,
+  DirectTimeline,
+  HashtagTimeline,
+  Notifications,
+  FollowRequests,
+  FavouritedStatuses,
+  BookmarkedStatuses,
+  FollowedTags,
+  ListTimeline,
+  Blocks,
+  DomainBlocks,
+  Mutes,
+  PinnedStatuses,
+  Lists,
+  GettingStartedMisc,
+  Directory,
+  Explore,
+  FollowRecommendations,
+  About,
+  PrivacyPolicy,
+} from './util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
+import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { Helmet } from 'react-helmet';
+import Header from './components/header';
+
+// 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 => ({
+  layout: state.getIn(['meta', 'layout']),
+  hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
+  hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
+  canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
+  layout: state.getIn(['meta', 'layout']),
+  layout_local_setting: state.getIn(['local_settings', 'layout']),
+  isWide: state.getIn(['local_settings', 'stretch']),
+  navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+  dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
+  unreadNotifications: state.getIn(['notifications', 'unread']),
+  showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']),
+  hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
+  moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]),
+  firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
+  username: state.getIn(['accounts', me, 'username']),
+});
+
+const keyMap = {
+  help: '?',
+  new: 'n',
+  search: 's',
+  forceNew: 'option+n',
+  toggleComposeSpoilers: 'option+x',
+  focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+  reply: 'r',
+  favourite: 'f',
+  boost: 'b',
+  mention: 'm',
+  open: ['enter', 'o'],
+  openProfile: 'p',
+  moveDown: ['down', 'j'],
+  moveUp: ['up', 'k'],
+  back: 'backspace',
+  goToHome: 'g h',
+  goToNotifications: 'g n',
+  goToLocal: 'g l',
+  goToFederated: 'g t',
+  goToDirect: 'g d',
+  goToStart: 'g s',
+  goToFavourites: 'g f',
+  goToPinned: 'g p',
+  goToProfile: 'g u',
+  goToBlocked: 'g b',
+  goToMuted: 'g m',
+  goToRequests: 'g r',
+  toggleSpoiler: 'x',
+  bookmark: 'd',
+  toggleCollapse: 'shift+x',
+  toggleSensitive: 'h',
+  openMedia: 'e',
+};
+
+class SwitchingColumnsArea extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    children: PropTypes.node,
+    location: PropTypes.object,
+    navbarUnder: PropTypes.bool,
+    mobile: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    if (this.props.mobile) {
+      document.body.classList.toggle('layout-single-column', true);
+      document.body.classList.toggle('layout-multiple-columns', false);
+    } else {
+      document.body.classList.toggle('layout-single-column', false);
+      document.body.classList.toggle('layout-multiple-columns', true);
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+      this.node.handleChildrenContentChange();
+    }
+
+    if (prevProps.mobile !== this.props.mobile) {
+      document.body.classList.toggle('layout-single-column', this.props.mobile);
+      document.body.classList.toggle('layout-multiple-columns', !this.props.mobile);
+    }
+  }
+
+  setRef = c => {
+    if (c) {
+      this.node = c;
+    }
+  };
+
+  render () {
+    const { children, mobile, navbarUnder } = this.props;
+    const { signedIn } = this.context.identity;
+
+    let redirect;
+
+    if (signedIn) {
+      if (mobile) {
+        redirect = <Redirect from='/' to='/home' exact />;
+      } else {
+        redirect = <Redirect from='/' to='/getting-started' exact />;
+      }
+    } else if (singleUserMode && owner && initialState?.accounts[owner]) {
+      redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
+    } else if (showTrends && trendsAsLanding) {
+      redirect = <Redirect from='/' to='/explore' exact />;
+    } else {
+      redirect = <Redirect from='/' to='/about' exact />;
+    }
+
+    return (
+      <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile} navbarUnder={navbarUnder}>
+        <WrappedSwitch>
+          {redirect}
+
+          <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+          <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
+          <WrappedRoute path='/about' component={About} content={children} />
+          <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
+
+          <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
+          <WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
+          <WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
+          <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
+          <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
+          <WrappedRoute path='/notifications' component={Notifications} content={children} />
+          <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+
+          <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
+          <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
+
+          <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
+          <WrappedRoute path='/directory' component={Directory} content={children} />
+          <WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
+          <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
+
+          <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
+          <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
+          <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
+          <WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} />
+          <WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
+          <WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
+
+          {/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
+          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} 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='/follow_requests' component={FollowRequests} content={children} />
+          <WrappedRoute path='/blocks' component={Blocks} content={children} />
+          <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/followed_tags' component={FollowedTags} 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} />
+
+          <Route component={BundleColumnError} />
+        </WrappedSwitch>
+      </ColumnsAreaContainer>
+    );
+  }
+
+}
+
+class UI extends React.Component {
+
+  static contextTypes = {
+    identity: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    children: PropTypes.node,
+    layout_local_setting: PropTypes.string,
+    isWide: PropTypes.bool,
+    systemFontUi: PropTypes.bool,
+    navbarUnder: PropTypes.bool,
+    isComposing: PropTypes.bool,
+    hasComposingText: PropTypes.bool,
+    hasMediaAttachments: PropTypes.bool,
+    canUploadMore: PropTypes.bool,
+    match: PropTypes.object.isRequired,
+    location: PropTypes.object.isRequired,
+    history: PropTypes.object.isRequired,
+    intl: PropTypes.object.isRequired,
+    dropdownMenuIsOpen: PropTypes.bool,
+    unreadNotifications: PropTypes.number,
+    showFaviconBadge: PropTypes.bool,
+    moved: PropTypes.map,
+    layout: PropTypes.string.isRequired,
+    firstLaunch: PropTypes.bool,
+    username: PropTypes.string,
+  };
+
+  state = {
+    draggingOver: false,
+  };
+
+  handleBeforeUnload = (e) => {
+    const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
+
+    dispatch(synchronouslySubmitMarkers());
+
+    if (hasComposingText || hasMediaAttachments) {
+      // Setting returnValue to any string causes confirmation dialog.
+      // Many browsers no longer display this text to users,
+      // but we set user-friendly message for other browsers, e.g. Edge.
+      e.returnValue = intl.formatMessage(messages.beforeUnload);
+    }
+  };
+
+  handleDragEnter = (e) => {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    if (e.dataTransfer && e.dataTransfer.types.includes('Files') && this.props.canUploadMore && this.context.identity.signedIn) {
+      this.setState({ draggingOver: true });
+    }
+  };
+
+  handleDragOver = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return false;
+    e.preventDefault();
+    e.stopPropagation();
+
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
+
+    }
+
+    return false;
+  };
+
+  handleDrop = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return;
+
+    e.preventDefault();
+
+    this.setState({ draggingOver: false });
+    this.dragTargets = [];
+
+    if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore && this.context.identity.signedIn) {
+      this.props.dispatch(uploadCompose(e.dataTransfer.files));
+    }
+  };
+
+  handleDragLeave = (e) => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
+    this.setState({ draggingOver: false });
+  };
+
+  dataTransferIsText = (dataTransfer) => {
+    return (dataTransfer && Array.from(dataTransfer.types).filter((type) => type === 'text/plain').length === 1);
+  };
+
+  closeUploadModal = () => {
+    this.setState({ draggingOver: false });
+  };
+
+  handleServiceWorkerPostMessage = ({ data }) => {
+    if (data.type === 'navigate') {
+      this.props.history.push(data.path);
+    } else {
+      console.warn('Unknown message type:', data.type);
+    }
+  };
+
+  handleVisibilityChange = () => {
+    const visibility = !document[this.visibilityHiddenProp];
+    this.props.dispatch(notificationsSetVisibility(visibility));
+    if (visibility) {
+      this.props.dispatch(submitMarkers({ immediate: true }));
+    }
+  };
+
+  handleLayoutChange = debounce(() => {
+    this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate
+  }, 500, {
+    trailing: true,
+  });
+
+  handleResize = () => {
+    const layout = layoutFromWindow(this.props.layout_local_setting);
+
+    if (layout !== this.props.layout) {
+      this.handleLayoutChange.cancel();
+      this.props.dispatch(changeLayout(layout));
+    } else {
+      this.handleLayoutChange();
+    }
+  };
+
+  componentDidMount () {
+    const { signedIn } = this.context.identity;
+
+    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.favicon = new Favico({ animation:'none' });
+
+    // On first launch, redirect to the follow recommendations page
+    if (signedIn && this.props.firstLaunch) {
+      this.context.router.history.replace('/start');
+      this.props.dispatch(closeOnboarding());
+    }
+
+    if (signedIn) {
+      this.props.dispatch(fetchMarkers());
+      this.props.dispatch(expandHomeTimeline());
+      this.props.dispatch(expandNotifications());
+      this.props.dispatch(fetchServerTranslationLanguages());
+
+      setTimeout(() => this.props.dispatch(fetchServer()), 3000);
+    }
+
+    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+    };
+
+    if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
+      this.visibilityHiddenProp = 'hidden';
+      this.visibilityChange = 'visibilitychange';
+    } else if (typeof document.msHidden !== 'undefined') {
+      this.visibilityHiddenProp = 'msHidden';
+      this.visibilityChange = 'msvisibilitychange';
+    } else if (typeof document.webkitHidden !== 'undefined') {
+      this.visibilityHiddenProp = 'webkitHidden';
+      this.visibilityChange = 'webkitvisibilitychange';
+    }
+
+    if (this.visibilityChange !== undefined) {
+      document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false);
+      this.handleVisibilityChange();
+    }
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.layout_local_setting !== this.props.layout_local_setting) {
+      const layout = layoutFromWindow(nextProps.layout_local_setting);
+
+      if (layout !== this.props.layout) {
+        this.handleLayoutChange.cancel();
+        this.props.dispatch(changeLayout(layout));
+      } else {
+        this.handleLayoutChange();
+      }
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.props.unreadNotifications != prevProps.unreadNotifications ||
+        this.props.showFaviconBadge != prevProps.showFaviconBadge) {
+      if (this.favicon) {
+        try {
+          this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0);
+        } catch (err) {
+          console.error(err);
+        }
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.visibilityChange !== undefined) {
+      document.removeEventListener(this.visibilityChange, this.handleVisibilityChange);
+    }
+
+    window.removeEventListener('beforeunload', this.handleBeforeUnload);
+    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;
+  };
+
+  handleHotkeyNew = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+    if (element) {
+      element.focus();
+    }
+  };
+
+  handleHotkeySearch = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.search__input');
+
+    if (element) {
+      element.focus();
+    }
+  };
+
+  handleHotkeyForceNew = e => {
+    this.handleHotkeyNew(e);
+    this.props.dispatch(resetCompose());
+  };
+
+  handleHotkeyToggleComposeSpoilers = e => {
+    e.preventDefault();
+    this.props.dispatch(changeComposeSpoilerness());
+  };
+
+  handleHotkeyFocusColumn = e => {
+    const index  = (e.key * 1) + 1; // First child is drawer, skip that
+    const column = this.node.querySelector(`.column:nth-child(${index})`);
+    if (!column) return;
+    const container = column.querySelector('.scrollable');
+
+    if (container) {
+      const status = container.querySelector('.focusable');
+
+      if (status) {
+        if (container.scrollTop > status.offsetTop) {
+          status.scrollIntoView(true);
+        }
+        status.focus();
+      }
+    }
+  };
+
+  handleHotkeyBack = () => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history.state) {
+      this.props.history.goBack();
+    } else {
+      this.props.history.push('/');
+    }
+  };
+
+  setHotkeysRef = c => {
+    this.hotkeys = c;
+  };
+
+  handleHotkeyToggleHelp = () => {
+    if (this.props.location.pathname === '/keyboard-shortcuts') {
+      this.props.history.goBack();
+    } else {
+      this.props.history.push('/keyboard-shortcuts');
+    }
+  };
+
+  handleHotkeyGoToHome = () => {
+    this.props.history.push('/home');
+  };
+
+  handleHotkeyGoToNotifications = () => {
+    this.props.history.push('/notifications');
+  };
+
+  handleHotkeyGoToLocal = () => {
+    this.props.history.push('/public/local');
+  };
+
+  handleHotkeyGoToFederated = () => {
+    this.props.history.push('/public');
+  };
+
+  handleHotkeyGoToDirect = () => {
+    this.props.history.push('/conversations');
+  };
+
+  handleHotkeyGoToStart = () => {
+    this.props.history.push('/getting-started');
+  };
+
+  handleHotkeyGoToFavourites = () => {
+    this.props.history.push('/favourites');
+  };
+
+  handleHotkeyGoToPinned = () => {
+    this.props.history.push('/pinned');
+  };
+
+  handleHotkeyGoToProfile = () => {
+    this.props.history.push(`/@${this.props.username}`);
+  };
+
+  handleHotkeyGoToBlocked = () => {
+    this.props.history.push('/blocks');
+  };
+
+  handleHotkeyGoToMuted = () => {
+    this.props.history.push('/mutes');
+  };
+
+  handleHotkeyGoToRequests = () => {
+    this.props.history.push('/follow_requests');
+  };
+
+  render () {
+    const { draggingOver } = this.state;
+    const { children, isWide, navbarUnder, location, dropdownMenuIsOpen, layout, moved } = this.props;
+
+    const columnsClass = layout => {
+      switch (layout) {
+      case 'single':
+        return 'single-column';
+      case 'multiple':
+        return 'multi-columns';
+      default:
+        return 'auto-columns';
+      }
+    };
+
+    const className = classNames('ui', columnsClass(layout), {
+      'wide': isWide,
+      'system-font': this.props.systemFontUi,
+      'navbar-under': navbarUnder,
+      'hicolor-privacy-icons': this.props.hicolorPrivacyIcons,
+    });
+
+    const handlers = {
+      help: this.handleHotkeyToggleHelp,
+      new: this.handleHotkeyNew,
+      search: this.handleHotkeySearch,
+      forceNew: this.handleHotkeyForceNew,
+      toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
+      focusColumn: this.handleHotkeyFocusColumn,
+      back: this.handleHotkeyBack,
+      goToHome: this.handleHotkeyGoToHome,
+      goToNotifications: this.handleHotkeyGoToNotifications,
+      goToLocal: this.handleHotkeyGoToLocal,
+      goToFederated: this.handleHotkeyGoToFederated,
+      goToDirect: this.handleHotkeyGoToDirect,
+      goToStart: this.handleHotkeyGoToStart,
+      goToFavourites: this.handleHotkeyGoToFavourites,
+      goToPinned: this.handleHotkeyGoToPinned,
+      goToProfile: this.handleHotkeyGoToProfile,
+      goToBlocked: this.handleHotkeyGoToBlocked,
+      goToMuted: this.handleHotkeyGoToMuted,
+      goToRequests: this.handleHotkeyGoToRequests,
+    };
+
+    return (
+      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
+        <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
+          {moved && (<div className='flash-message alert'>
+            <FormattedMessage
+              id='moved_to_warning'
+              defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.'
+              values={{ moved_to_link: (
+                <PermaLink href={moved.get('url')} to={`/@${moved.get('acct')}`}>
+                  @{moved.get('acct')}
+                </PermaLink>
+              ) }}
+            />
+          </div>)}
+
+          <Header />
+
+          <SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'} navbarUnder={navbarUnder}>
+            {children}
+          </SwitchingColumnsArea>
+
+          {layout !== 'mobile' && <PictureInPicture />}
+          <NotificationsContainer />
+          <LoadingBarContainer className='loading-bar' />
+          <ModalContainer />
+          <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(withRouter(UI)));
diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js
new file mode 100644
index 000000000..03e501628
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js
@@ -0,0 +1,207 @@
+export function EmojiPicker () {
+  return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/features/emoji/emoji_picker');
+}
+
+export function Compose () {
+  return import(/* webpackChunkName: "flavours/glitch/async/compose" */'flavours/glitch/features/compose');
+}
+
+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 PinnedAccountsEditor () {
+  return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'flavours/glitch/features/pinned_accounts_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 FollowedTags () {
+  return import(/* webpackChunkName: "flavours/glitch/async/followed_tags" */'flavours/glitch/features/followed_tags');
+}
+
+export function BookmarkedStatuses () {
+  return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
+}
+
+export function Blocks () {
+  return import(/* webpackChunkName: "flavours/glitch/async/blocks" */'flavours/glitch/features/blocks');
+}
+
+export function DomainBlocks () {
+  return import(/* webpackChunkName: "flavours/glitch/async/domain_blocks" */'flavours/glitch/features/domain_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 BlockModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/block_modal" */'flavours/glitch/features/ui/components/block_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 Audio () {
+  return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio');
+}
+
+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');
+}
+
+export function ListAdder () {
+  return import(/* webpackChunkName: "features/glitch/async/list_adder" */'flavours/glitch/features/list_adder');
+}
+
+export function Tesseract () {
+  return import(/*webpackChunkName: "tesseract" */'tesseract.js');
+}
+
+export function Directory () {
+  return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory');
+}
+
+export function FollowRecommendations () {
+  return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations');
+}
+
+export function CompareHistoryModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal');
+}
+
+export function FilterModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/filter_modal" */'flavours/glitch/features/ui/components/filter_modal');
+}
+
+export function Explore () {
+  return import(/* webpackChunkName: "flavours/glitch/async/explore" */'flavours/glitch/features/explore');
+}
+
+export function InteractionModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/modals/interaction_modal" */'flavours/glitch/features/interaction_modal');
+}
+
+export function SubscribedLanguagesModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/modals/subscribed_languages_modal" */'flavours/glitch/features/subscribed_languages_modal');
+}
+
+export function ClosedRegistrationsModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/modals/closed_registrations_modal" */'flavours/glitch/features/closed_registrations_modal');
+}
+
+export function About () {
+  return import(/*webpackChunkName: "features/glitch/async/about" */'flavours/glitch/features/about');
+}
+
+export function PrivacyPolicy () {
+  return import(/*webpackChunkName: "features/glitch/async/privacy_policy" */'flavours/glitch/features/privacy_policy');
+}
diff --git a/app/javascript/flavours/glitch/features/ui/util/fullscreen.js b/app/javascript/flavours/glitch/features/ui/util/fullscreen.js
new file mode 100644
index 000000000..cf5d0cf98
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/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/features/ui/util/get_rect_from_entry.js b/app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js
new file mode 100644
index 000000000..c266cd7dc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/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/features/ui/util/intersection_observer_wrapper.js b/app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js
new file mode 100644
index 000000000..2b24c6583
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/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/features/ui/util/optional_motion.js b/app/javascript/flavours/glitch/features/ui/util/optional_motion.js
new file mode 100644
index 000000000..a7fbe6310
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/optional_motion.js
@@ -0,0 +1,5 @@
+import { reduceMotion } from 'flavours/glitch/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/features/ui/util/react_router_helpers.jsx b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx
new file mode 100644
index 000000000..b1c952d87
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Switch, Route } from 'react-router-dom';
+import StackTrace from 'stacktrace-js';
+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,
+    componentParams: PropTypes.object,
+  };
+
+  static defaultProps = {
+    componentParams: {},
+  };
+
+  static getDerivedStateFromError () {
+    return {
+      hasError: true,
+    };
+  }
+
+  state = {
+    hasError: false,
+    stacktrace: '',
+  };
+
+  componentDidCatch (error) {
+    StackTrace.fromError(error).then(stackframes => {
+      this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  renderComponent = ({ match }) => {
+    const { component, content, multiColumn, componentParams } = this.props;
+    const { hasError, stacktrace } = this.state;
+
+    if (hasError) {
+      return (
+        <BundleColumnError
+          stacktrace={stacktrace}
+          multiColumn={multiColumn}
+          errorType='error'
+        />
+      );
+    }
+
+    return (
+      <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
+        {Component => <Component params={match.params} multiColumn={multiColumn} {...componentParams}>{content}</Component>}
+      </BundleContainer>
+    );
+  };
+
+  renderLoading = () => {
+    const { multiColumn } = this.props;
+
+    return <ColumnLoading multiColumn={multiColumn} />;
+  };
+
+  renderError = (props) => {
+    return <BundleColumnError {...props} errorType='network' />;
+  };
+
+  render () {
+    const { component: Component, content, ...rest } = this.props;
+
+    return <Route {...rest} render={this.renderComponent} />;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/util/reduced_motion.jsx b/app/javascript/flavours/glitch/features/ui/util/reduced_motion.jsx
new file mode 100644
index 000000000..1123b80ed
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/reduced_motion.jsx
@@ -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/features/ui/util/schedule_idle_task.js b/app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js
new file mode 100644
index 000000000..b04d4a8ee
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/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/features/video/index.jsx b/app/javascript/flavours/glitch/features/video/index.jsx
new file mode 100644
index 000000000..80323770c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/video/index.jsx
@@ -0,0 +1,676 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { is } from 'immutable';
+import { throttle, debounce } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
+import { displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
+import Icon from 'flavours/glitch/components/icon';
+import Blurhash from 'flavours/glitch/components/blurhash';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+  hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+  expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+  close: { id: 'video.close', defaultMessage: 'Close video' },
+  fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+  exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+export const formatTime = secondsNum => {
+  let hours   = Math.floor(secondsNum / 3600);
+  let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
+  let seconds = secondsNum - (hours * 3600) - (minutes * 60);
+
+  if (hours   < 10) hours   = '0' + hours;
+  if (minutes < 10) minutes = '0' + minutes;
+  if (seconds < 10) seconds = '0' + seconds;
+  return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
+};
+
+export const findElementPosition = el => {
+  let box;
+
+  if (el.getBoundingClientRect && el.parentNode) {
+    box = el.getBoundingClientRect();
+  }
+
+  if (!box) {
+    return {
+      left: 0,
+      top: 0,
+    };
+  }
+
+  const docEl = document.documentElement;
+  const body  = document.body;
+
+  const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+  const scrollLeft = window.pageXOffset || body.scrollLeft;
+  const left       = (box.left + scrollLeft) - clientLeft;
+
+  const clientTop = docEl.clientTop || body.clientTop || 0;
+  const scrollTop = window.pageYOffset || body.scrollTop;
+  const top       = (box.top + scrollTop) - clientTop;
+
+  return {
+    left: Math.round(left),
+    top: Math.round(top),
+  };
+};
+
+export const getPointerPosition = (el, event) => {
+  const position = {};
+  const box = findElementPosition(el);
+  const boxW = el.offsetWidth;
+  const boxH = el.offsetHeight;
+  const boxY = box.top;
+  const boxX = box.left;
+
+  let pageY = event.pageY;
+  let pageX = event.pageX;
+
+  if (event.changedTouches) {
+    pageX = event.changedTouches[0].pageX;
+    pageY = event.changedTouches[0].pageY;
+  }
+
+  position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
+  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+  return position;
+};
+
+export const fileNameFromURL = str => {
+  const url      = new URL(str);
+  const pathname = url.pathname;
+  const index    = pathname.lastIndexOf('/');
+
+  return pathname.slice(index + 1);
+};
+
+class Video extends React.PureComponent {
+
+  static propTypes = {
+    preview: PropTypes.string,
+    frameRate: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    lang: PropTypes.string,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    sensitive: PropTypes.bool,
+    currentTime: PropTypes.number,
+    onOpenVideo: PropTypes.func,
+    onCloseVideo: PropTypes.func,
+    letterbox: PropTypes.bool,
+    fullwidth: PropTypes.bool,
+    detailed: PropTypes.bool,
+    inline: PropTypes.bool,
+    editable: PropTypes.bool,
+    alwaysVisible: PropTypes.bool,
+    cacheWidth: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+    visible: PropTypes.bool,
+    onToggleVisibility: PropTypes.func,
+    deployPictureInPicture: PropTypes.func,
+    preventPlayback: PropTypes.bool,
+    blurhash: PropTypes.string,
+    autoPlay: PropTypes.bool,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
+    componentIndex: PropTypes.number,
+    autoFocus: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    frameRate: '25',
+  };
+
+  state = {
+    currentTime: 0,
+    duration: 0,
+    volume: 0.5,
+    paused: true,
+    dragging: false,
+    containerWidth: this.props.width,
+    fullscreen: false,
+    hovered: false,
+    muted: false,
+    revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+      this.setState({ revealed: nextProps.visible });
+    }
+  }
+
+  setPlayerRef = c => {
+    this.player = c;
+
+    if (this.player) {
+      this._setDimensions();
+    }
+  };
+
+  _setDimensions () {
+    const width = this.player.offsetWidth;
+
+    if (width && width != this.state.containerWidth) {
+      if (this.props.cacheWidth) {
+        this.props.cacheWidth(width);
+      }
+
+      this.setState({
+        containerWidth: width,
+      });
+    }
+  }
+
+  setVideoRef = c => {
+    this.video = c;
+
+    if (this.video) {
+      this.setState({ volume: this.video.volume, muted: this.video.muted });
+    }
+  };
+
+  setSeekRef = c => {
+    this.seek = c;
+  };
+
+  setVolumeRef = c => {
+    this.volume = c;
+  };
+
+  handleClickRoot = e => e.stopPropagation();
+
+  handlePlay = () => {
+    this.setState({ paused: false });
+    this._updateTime();
+  };
+
+  handlePause = () => {
+    this.setState({ paused: true });
+  };
+
+  _updateTime () {
+    requestAnimationFrame(() => {
+      if (!this.video) return;
+
+      this.handleTimeUpdate();
+
+      if (!this.state.paused) {
+        this._updateTime();
+      }
+    });
+  }
+
+  handleTimeUpdate = () => {
+    this.setState({
+      currentTime: this.video.currentTime,
+      duration:this.video.duration,
+    });
+  };
+
+  handleVolumeMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+    this.handleMouseVolSlide(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  };
+
+  handleVolumeMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+  };
+
+  handleMouseVolSlide = throttle(e => {
+    const { x } = getPointerPosition(this.volume, e);
+
+    if(!isNaN(x)) {
+      this.setState({ volume: x }, () => {
+        this.video.volume = x;
+      });
+    }
+  }, 15);
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove, true);
+    document.addEventListener('mouseup', this.handleMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseMove, true);
+    document.addEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: true });
+    this.video.pause();
+    this.handleMouseMove(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  };
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove, true);
+    document.removeEventListener('mouseup', this.handleMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseMove, true);
+    document.removeEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: false });
+    this.video.play();
+  };
+
+  handleMouseMove = throttle(e => {
+    const { x } = getPointerPosition(this.seek, e);
+    const currentTime = this.video.duration * x;
+
+    if (!isNaN(currentTime)) {
+      this.setState({ currentTime }, () => {
+        this.video.currentTime = currentTime;
+      });
+    }
+  }, 15);
+
+  seekBy (time) {
+    const currentTime = this.video.currentTime + time;
+
+    if (!isNaN(currentTime)) {
+      this.setState({ currentTime }, () => {
+        this.video.currentTime = currentTime;
+      });
+    }
+  }
+
+  handleVideoKeyDown = e => {
+    // On the video element or the seek bar, we can safely use the space bar
+    // for playback control because there are no buttons to press
+
+    if (e.key === ' ') {
+      e.preventDefault();
+      e.stopPropagation();
+      this.togglePlay();
+    }
+  };
+
+  handleKeyDown = e => {
+    const frameTime = 1 / this.getFrameRate();
+
+    switch(e.key) {
+    case 'k':
+      e.preventDefault();
+      e.stopPropagation();
+      this.togglePlay();
+      break;
+    case 'm':
+      e.preventDefault();
+      e.stopPropagation();
+      this.toggleMute();
+      break;
+    case 'f':
+      e.preventDefault();
+      e.stopPropagation();
+      this.toggleFullscreen();
+      break;
+    case 'j':
+      e.preventDefault();
+      e.stopPropagation();
+      this.seekBy(-10);
+      break;
+    case 'l':
+      e.preventDefault();
+      e.stopPropagation();
+      this.seekBy(10);
+      break;
+    case ',':
+      e.preventDefault();
+      e.stopPropagation();
+      this.seekBy(-frameTime);
+      break;
+    case '.':
+      e.preventDefault();
+      e.stopPropagation();
+      this.seekBy(frameTime);
+      break;
+    }
+
+    // If we are in fullscreen mode, we don't want any hotkeys
+    // interacting with the UI that's not visible
+
+    if (this.state.fullscreen) {
+      e.preventDefault();
+      e.stopPropagation();
+
+      if (e.key === 'Escape') {
+        exitFullscreen();
+      }
+    }
+  };
+
+  togglePlay = () => {
+    if (this.state.paused) {
+      this.setState({ paused: false }, () => this.video.play());
+    } else {
+      this.setState({ paused: true }, () => 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);
+
+    window.addEventListener('scroll', this.handleScroll);
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('scroll', this.handleScroll);
+    window.removeEventListener('resize', this.handleResize);
+
+    document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+    if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('video', {
+        src: this.props.src,
+        currentTime: this.video.currentTime,
+        muted: this.video.muted,
+        volume: this.video.volume,
+      });
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.player && this.player.offsetWidth && this.player.offsetWidth != this.state.containerWidth && !this.state.fullscreen) {
+      if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
+      this.setState({
+        containerWidth: this.player.offsetWidth,
+      });
+    }
+    if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) {
+      this.video.pause();
+    }
+  }
+
+  handleResize = debounce(() => {
+    if (this.player) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  handleScroll = throttle(() => {
+    if (!this.video) {
+      return;
+    }
+
+    const { top, height } = this.video.getBoundingClientRect();
+    const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+    if (!this.state.paused && !inView) {
+      this.video.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('video', {
+          src: this.props.src,
+          currentTime: this.video.currentTime,
+          muted: this.video.muted,
+          volume: this.video.volume,
+        });
+      }
+
+      this.setState({ paused: true });
+    }
+  }, 150, { trailing: true });
+
+  handleFullscreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  };
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  };
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  };
+
+  toggleMute = () => {
+    const muted = !this.video.muted;
+
+    this.setState({ muted }, () => {
+      this.video.muted = muted;
+    });
+  };
+
+  toggleReveal = () => {
+    if (this.state.revealed) {
+      this.setState({ paused: true });
+    }
+
+    if (this.props.onToggleVisibility) {
+      this.props.onToggleVisibility();
+    } else {
+      this.setState({ revealed: !this.state.revealed });
+    }
+  };
+
+  handleLoadedData = () => {
+    const { currentTime, volume, muted, autoPlay } = this.props;
+
+    if (currentTime) {
+      this.video.currentTime = currentTime;
+    }
+
+    if (volume !== undefined) {
+      this.video.volume = volume;
+    }
+
+    if (muted !== undefined) {
+      this.video.muted = muted;
+    }
+
+    if (autoPlay) {
+      this.video.play();
+    }
+  };
+
+  handleProgress = () => {
+    const lastTimeRange = this.video.buffered.length - 1;
+
+    if (lastTimeRange > -1) {
+      this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
+    }
+  };
+
+  handleVolumeChange = () => {
+    this.setState({ volume: this.video.volume, muted: this.video.muted });
+  };
+
+  handleOpenVideo = () => {
+    this.video.pause();
+
+    this.props.onOpenVideo({
+      startTime: this.video.currentTime,
+      autoPlay: !this.state.paused,
+      defaultVolume: this.state.volume,
+      componentIndex: this.props.componentIndex,
+    });
+  };
+
+  handleCloseVideo = () => {
+    this.video.pause();
+    this.props.onCloseVideo();
+  };
+
+  getFrameRate () {
+    if (this.props.frameRate && isNaN(this.props.frameRate)) {
+      // The frame rate is returned as a fraction string so we
+      // need to convert it to a number
+
+      return this.props.frameRate.split('/').reduce((p, c) => p / c);
+    }
+
+    return this.props.frameRate || 25;
+  }
+
+  render () {
+    const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, lang, letterbox, fullwidth, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
+    const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const progress = Math.min((currentTime / duration) * 100, 100);
+    const playerStyle = {};
+
+    const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
+
+    let { width, height } = this.props;
+
+    if (inline && containerWidth) {
+      width  = containerWidth;
+      height = containerWidth / (16/9);
+
+      playerStyle.height = height;
+    } else if (inline) {
+      return (<div className={computedClass} ref={this.setPlayerRef} tabindex={0} />);
+    }
+
+    let preload;
+
+    if (this.props.currentTime || fullscreen || dragging) {
+      preload = 'auto';
+    } else if (detailed) {
+      preload = 'metadata';
+    } else {
+      preload = 'none';
+    }
+
+    let warning;
+
+    if (sensitive) {
+      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
+    } else {
+      warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
+    }
+
+    return (
+      <div
+        className={computedClass}
+        style={playerStyle}
+        ref={this.setPlayerRef}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        onClick={this.handleClickRoot}
+        onKeyDown={this.handleKeyDown}
+        tabIndex={0}
+      >
+        <Blurhash
+          hash={blurhash}
+          className={classNames('media-gallery__preview', {
+            'media-gallery__preview--hidden': revealed,
+          })}
+          dummy={!useBlurhash}
+        />
+
+        {(revealed || editable) && <video
+          ref={this.setVideoRef}
+          src={src}
+          poster={preview}
+          preload={preload}
+          loop
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          title={alt}
+          lang={lang}
+          width={width}
+          height={height}
+          volume={volume}
+          onClick={this.togglePlay}
+          onKeyDown={this.handleVideoKeyDown}
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onLoadedData={this.handleLoadedData}
+          onProgress={this.handleProgress}
+          onVolumeChange={this.handleVolumeChange}
+        />}
+
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
+          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
+            <span className='spoiler-button__overlay__label'>{warning}</span>
+          </button>
+        </div>
+
+        <div className={classNames('video-player__controls', { active: paused || hovered })}>
+          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+            <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
+            <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
+
+            <span
+              className={classNames('video-player__seek__handle', { active: dragging })}
+              tabIndex='0'
+              style={{ left: `${progress}%` }}
+              onKeyDown={this.handleVideoKeyDown}
+            />
+          </div>
+
+          <div className='video-player__buttons-bar'>
+            <div className='video-player__buttons left'>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={autoFocus}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+
+              <div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
+                <div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} />
+
+                <span
+                  className={classNames('video-player__volume__handle')}
+                  tabIndex='0'
+                  style={{ left: `${volume * 100}%` }}
+                />
+              </div>
+
+              {(detailed || fullscreen) && (
+                <span className='video-player__time'>
+                  <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
+                  <span className='video-player__time-sep'>/</span>
+                  <span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span>
+                </span>
+              )}
+            </div>
+
+            <div className='video-player__buttons right'>
+              {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
+              {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
+              {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
+              <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(Video);
diff --git a/app/javascript/flavours/glitch/images/elephant_ui_disappointed.svg b/app/javascript/flavours/glitch/images/elephant_ui_disappointed.svg
new file mode 100644
index 000000000..580c15a13
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/elephant_ui_disappointed.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="134.11569" width="134.61565" viewBox="0 0 134.61565 134.11569"><path d="M82.69963 103.86569c6.8 1.5 11 2.4 11.3-6.200005.3-8.6-1.8-17.3-1.8-17.3l-13.6 1.1 4.1 22.400005z" class="st32" fill="#3a434e" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.39963 112.96569c-.2 10.3-.6 17.5 6.5 17.4 7.1-.1 12.6 1.1 13.6-5.3 1.1-6.3 1.9-20.6.7-28.000005" class="st32" fill="#3a434e" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M86.39963 97.66569c-1.4-7.5-4.1-23.2-4.1-23.2s13.2-1.5 10.4-13c-2.7-11.4-7.5-22.6-11-31.1s-14.5-16.9-28.6-15.7c-19.2 1.6-25.6 7-31.6 23.1-5.4 14.4-10.4 47.2-8.9 63.3.8 8.7 5 13.7 14.4 13.5 9.4-.2 39.8-.8 49.8-2.8.3-.1.6-.1.9-.2" class="st33" fill="#56606b"/><path d="M85.89963 97.76569l-4.1-23.2c0-.3.1-.5.4-.6 2.6-.4 5.3-1.4 7.3-3.1 1-.8 1.9-1.9 2.4-3.1.5-1.2.7-2.5.6-3.9 0-1.3-.4-2.6-.7-4-.3-1.3-.7-2.7-1.1-4-.8-2.7-1.7-5.3-2.6-7.9-1.9-5.2-4-10.4-6.1-15.5-.5-1.3-1-2.6-1.7-3.8-.6-1.2-1.4-2.3-2.3-3.4-1.7-2.1-3.8-4-6-5.5-4.6-3-10-4.7-15.4-4.9-2.7-.1-5.5.3-8.2.6-2.7.4-5.5.9-8.1 1.7-2.6.8-5.1 1.9-7.3 3.5s-4.1 3.6-5.6 5.8c-1.5 2.3-2.8 4.7-3.9 7.3-.6 1.3-1.1 2.5-1.6 3.8-.4 1.3-.9 2.6-1.3 3.9-1.6 5.3-2.8 10.7-3.9 16.1-1 5.4-1.9 10.9-2.6 16.4-.7 5.5-1.2 11-1.3 16.6-.1 2.8-.1 5.5.1 8.3.1 2.8.5 5.5 1.6 8 1 2.5 2.9 4.6 5.4 5.7 2.4 1.1 5.2 1.3 8 1.3 5.6-.1 11.1-.2 16.7-.4 11.1-.4 22.2-.8 33.2-2.3.1 0 .2.1.2.2s0 .2-.1.2c-2.7.9-5.5 1.2-8.3 1.4-2.8.2-5.6.5-8.3.6-5.6.3-11.1.6-16.7.7-5.6.2-11.1.3-16.7.4-2.8.1-5.7-.1-8.4-1.3s-4.7-3.5-5.8-6.2c-1.1-2.6-1.5-5.5-1.6-8.3-.2-2.8-.2-5.6-.1-8.4.2-5.6.7-11.1 1.3-16.7.7-5.5 1.5-11 2.6-16.5s2.3-10.9 3.9-16.3c.4-1.3.9-2.7 1.3-4 .5-1.3 1-2.6 1.6-3.9 1.1-2.6 2.4-5.1 4-7.4 1.6-2.3 3.6-4.4 5.9-6.1 2.3-1.7 4.9-2.8 7.6-3.7 2.7-.8 5.5-1.4 8.2-1.7 2.8-.3 5.5-.7 8.4-.6 5.6.2 11.2 1.9 15.9 5 2.4 1.6 4.5 3.5 6.3 5.7.9 1.1 1.7 2.3 2.4 3.5.7 1.3 1.2 2.6 1.7 3.9 2.1 5.1 4.2 10.3 6.1 15.5.9 2.6 1.8 5.3 2.6 7.9.4 1.3.8 2.7 1.1 4 .3 1.3.7 2.7.8 4.2.1 1.4-.1 2.9-.7 4.3s-1.5 2.5-2.6 3.5c-2.3 1.9-5 2.8-7.9 3.3l.4-.6 4.1 23.2c0 .3-.1.5-.4.6-.3.1-.7.5-.7.2z"/><path d="M26.49963 114.06569c-4.7 0-7.4-2.1-10-4.4-2.3-2-3.2-4.6-3.4-8.6-.1-2.700005-.6-10.000005.4-18.800005 3.8.9 9.7 3.8 13.4 7.6 5.6 5.7 17.7 6.3 22.7 6.3h1.8l.1-.4s.5-2.6 1.8-5.2l.3-.6-.7-.1c-.4-.1-10.9-1.9-9.7-10.8.7-4.9 13.3-7.9 33.9-7.9 2.2 0 3.8 0 4.2.1l3.5 2.2c-1.5.5-2.6.6-2.6.6l-.5.1.1.5c0 .2 2.8 16.4 4.1 24 0 0-7.9 13.100005-8 13.000005-.1-.1-.3-.1-.3-.1-.3 0-.7.1-.9.1-9.9 1.7-39.6 2.4-49.3 2.6l-.9-.2z" class="st34" fill="#3a434e"/><path d="M45.89963 51.36569c-.7 0-1.4-.6-1.4-1.4v-5.1c0-.7.6-1.4 1.4-1.4.7 0 1.4.6 1.4 1.4v5.1c-.1.8-.7 1.4-1.4 1.4z"/><path d="M72.89963 30.365685c-3.5.4-2.7 2.9-1.2 3.5 1.5.6 3.7.1 4.3-1.6.4-1.6-1.3-2.1-3.1-1.9z" class="st35" fill="#4f5862"/><path d="M44.29963 53.965685c-.4.7-1.5.2-2.7-.6-1.2-.8-2.1-1.5-1.6-2.2.4-.7 1.6-.4 2.8.4 1.2.8 2 1.7 1.5 2.4z" class="st34" fill="#4f5862"/><path d="M27.29963 36.165685c0-5.6-3.7-9.4-7.9-9.8-4.2-.4-9-.3-14.0000002 11.3-5.00000001 11.6-6.7 15.7-2.6 17.9 4.1 2.2 9.5000002 1.5 11.3000002-1.4 0 0 5.3 3.8 9.7-3.8" class="st36" fill="#56606b" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M11.19963 40.565685c-2.7000002 5.1-2.7000002 7.7-.5 8.5 2.2.8 4.1.7 6.4-3 0 0 2 .7 4.9-4.1.9-1.5-.7-2.6-.7-2.6s-4.8 1.3-7.1-5l-3 6.2z" class="st34" fill="#3a434e" fill-opacity="0"/><path d="M9.7996298 43.365685l4.4000002-9s1.8 6.3 7.8 4.9" class="st7" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M27.89963 67.365685c-4.9.8-9.7 4.5-9.3 15.7.4 11.2.5 18.700005 6.1 20.000005 5.5 1.3 13.8.3 14.1-7.100005.3-7.4.3-16.1.3-16.1" class="st36" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M28.69963 102.96569c-1.4 0-2.8-.2-4.1-.5-1.2-.3-2.2-.9-3-2 .5.2 1.1.3 1.7.3 1.2 0 5.2-.5 5.8-7.200005.7-7.4 2.8-10.9 6.6-10.9.8 0 1.6.1 2.6.4 0 3.4-.1 8.3-.2 12.7-.2 6.700005-7.2 7.200005-9.4 7.200005z" class="st34" fill="#3a434e"/><path d="M50.69963 18.965685c-5.2 2.9-14.6 4.7-18.1-1.5-3-5.4 2.1-9.6999996 7.8-9.9999996 5.7-.3 7.6 1.2 7.6 1.2s1.9-5.9 9.3-7.69999998c3.9-1 6-.1 6.2 1.19999998 0 0 3.6-.9 4 3.5 0 0 3.9-.4 3.1 5.1999996-.8 5.6-10.6 10.1-17.7 6.4 0 0-1.1 1.2-2.2 1.7z" class="st33" fill="#56606b"/><path d="M40.79963 21.665685c-2.7 0-4.8-.9-6.3-2.3-.7-1.1-.8-2.9-.3-4.3.8-1.9 2.6-3.3 4.6-3.7 1.2-.2 2.6-.4 3.9-.4 3.3 0 6.2.8 7.3 1.9l.6.6.3-.7s.7-2 2.2-2c.2 0 .5.1.8.2 2.2.9 3.5 1.2 4.6 1.2.5 0 .9-.1 1.3-.2.1-.1.4-.1.6-.1.6 0 1.5.3 1.8.8.2.3.2.6.1 1l-.2.6h.7c.4 0 1.4.2 1.8.9.2.4.2 1-.2 1.7-1.8.8-3.8 1.2-5.7 1.2-2 0-4 0-5.6-.8 0 0-1.2 1.3-2.2 1.8-3.1 1.6-7 2.6-10.1 2.6z" class="st34" fill="#3a434e" fill-opacity=".94117647"/><path d="M61.79963 18.66569c-3.1.5-6.3.1-8.9-1.5.7.2 1.5.4 2.2.5.7.2 1.4.2 2.2.3.7.1 1.4 0 2.2 0 .7-.1 1.4-.1 2.2-.2h.1c.3 0 .5.1.6.4-.1.2-.3.5-.6.5z"/><path d="M37.59963 21.26569c-2.4-.4-4.8-2.1-5.7-4.5-.5-1.2-.7-2.6-.3-3.9.3-1.3 1.1-2.4 2.1-3.3 2-1.7 4.6-2.5 7.1-2.6 1.3-.1 2.5 0 3.8.1.6.1 1.3.2 1.9.4.6.2 1.2.4 1.9.8l-.8.2c.6-1.6 1.6-3 2.8-4.2 1.2-1.2 2.6-2.2 4.1-2.9 1.5-.7 3.2-1.1 4.8-1.3.8-.1 1.7-.1 2.6.1.4.1.9.3 1.3.6s.7.8.8 1.3l-.6-.4c.6-.1 1.2-.1 1.7 0 .6.1 1.1.4 1.6.8.4.4.7.9.9 1.5.1.3.2.5.2.8l.1.8-.5-.4c1 0 1.9.3 2.6.9.7.7 1 1.6 1.1 2.5.1.9 0 1.7-.1 2.5-.2.9-.5 1.7-1 2.4-.9 1.4-2.2 2.5-3.7 3.4-1.4.9-3 1.4-4.6 1.8-.3.1-.5-.1-.6-.4-.1-.3.1-.5.4-.6 1.5-.3 3-.9 4.3-1.7 1.3-.8 2.5-1.8 3.3-3.1.4-.6.7-1.3.8-2 .1-.7.2-1.5.1-2.2-.1-.7-.3-1.4-.8-1.9-.5-.4-1.2-.7-1.8-.7-.3 0-.5-.2-.5-.4l-.1-.7c-.1-.2-.1-.4-.2-.7-.2-.4-.4-.8-.7-1.1-.3-.3-.7-.5-1.1-.6-.4-.1-.9-.1-1.3 0-.3.1-.5-.1-.6-.4-.1-.5-.7-.9-1.4-1.1-.7-.2-1.5-.2-2.2-.1-1.5.2-3.1.6-4.5 1.2-1.4.7-2.7 1.6-3.8 2.7-1.1 1.1-2 2.5-2.5 3.8-.1.3-.4.4-.6.3h-.1c-.4-.2-1-.5-1.5-.6-.6-.1-1.2-.2-1.7-.3-1.2-.1-2.4-.2-3.6-.1-2.4.1-4.7.8-6.5 2.3-.9.7-1.6 1.7-1.9 2.8-.3 1.1-.2 2.3.2 3.4s1.1 2.1 1.9 2.9c.6.9 1.7 1.5 2.9 1.9z"/><path d="M63.49963 2.1656854c0 3.5-2.6 5.5-4.3 6.1m8.3-2.6c.2 3.4-3.3 5.1999996-3.3 5.1999996" class="st7" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.29963 84.765685c2.6 2.3 3 4.3-2.4 4.8-5.3.5-25.7 2.4-28.2 2.6-2.4.3-3.4 1.7-3.4 2.8 0 1.1.5 3.2 4 3.1 3.4-.1 23.8-1.5 30.4-2.4 6.6-.8 14.4-2.4 13.4-9s-5.4-8.7-5.4-8.7l-8.4 6.8z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.29963 84.765685c2.6 2.3 3 4.3-2.4 4.8-5.3.5-25.7 2.4-28.2 2.6-2.4.3-3.4 1.7-3.4 2.8 0 1.1.5 3.2 4 3.1 3.4-.1 23.8-1.5 30.4-2.4 6.6-.8 13.8-2.3 13.4-9-.3-5.5-3.1-7-4.4-8.1-.5-.1-1-.1-1.6-.2l-7.8 6.4z" fill="#625d28" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M102.69963 64.665685c5.4-.1 10.3-1.9 12.2-6.5 1.9-4.6 8.7-10.1 14.2-2.1 5.4 8.1 6.6 17.3 2.8 23.7-3.8 6.5-12.1 3.5-14.9-.5-2.7-4-8.6-2.9-14.5-2.7-5.9.2.2-11.9.2-11.9z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.89963 54.865685s10.2 21.3 13.5 26.8c3.2 5.5 12.9 6.2 17.4 3.5 4.5-2.7 7.3-7.3 8-15.1.7-7.9-2.4-14.9-10-15.2-7.6-.3-11.9 7.6-12.1 13.7" class="st36" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.89963 54.865685s10.2 21.3 13.5 26.8c3.2 5.5 12.9 6.2 17.4 3.5 4.5-2.7 7.3-7.3 8-15.1.7-7.9-2.4-14.9-10-15.2-7.6-.3-11.9 7.6-12.1 13.7" class="st36" fill="#56606b" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.19963 86.165685c-3.7 0-8.3-1.3-10.4-4.8-.9-1.5-2.4-4.3-4.4-8.4l5.9-.1c4 7.4 5.9 9.8 8 9.8 3.9 0 6-3.4 6.9-9.5.2-1.2.3-2.3.4-3.4.5-4.6.9-7.2 3.4-7.5.3 0 .6-.1.9-.1 2.1 0 2.5 1.2 3.1 2.8.1.2.2.5.2.7.1 1.3.1 2.7 0 4.2-.7 7.3-3.1 11.9-7.7 14.7-1.6 1-3.9 1.6-6.3 1.6z" class="st34" fill="#3a434e"/><path d="M89.19963 63.86569l-.3 6.6c-.1 1.1-.2 2.2-.4 3.3-.1.6-.3 1.1-.5 1.7-.3.5-.6 1.1-1.2 1.5-.2.1-.5.1-.7-.2-.1-.2-.1-.5.2-.7.7-.4 1.1-1.5 1.3-2.5.2-1 .4-2.1.5-3.2.2-2.2.3-4.4.5-6.6 0-.2.2-.3.3-.3.2.1.3.3.3.4z"/><path d="M52.29963 68.665685c-6.3.6-11.1 3.9-10 10.7 1.1 6.8 7.6 8.1 16 7.7 8.4-.4 26.4-1.3 26.4-1.3s-3.3-1.7-4.8-3.3c-.5-.6-1-1.4-1.6-2.5-1.6.1-15.5.8-22.7 1-3.4.1-3.8-1.2-3.9-1.8-.3-1.2.5-2.7 2.8-2.8 3.1-.2 10.8-.7 21.4-.7h11.5s.9-.3 1-9.1c0-.1-29.8 1.5-36.1 2.1z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M56.19963 86.665685c-8.8 0-12.6-2.3-13.5-7.4 0-.1 0-.2-.1-.4 1.2 1.5 3.5 2.7 5.6 2.7 1.4 0 2.6-.5 3.6-1.5.5.7 1.4 1.4 3.7 1.4h.4c6.9-.2 19.5-.8 22.1-.9.6 1.1 1.2 1.9 1.6 2.4.9 1 2.4 1.9 3.6 2.5-5 .3-18.1.9-24.7 1.2h-2.3z" class="st39" fill="#625d28"/><path d="M44.09963 57.865685c-2.2-.6-5.8-8.3-8.7-8.7-2.9-.3-6.6 1.6-3.2 8.5 3.4 6.9 8 10 14.3 8.2 6.3-1.8 12.7-5.1 14.5-8.3 1.8-3.2-.6-6.2-4.8-4.3-4.1 1.7-9.9 5.2-12.1 4.6z" fill="#b3bfcd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M43.09963 65.865685c-4.3 0-7.7-2.7-10.4-8.4-1.4-2.8-1.7-5.1-.8-6.4.8-1.3 2.3-1.4 2.9-1.4h.6c.4 0 .8.2 1.2.6-.7.1-1.3.5-1.6 1.2-.6 1.2-.3 2.9.9 4.7l.4.6c2.1 3.1 4.1 6 7.8 6 .9 0 1.9-.2 2.9-.6 5.6-2 9.4-3.6 11.1-5.4 1.2-1.3 1.9-2.6 1.7-3.6.5.2.9.5 1.1.9.5.8.4 1.9-.2 3-1.6 2.8-7.4 6.2-14.2 8.1-1.2.5-2.3.7-3.4.7z" class="st35" fill="#93a1b5"/><path d="M13.89963 107.66569c-.1 5.1 1.3 10.2 2.3 14.8 1.3 5.5 1.3 10.1 5.2 10.7 3.9.6 10.1.9 14.4 0 4.3-.9 4.1-5.2 4.5-8.2.4-3.1 0-10.7 0-10.7s-1.1-1.4-3-1.9" class="st34" fill="#3a434e"/><path d="M14.39963 107.66569c-.1 5.2 1.3 10.3 2.5 15.4l.8 3.9c.3 1.3.6 2.5 1.1 3.6.3.5.6 1 1.1 1.4.5.3 1 .5 1.6.6 1.3.2 2.6.3 3.9.4 2.6.2 5.2.2 7.8 0 1.3-.1 2.6-.3 3.7-.7 1.1-.4 1.9-1.4 2.3-2.6.4-1.2.5-2.4.7-3.7.1-.7.2-1.3.2-1.9.1-.6.1-1.3.1-1.9 0-2.6 0-5.2-.2-7.8l.1.3c-.1-.2-.3-.4-.5-.6l-.6-.6c-.4-.4-.9-.7-1.5-.9-.1 0-.1-.1-.1-.2s.1-.1.2-.1c.6.1 1.2.3 1.7.6.3.1.5.3.8.5.3.2.5.4.8.6.1.1.1.2.1.2.1 2.6.2 5.3.2 7.9 0 .7 0 1.3-.1 2s-.2 1.3-.2 2c-.1 1.3-.3 2.7-.7 4-.5 1.3-1.5 2.6-2.8 3.1-1.4.6-2.7.7-4 .8-2.7.2-5.3.2-8 0-1.3-.1-2.6-.2-4-.4-.7-.1-1.4-.4-2-.8-.6-.5-1-1.1-1.4-1.7-.6-1.3-.9-2.6-1.2-3.9l-.8-3.9c-1.1-5.1-2.6-10.3-2.5-15.6 0-.3.2-.5.5-.5.2 0 .4.2.4.5z"/><path d="M68.19963 86.665685l.4 4.6s.3 1.5 2.4 1.5c2.1-.1 2.2-2 2.2-2l-.1-4.5-4.9.4z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M110.49963 71.465685c-.5 1.8.5 2.9 3.8 4.6 3.3 1.8 4 5.1 8.2 6 4.3.9 8.2-4.5 3.8-10.1-4.5-5.6-14.1-6.5-15.8-.5z" class="st39" fill="#625d28"/><circle r="1.7" cy="57.765686" cx="126.09963" fill="#99988c"/><path d="M17.39963 115.26569s.8 3.9 1.1 6.3c.3 2.4.9 3.8 5.9 3.2 5-.6 4.9-1.5 5.1-6.4.2-4.9-.1-7.4-3.7-7.6-3.6-.2-9.1.7-8.4 4.5z" class="st33" fill="#56606b"/><path fill="#3a434e" class="st34" d="M11.19963 40.565685c-2.7000002 5.1-2.7000002 7.7-.5 8.5 2.2.8 4.1.7 6.4-3 0 0 2 .7 4.9-4.1.9-1.5-.7-2.6-.7-2.6s-4.8 1.3-7.1-5l-3 6.2z"/></svg>
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/images/elephant_ui_working.svg b/app/javascript/flavours/glitch/images/elephant_ui_working.svg
new file mode 100644
index 000000000..8ba475db0
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/elephant_ui_working.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 124.12477 127.91685" width="124.12476" height="127.91685"><path d="M72.584191 46.815676c-2.3-2.2-4.2-2.5-6.6-.6-2.4 1.9-2.1 4.8.9 7.6 3.1 2.9 4.7 4.1 6.7 5 2.1.9 5.4 2.5 10.5-2s10.2-11.1 9.4-14.7c-.8-3.6-4.1-1.8-6.8 1.2s-3.7 4-5.4 5.2c-1.5 1.3-3.8 3-8.7-1.7z" class="st0" style="fill:#93a1b5;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M116.384191 75.015676c0 6.3-3.9 9.8-9.1 9.8-5.3 0-9.9-3.5-9.9-9.8 0-6.3 4.3-10.3 9.5-10.3s9.5 4 9.5 10.3z" style="fill:#3a434e;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M54.184191 16.615676c-23 1.2-30.5 14.1-32.8 27.8-3 18.2-8.2 44.2-9.2 53.2s-1 16 6 22 11 5 23 7 19 0 20-8l16.8-1.1s14.5 5.5 18.8 6.9c4.3 1.4 10.6.5 12.1-7.1s.2-12.5-6.6-14.4c-6.8-1.9-10.6-2.9-10.6-2.9l4.4-30.1s17.4 1.6 22.6-20c0 0 3.9 1.1 4.8-2.8.9-3.9-2.6-6.2-5.6-4.8l-2.5-1s-.2-3.8-3.5-4.2c-2.1-.2-6 3.4-3 7.4 0 0-3.4 8.9-12 7.8-8.6-1.1-12.5-11.2-15-18.2s-10.7-18.3-27.7-17.5z" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M95.484191 69.915676c-.6 0-1.2 0-1.8-.1-4.2-.2-10.9-2.4-17-7.8-.7-1-.4-2-.2-2.5.5-1.1 1.7-1.8 3-1.8.8 0 1.5.3 2.2.8 3 2.2 7.8 5.1 13.8 5.1.9 0 1.8-.1 2.7-.2 7.2-1 12.1-5.8 14.3-9.9.6-1.2 1.3-2.5 1.8-3.5.2-.5.3-.7.6-.9.3-.2 1 .2 1 .2l2.1.8c-4.5 17.8-17.4 19.2-21.2 19.2h-1.3v.6z" class="st3" style="fill:#3a434e;opacity:.98;fill-opacity:1"/><path d="M48.884191 126.915676c-2.2 0-4.7-.2-7.6-.7-2.8-.5-5.1-.8-7.1-1-6.9-.9-10.3-1.3-15.6-5.9-7-6-6.8-13-5.8-21.6.3-2.3.8-5.9 1.7-11.2 3.1 1.4 6.1 2.2 8.7 2.2 3.1 0 5.4-1.2 6.6-3.4 1.6 1.9 6.9 7.3 13.3 7.3 1 0 1.9-.1 2.8-.4 3.5-1 19.8-2.1 46.9-3.4l-1.7 11.7.4.1s3.8 1 10.6 2.9c6.1 1.7 7.9 5.6 6.3 13.8-1.3 6.6-6.2 7.3-8.2 7.3-1.1 0-2.2-.2-3.3-.5-4.2-1.4-18.6-6.8-18.7-6.9h-.1l-17.3 1.2-.1.4c-.7 5.4-4.5 8.1-11.8 8.1z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M41.184191 103.415676c-3.8-1.4-6-1.4-7.7-1.4-1.8 0-4.6 3.3 1.4 5.4 6 2.1 10.3 3.4 10.3 3.4s1.8-2.1 3.5-2.9c1.6-.8 2.3-.9 2.3-.9l-9.8-3.6z" style="fill:#56606b;fill-opacity:1"/><path d="M27.584191 38.615676c1.2-5-2.1-8.2-5.7-9.2-3.5-1-8.4-1.7-13.9 6.9s-9.5 16.5-6.4 20.6c3.1 4.1 9.3 3.4 11.8-.8 0 0 5.7 3.8 9.5-4.2" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M10.884191 45.115676c-1.6 2.5-.8 5 2 5.9 2.7 1 5-1.5 6.5-3.8 1.6-2.3 3.6-5.9 3.6-5.9s-3.7 1.2-5.6-.2c-2-1.4-1.5-3.8-1.5-3.8l-5 7.8z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M22.684191 41.415676c-2.6 1.1-6.8.6-6.9-4.1 0 0-5.1 7.6-5.9 9.6" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M67.584191 5.215676c0-3.4-3.9-2.6-3.9-2.6 0-1.2-2-3.5-8.5-.6-6.4 2.9-7.3 6-7.3 6-3.8-1.7-9.6-2.6-13.5.8-3.9 3.4-4.3 10 2.3 13.5 0 0 2.9.9 7.7-.4 4.8-1.3 7.7-3.3 7.7-3.3s3.7 2.3 9 .6c5.3-1.7 9.9-4.5 10.3-10.1.5-5.7-3.8-3.9-3.8-3.9z" style="fill:#56606b;fill-opacity:1"/><path d="M67.084191 16.315676c-1-5.5-7-3-7-3 .5-2.1-3-4.1-5.5-2.7-2.5 1.4-6.6-.1-6.6-.1-6.4-4.4-14.3-2.1-16.1 2-1.1 3.3.2 7.3 4.8 9.7 0 0 2.9.9 7.7-.4 4.8-1.3 7.7-3.3 7.7-3.3s3.7 2.3 9 .6c2.3-.6 4.3-1.5 6-2.8 0 .1 0 0 0 0z" style="fill:#3a434e;fill-opacity:1"/><path d="M36.684191 22.715676c-.1 0-.2 0-.2-.1-3.1-1.6-5-4.1-5.4-7-.3-2.7.8-5.4 3-7.3 3.9-3.3 9.5-2.8 13.6-1.1.5-1.1 2.2-3.5 7.3-5.8 4.5-2 6.9-1.5 8-.8.6.4.9.9 1.1 1.3.7-.1 2-.1 3 .7.5.4.9 1 1 1.8.7-.1 1.8-.2 2.7.4 1 .7 1.4 2.1 1.2 4.1-.4 5-3.9 8.5-10.7 10.6-5.5 1.7-9.3-.6-9.4-.7-.2-.1-.3-.5-.2-.7.1-.2.5-.3.7-.2 0 0 3.6 2.2 8.6.6 6.3-2 9.6-5.1 10-9.7.1-1.6-.2-2.8-.8-3.3-.9-.7-2.3-.1-2.3-.1-.2.1-.3 0-.5 0-.1-.1-.2-.2-.2-.4 0-.8-.2-1.3-.6-1.7-.9-.8-2.6-.4-2.7-.4-.1 0-.3 0-.4-.1-.1-.1-.2-.2-.2-.4 0-.3-.2-.7-.7-1-.6-.4-2.6-1.1-7.1.8-6.1 2.7-7 5.6-7 5.7 0 .1-.1.3-.3.3-.1.1-.3.1-.4 0-3.9-1.7-9.3-2.4-13 .8-1.9 1.7-2.9 4.1-2.6 6.4.3 2.5 2 4.7 4.8 6.2.2.1.3.4.2.7-.1.3-.3.4-.5.4z"/><path d="M40.584191 84.115676s6.3 16.8 7.1 19.3c.8 2.5 1.8 3.4 7.3 3 5.5-.4 6.7-21.5 6.7-21.5l-21.1-.8z" style="fill:#191b22;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M51.084191 103.415676c-1.7-2.1-1.9-4.2-1.9-4.2l2-10.9 3-1.4-3.1 16.5zm33.9-35.3l-23.9 1.9 1.2 8 25.2 1.1 4.6-9c-2.3-.3-4.7-.9-7.1-2z" class="st9" style="fill:#191b22;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M28.484191 82.915676c7.2 9.4 12.7 11.4 21.8 7.7 8.5-3.4 15.4-9 15.1-15-.3-6-2.1-10.3-9.1-9.8-2.3.2-6.8 2.8-9.6 4.4-1.8 1-4.2 2.2-6 .4-1.8-1.8-4.3-4.4-4.3-4.4" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M49.284191 80.515676c-.3-.2-.5-.4-.7-.6-1.2.7-2.3 1.3-2.8 1.6-2 1.2-3.8.5-4.7-.3-.9-.9-2.3-2.4-5.5-1.5-3.7 1-4.5 5.7-2.5 8.4 6.5 6.1 12.8 4.1 15.2 3.3 2.4-.8 6.3-2.7 6.3-2.7l.6-6c-2-.8-4.1-.9-5.9-2.2z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M28.484191 82.915676c7.2 9.4 12.7 11.4 21.8 7.7 8.5-3.4 15.4-9 15.1-15-.3-6-2.1-10.3-9.1-9.8-2.3.2-6.8 2.8-9.6 4.4-1.8 1-4.2 2.2-6 .4-1.8-1.8-4.3-4.4-4.3-4.4m35.4-8.6c6.5 8.3 15.5 12.5 21.8 12.7" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M53.184191 104.415676c-1.6.1-2.7-1.1-2.4-2.7l4.9-25.9c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6.5 3.7.7 4.6.2.9.3 3 .1 4.6l-2.2 21.3c-.2 1.6-1.7 3.1-3.3 3.3l-45.6 4z" style="fill:#191b22;fill-opacity:1"/><path d="M53.184191 104.415676c-1.6.1-2.7-1.1-2.4-2.7l4.9-25.9c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6.5 3.7.7 4.6.2.9.3 3 .1 4.6l-2.2 21.3c-.2 1.6-1.7 3.1-3.3 3.3l-45.6 4z" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M55.684191 105.915676c-1.6.1-2.3-.4-2-2l4.4-25.6c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6 1.3 2.9 2.5 2.8 1.2-.1 1.9 1.2 1.6 2.8l-5.2 24.9c-.3 1.6-2 3.1-3.6 3.2l-45.5 3.1z" style="fill:#191b22;fill-opacity:1"/><path d="M53.184191 104.315676l4.9-26c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6 1.3 2.9 2.5 2.8 1.2-.1 1.9 1.2 1.6 2.8l-5.2 24.9c-.3 1.6-2 3.1-3.6 3.2l-46.7 3.7m9.2-103.9c-.3 2.9-2.9 4.9-4.1 5.8m8-3.2c-.7 3.5-4 6-4 6" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M39.884191 53.615676c-2.3-2.2-4.2-2.5-6.6-.6-2.4 1.9-2.1 4.8.9 7.6 3.1 2.9 4.7 4.1 6.7 5 2 .9 5.4 2.5 10.5-2s10.2-11.1 9.4-14.7c-.8-3.6-4.1-1.8-6.8 1.2s-3.7 4-5.4 5.2c-1.7 1.2-3.8 3-8.7-1.7z" class="st0" style="fill:#93a1b5;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M44.384191 61.315676c-2.3 0-4.7-1.2-6.6-3.1-.9-.9-1.1-2-.7-2.9.3-.8 1.1-1.3 1.8-1.3.2 0 .5 0 .7.1 2.3 2.1 4.2 3.2 5.9 3.2 1.6 0 2.7-.8 3.5-1.4 1.7-1.3 2.8-2.3 5.5-5.3.9-1.1 1.9-1.9 2.7-2.4.3.2.6.4.7.8.2.8-.2 2-.7 2.7-.9 1.3-4.9 5.4-9 8.4-1.1.7-2.4 1.2-3.8 1.2z" style="fill:#b3bfcd;fill-opacity:1"/><path d="M45.784191 50.115676c-.7 0-1.4-.6-1.4-1.4v-6.1c0-.7.6-1.4 1.4-1.4.7 0 1.4.6 1.4 1.4v6.1c0 .8-.6 1.4-1.4 1.4z"/><path d="M61.184191 118.215676c.7-7.1-3.5-10.6-9.2-11.1-5.7-.5-10.2 6.8-9.1 13.1 1.1 6.3 6.7 7.2 6.7 7.2" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M52.084191 107.515676c-2.2-.7-4.3-1.4-6.5-2.2-2.1-.8-4.3-1.5-6.4-2.3-.1 0-.2-.2-.1-.3 0-.1.2-.2.3-.2 2.2.6 4.4 1.3 6.5 1.9 2.2.7 4.3 1.3 6.5 2 .3.1.4.4.3.6-.1.4-.4.6-.6.5zm25.4 10.1c-.2-1.4-.2-2.9.1-4.2.2-1.4.6-2.7 1.1-4-.3 1.3-.5 2.7-.6 4.1 0 1.4.1 2.7.4 4 .1.3-.1.5-.3.6-.2.1-.6-.1-.7-.5 0 .1 0 .1 0 0z"/><path d="M104.284191 103.615676c-3.6-.7-8.5 2.1-9.5 9.7s2.1 10.7 5.3 11.6m15.1-83.5l-.39999 1.4m-1.90001-1.2c2.4 1.7 6.4 3.4 6.4 3.4m-1.6-2.6l-.60001 1.59999" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M47.484191 79.215676c3.2 2.7 7 3.3 7 3.3" style="fill:none;stroke:#000;stroke-miterlimit:10"/><path d="M69.284191 24.315676c-3.5.4-2.7 2.9-1.2 3.5 1.5.6 3.7.1 4.3-1.6.4-1.6-1.3-2.1-3.1-1.9z" style="fill:#4f5862;fill-opacity:1"/></svg>
\ No newline at end of file
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/logo_warn_glitch.svg b/app/javascript/flavours/glitch/images/logo_warn_glitch.svg
new file mode 100644
index 000000000..32c5854ee
--- /dev/null
+++ b/app/javascript/flavours/glitch/images/logo_warn_glitch.svg
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 216.41507 232.00976"
+   version="1.1"
+   id="svg6"
+   sodipodi:docname="logo_warn_glitch.svg"
+   inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs10" />
+  <sodipodi:namedview
+     id="namedview8"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     showgrid="false"
+     inkscape:zoom="1.7951831"
+     inkscape:cx="-30.916067"
+     inkscape:cy="90.241493"
+     inkscape:window-width="1920"
+     inkscape:window-height="1011"
+     inkscape:window-x="0"
+     inkscape:window-y="32"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg6" />
+  <g
+     id="g2025">
+    <path
+       d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915"
+       fill="#3088d4"
+       id="path2" />
+    <path
+       d="m 124.52893,137.75645 c 0,9.01375 -7.30875,16.32125 -16.3225,16.32125 -9.01375,0 -16.32125,-7.3075 -16.32125,-16.32125 0,-9.01375 7.3075,-16.3225 16.32125,-16.3225 9.01375,0 16.3225,7.30875 16.3225,16.3225"
+       fill="#ffffff"
+       id="path4"
+       sodipodi:nodetypes="csssc" />
+    <path
+       id="path1121"
+       d="m 108.20703,25.453125 c -9.013749,0 -16.322264,7.308516 -16.322264,16.322266 0,5.31808 2.555126,37.386806 6.492187,67.763669 4.100497,4.20028 15.890147,3.77063 19.660157,-0.01 3.9367,-30.375272 6.49219,-62.4364 6.49219,-67.753909 0,-9.01375 -7.30852,-16.322266 -16.32227,-16.322266 z"
+       style="fill:#ffffff"
+       sodipodi:nodetypes="ssccsss" />
+  </g>
+</svg>
diff --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/initial_state.js b/app/javascript/flavours/glitch/initial_state.js
new file mode 100644
index 000000000..03be4ff6c
--- /dev/null
+++ b/app/javascript/flavours/glitch/initial_state.js
@@ -0,0 +1,153 @@
+// @ts-check
+
+/**
+ * @typedef Emoji
+ * @property {string} shortcode
+ * @property {string} static_url
+ * @property {string} url
+ */
+
+/**
+ * @typedef AccountField
+ * @property {string} name
+ * @property {string} value
+ * @property {string} verified_at
+ */
+
+/**
+ * @typedef Account
+ * @property {string} acct
+ * @property {string} avatar
+ * @property {string} avatar_static
+ * @property {boolean} bot
+ * @property {string} created_at
+ * @property {boolean=} discoverable
+ * @property {string} display_name
+ * @property {Emoji[]} emojis
+ * @property {AccountField[]} fields
+ * @property {number} followers_count
+ * @property {number} following_count
+ * @property {boolean} group
+ * @property {string} header
+ * @property {string} header_static
+ * @property {string} id
+ * @property {string=} last_status_at
+ * @property {boolean} locked
+ * @property {string} note
+ * @property {number} statuses_count
+ * @property {string} url
+ * @property {string} username
+ */
+
+/**
+ * @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
+ */
+
+/**
+ * @typedef InitialStateMeta
+ * @property {string} access_token
+ * @property {boolean=} advanced_layout
+ * @property {boolean} auto_play_gif
+ * @property {boolean} activity_api_enabled
+ * @property {string} admin
+ * @property {boolean=} boost_modal
+ * @property {boolean} crop_images
+ * @property {boolean=} delete_modal
+ * @property {boolean=} disable_swiping
+ * @property {string=} disabled_account_id
+ * @property {boolean} display_media
+ * @property {string} domain
+ * @property {boolean=} expand_spoilers
+ * @property {boolean} limited_federation_mode
+ * @property {string} locale
+ * @property {string | null} mascot
+ * @property {string=} me
+ * @property {string=} moved_to_account_id
+ * @property {string=} owner
+ * @property {boolean} profile_directory
+ * @property {boolean} registrations_open
+ * @property {boolean} reduce_motion
+ * @property {string} repository
+ * @property {boolean} search_enabled
+ * @property {boolean} single_user_mode
+ * @property {string} source_url
+ * @property {string} streaming_api_base_url
+ * @property {boolean} timeline_preview
+ * @property {string} title
+ * @property {boolean} trends
+ * @property {boolean} trends_as_landing_page
+ * @property {boolean} unfollow_modal
+ * @property {boolean} use_blurhash
+ * @property {boolean=} use_pending_items
+ * @property {string} version
+ * @property {boolean} translation_enabled
+ * @property {object} local_settings
+ */
+
+/**
+ * @typedef InitialState
+ * @property {Record<string, Account>} accounts
+ * @property {InitialStateLanguage[]} languages
+ * @property {InitialStateMeta} meta
+ */
+
+const element = document.getElementById('initial-state');
+/** @type {InitialState | undefined} */
+const initialState = element?.textContent && JSON.parse(element.textContent);
+
+// Glitch-soc-specific “local settings”
+try {
+  initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
+} catch (e) {
+  initialState.local_settings = {};
+}
+
+/**
+ * @template {keyof InitialStateMeta} K
+ * @param {K} prop
+ * @returns {InitialStateMeta[K] | undefined}
+ */
+const getMeta = (prop) => initialState?.meta && initialState.meta[prop];
+
+export const activityApiEnabled = getMeta('activity_api_enabled');
+export const autoPlayGif = getMeta('auto_play_gif');
+export const boostModal = getMeta('boost_modal');
+export const cropImages = getMeta('crop_images');
+export const deleteModal = getMeta('delete_modal');
+export const disableSwiping = getMeta('disable_swiping');
+export const disabledAccountId = getMeta('disabled_account_id');
+export const displayMedia = getMeta('display_media');
+export const domain = getMeta('domain');
+export const expandSpoilers = getMeta('expand_spoilers');
+export const forceSingleColumn = !getMeta('advanced_layout');
+export const limitedFederationMode = getMeta('limited_federation_mode');
+export const mascot = getMeta('mascot');
+export const me = getMeta('me');
+export const movedToAccountId = getMeta('moved_to_account_id');
+export const owner = getMeta('owner');
+export const profile_directory = getMeta('profile_directory');
+export const reduceMotion = getMeta('reduce_motion');
+export const registrationsOpen = getMeta('registrations_open');
+export const repository = getMeta('repository');
+export const searchEnabled = getMeta('search_enabled');
+export const showTrends = getMeta('trends');
+export const singleUserMode = getMeta('single_user_mode');
+export const source_url = getMeta('source_url');
+export const timelinePreview = getMeta('timeline_preview');
+export const title = getMeta('title');
+export const trendsAsLanding = getMeta('trends_as_landing_page');
+export const unfollowModal = getMeta('unfollow_modal');
+export const useBlurhash = getMeta('use_blurhash');
+export const usePendingItems = getMeta('use_pending_items');
+export const version = getMeta('version');
+export const languages = initialState?.languages;
+export const statusPageUrl = getMeta('status_page_url');
+
+// Glitch-soc-specific settings
+export const maxChars = (initialState && initialState.max_toot_chars) || 500;
+export const favouriteModal = getMeta('favourite_modal');
+export const pollLimits = (initialState && initialState.poll_limits);
+export const defaultContentType = getMeta('default_content_type');
+export const useSystemEmojiFont = getMeta('system_emoji_font');
+
+export default initialState;
diff --git a/app/javascript/flavours/glitch/is_mobile.js b/app/javascript/flavours/glitch/is_mobile.js
new file mode 100644
index 000000000..31944d89b
--- /dev/null
+++ b/app/javascript/flavours/glitch/is_mobile.js
@@ -0,0 +1,55 @@
+// @ts-check
+
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { forceSingleColumn } from 'flavours/glitch/initial_state';
+
+const LAYOUT_BREAKPOINT = 630;
+
+/**
+ * @param {number} width
+ * @returns {boolean}
+ */
+export const isMobile = width => width <= LAYOUT_BREAKPOINT;
+
+/**
+ * @param {string} layout_local_setting
+ * @returns {string}
+ */
+export const layoutFromWindow = (layout_local_setting) => {
+  switch (layout_local_setting) {
+  case 'multiple':
+    return 'multi-column';
+  case 'single':
+    if (isMobile(window.innerWidth)) {
+      return 'mobile';
+    } else {
+      return 'single-column';
+    }
+  default:
+    if (isMobile(window.innerWidth)) {
+      return 'mobile';
+    } else if (forceSingleColumn) {
+      return 'single-column';
+    } else {
+      return 'multi-column';
+    }
+  }
+};
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+let userTouching = false;
+
+const touchListener = () => {
+  userTouching = true;
+
+  window.removeEventListener('touchstart', touchListener, listenerOptions);
+};
+
+window.addEventListener('touchstart', touchListener, listenerOptions);
+
+export const isUserTouching = () => userTouching;
+
+export const isIOS = () => iOS;
diff --git a/app/javascript/flavours/glitch/load_keyboard_extensions.js b/app/javascript/flavours/glitch/load_keyboard_extensions.js
new file mode 100644
index 000000000..2dd0e45fa
--- /dev/null
+++ b/app/javascript/flavours/glitch/load_keyboard_extensions.js
@@ -0,0 +1,16 @@
+// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install
+// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks
+// can at least log in using KaiOS devices).
+
+function importArrowKeyNavigation() {
+  return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation');
+}
+
+export default function loadKeyboardExtensions() {
+  if (/KAIOS/.test(navigator.userAgent)) {
+    return importArrowKeyNavigation().then(arrowKeyNav => {
+      arrowKeyNav.register();
+    });
+  }
+  return Promise.resolve();
+}
diff --git a/app/javascript/flavours/glitch/load_polyfills.js b/app/javascript/flavours/glitch/load_polyfills.js
new file mode 100644
index 000000000..b2c41303a
--- /dev/null
+++ b/app/javascript/flavours/glitch/load_polyfills.js
@@ -0,0 +1,41 @@
+// 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 = !(
+    Array.prototype.includes &&
+    HTMLCanvasElement.prototype.toBlob &&
+    window.Intl &&
+    Object.assign &&
+    Object.values &&
+    window.Symbol &&
+    Promise.prototype.finally
+  );
+
+  // Latest version of Firefox and Safari do not have IntersectionObserver.
+  // Edge does not have requestIdleCallback.
+  // This avoids shipping them all the polyfills.
+  const needsExtraPolyfills = !(
+    window.AbortController &&
+    window.IntersectionObserver &&
+    window.IntersectionObserverEntry &&
+    'isIntersecting' in IntersectionObserverEntry.prototype &&
+    window.requestIdleCallback
+  );
+
+  return Promise.all([
+    needsBasePolyfills && importBasePolyfills(),
+    needsExtraPolyfills && importExtraPolyfills(),
+  ]);
+}
+
+export default loadPolyfills;
diff --git a/app/javascript/flavours/glitch/locales/af.json b/app/javascript/flavours/glitch/locales/af.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/af.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/an.json b/app/javascript/flavours/glitch/locales/an.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/an.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/ar.json b/app/javascript/flavours/glitch/locales/ar.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ar.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ast.json b/app/javascript/flavours/glitch/locales/ast.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ast.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/be.json b/app/javascript/flavours/glitch/locales/be.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/be.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/bg.json b/app/javascript/flavours/glitch/locales/bg.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/bg.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/bn.json b/app/javascript/flavours/glitch/locales/bn.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/bn.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/br.json b/app/javascript/flavours/glitch/locales/br.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/br.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/bs.json b/app/javascript/flavours/glitch/locales/bs.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/bs.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/ca.json b/app/javascript/flavours/glitch/locales/ca.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ca.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ckb.json b/app/javascript/flavours/glitch/locales/ckb.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ckb.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/co.json b/app/javascript/flavours/glitch/locales/co.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/co.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/cs.json b/app/javascript/flavours/glitch/locales/cs.json
new file mode 100644
index 000000000..a3d1c3b9c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/cs.json
@@ -0,0 +1,152 @@
+{
+  "about.fork_disclaimer": "Glitch-soc je svobodný software s otevřeným zdrojovým kódem založený na Mastodonu.",
+  "advanced_options.icon_title": "Pokročilá nastavení",
+  "advanced_options.local-only.long": "Neposílat na jiné servery",
+  "advanced_options.local-only.short": "Lokální příspěvek",
+  "advanced_options.local-only.tooltip": "Tento příspěvek je pouze lokální",
+  "advanced_options.threaded_mode.long": "Po odeslání automaticky otevře pole pro odpověď",
+  "advanced_options.threaded_mode.short": "Režim vlákna",
+  "advanced_options.threaded_mode.tooltip": "Režim vlákna je zapnutý",
+  "boost_modal.missing_description": "Příspěvek obsahuje obrázky bez popisků",
+  "column.subheading": "Různé",
+  "compose.attach": "Připojit...",
+  "compose.attach.doodle": "Něco namalovat",
+  "compose.attach.upload": "Nahrát soubor",
+  "compose.content-type.plain": "Prostý text",
+  "compose_form.poll.multiple_choices": "Povolit více odpovědí",
+  "compose_form.poll.single_choice": "Povolit jednu odpověď",
+  "compose_form.spoiler": "Přidat varování o obsahu",
+  "confirmation_modal.do_not_ask_again": "Příště se už neptat",
+  "content-type.change": "Formát příspěvku",
+  "direct.group_by_conversations": "Seskupit do konverzací",
+  "endorsed_accounts_editor.endorsed_accounts": "Vybrané účty",
+  "favourite_modal.combo": "Příště můžete pro přeskočení stisknout {combo}",
+  "getting_started.onboarding": "Ukaž mi to tu",
+  "home.column_settings.advanced": "Pokročilé",
+  "home.column_settings.filter_regex": "Filtrovat podle regulárních výrazů",
+  "home.column_settings.show_direct": "Zobrazit přímé zprávy",
+  "keyboard_shortcuts.bookmark": "Přidat do záložek",
+  "keyboard_shortcuts.secondary_toot": "Odeslat příspěvek s druhotným nastavením soukromí",
+  "keyboard_shortcuts.toggle_collapse": "Sbalit/rozbalit příspěvek",
+  "layout.auto": "Automatické",
+  "layout.hint.auto": "Vybrat rozložení automaticky v závislosti na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.",
+  "layout.hint.desktop": "Použít vícesloupcové rozložení nezávisle na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.",
+  "layout.hint.single": "Použít jednosloupcové rozložení nezávisle na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.",
+  "media_gallery.sensitive": "Citlivý obsah",
+  "navigation_bar.app_settings": "Nastavení aplikace",
+  "navigation_bar.featured_users": "Vybraní uživatelé",
+  "navigation_bar.keyboard_shortcuts": "Klávesové zkratky",
+  "navigation_bar.misc": "Různé",
+  "notification.markForDeletion": "Označit pro smazání",
+  "notification_purge.btn_all": "Vybrat\nvše",
+  "notification_purge.btn_apply": "Smazat\nvybrané",
+  "notification_purge.btn_invert": "Obrátit\nvýběr",
+  "notification_purge.btn_none": "Nevybrat\nnic",
+  "notification_purge.start": "Čistící režim",
+  "notifications.marked_clear": "Smazat vybraná oznámení",
+  "notifications.marked_clear_confirmation": "Určitě chcete trvale smazat všechna vybraná oznámení?",
+  "onboarding.done": "Hotovo",
+  "onboarding.next": "Další",
+  "onboarding.page_five.public_timelines": "Místní časová osa zobrazuje veřejné příspěvky všech uživatelů instance {domain}. Federovaná časová osa zobrazí příspěvky od všech, koho uživatelé instance {domain} sledují. Tyto veřejné časové osy jsou skvělý způsob, jak objevit nové lidi.",
+  "onboarding.page_four.home": "Domovská časová osa zobrazuje příspěvky od lidí, které sledujete.",
+  "onboarding.page_four.notifications": "Notifikace se zobrazí, když s vámi někdo interaguje.",
+  "onboarding.page_one.federation": "{domain} je 'instance' Mastodonu. Mastodon je síť nezávislých serverů, které jsou spolu propojené do jedné velké sociální sítě. Těmto serverům říkáme instance.",
+  "onboarding.page_one.handle": "Jste na instanci {domain}, takže celá adresa vašeho profilu je {handle}",
+  "onboarding.page_one.welcome": "Vítá vás {domain}!",
+  "onboarding.page_six.almost_done": "Skoro hotovo...",
+  "onboarding.page_six.appetoot": "Veselé mastodonění!",
+  "onboarding.page_six.apps_available": "Jsou dostupné {apps} pro iOS, Android i jiné platformy.",
+  "onboarding.page_six.github": "Na serveru {domain} běží Glitchsoc. Glitchsoc je přátelský {fork} programu {Mastodon}, a je kompatibilní s jakoukoliv jinou mastodoní instancí nebo aplikací. Glitchsoc je zcela svobodný a má otevřený zdrojový kód. Na stránce {github} můžete hlásit chyby, žádat o nové funkce, nebo ke kódu vlastnoručně přispět.",
+  "onboarding.page_six.various_app": "mobilní aplikace",
+  "onboarding.page_three.profile": "Upravte si svůj profil a nastavte si profilový obrázek, jméno, a krátký text o sobě. Naleznete tam i další možnosti nastavení.",
+  "onboarding.page_three.search": "Pomocí vyhledávací lišty můžete hledat lidi nebo hashtagy. Pokud hledáte někoho z jiné instance, musíte použít celou adresu jeho profilu.",
+  "onboarding.page_two.compose": "Příspěvky se píší v levém sloupci. Pomocí ikon pod příspěvkem k němu můžete připojit obrázky, změnit úroveň soukromí nebo přidat varování o obsahu.",
+  "onboarding.skip": "Přeskočit",
+  "settings.always_show_spoilers_field": "Vždy zobrazit pole pro varování o obsahu",
+  "settings.auto_collapse": "Automaticky sbalit",
+  "settings.auto_collapse_all": "Všechno",
+  "settings.auto_collapse_lengthy": "Dlouhé příspěvky",
+  "settings.auto_collapse_media": "Příspěvky s přílohami",
+  "settings.auto_collapse_notifications": "Oznámení",
+  "settings.auto_collapse_reblogs": "Boosty",
+  "settings.auto_collapse_replies": "Odpovědi",
+  "settings.close": "Zavřít",
+  "settings.collapsed_statuses": "Sbalené příspěvky",
+  "settings.compose_box_opts": "Editační pole",
+  "settings.confirm_before_clearing_draft": "Zobrazit potvrzovací dialog před přepsáním právě vytvářené zprávy",
+  "settings.confirm_boost_missing_media_description": "Zobrazit potvrzovací dialog před boostnutím příspěvku s chybějícími popisky obrázků",
+  "settings.confirm_missing_media_description": "Zobrazit potvrzovací dialog při odesílání příspěvku, ve kterém chybí popisky obrázků",
+  "settings.content_warnings": "Varování o obsahu",
+  "settings.content_warnings.regexp": "Regulární výraz",
+  "settings.content_warnings_filter": "Tato varování o obsahu automaticky nerozbalovat:",
+  "settings.content_warnings_media_outside": "Zobrazit obrázky a videa mimo varování o obsahu",
+  "settings.content_warnings_media_outside_hint": "Obrázky a videa z příspěvku s varováním o obsahu se zobrazí se separátním přepínačem zobrazení, stejně jako na běžném Mastodonu.",
+  "settings.content_warnings_shared_state": "Zobrazit/schovat všechny kopie naráz",
+  "settings.content_warnings_shared_state_hint": "Tlačítko varování o obsahu bude mít efekt na všechny kopie příspěvku naráz, stejně jako na běžném Mastodonu. Nebude pak možné automaticky sbalit jakoukoliv kopii příspěvku, která má rozbalené varování o obsahu",
+  "settings.content_warnings_unfold_opts": "Možnosti automatického rozbalení",
+  "settings.deprecated_setting": "Tato možnost se nyní nastavuje v {settings_page_link}",
+  "settings.enable_collapsed": "Povolit sbalené příspěvky",
+  "settings.enable_collapsed_hint": "U sbalených příspěvků je část jejich obsahu skrytá, aby zabraly méně místa na obrazovce. (Tohle není stejná funkce jako varování o obsahu.)",
+  "settings.enable_content_warnings_auto_unfold": "Vždy rozbalit příspěvky označené varováním o obsahu",
+  "settings.general": "Obecné",
+  "settings.hicolor_privacy_icons": "Barevné ikony soukromí",
+  "settings.hicolor_privacy_icons.hint": "Zobrazit ikony úrovně soukromí příspěvků v jasných, snadno rozlišitelných barvách",
+  "settings.image_backgrounds": "Obrázkové pozadí",
+  "settings.image_backgrounds_media": "Náhled médií ve sbalených příspěvcích",
+  "settings.image_backgrounds_media_hint": "Pokud jsou k příspěvku přiložena média, použije se první z nich jako pozadí",
+  "settings.image_backgrounds_users": "Nastavit sbaleným příspěvkům obrázkové pozadí",
+  "settings.inline_preview_cards": "Zobrazit v časové ose náhledy externích odkazů",
+  "settings.layout": "Rozložení:",
+  "settings.layout_opts": "Možnosti rozvržení",
+  "settings.media": "Média",
+  "settings.media_fullwidth": "Zobrazit náhledy v plné šířce",
+  "settings.media_letterbox": "Neořezávat obrázky",
+  "settings.media_letterbox_hint": "Místo výřezu obrázku zobrazit obrázek celý, doplněný podle potřeby o prázdné okraje",
+  "settings.media_reveal_behind_cw": "Automaticky zobrazit média označená varováním o obsahu",
+  "settings.notifications.favicon_badge": "Zobrazit počet na ikoně serveru",
+  "settings.notifications.favicon_badge.hint": "Zobrazí počet nepřečtených oznámení na ikoně serveru",
+  "settings.notifications.tab_badge": "Zobrazit počet nepřečtených oznámení",
+  "settings.notifications.tab_badge.hint": "Počet nepřečtených oznámení se viditelně zobrazí na hlavní stránce (pokud není seznam oznámení viditelný)",
+  "settings.notifications_opts": "Možnosti oznámení",
+  "settings.pop_in_left": "Vlevo",
+  "settings.pop_in_player": "Povolit plovoucí okno přehrávače",
+  "settings.pop_in_position": "Pozice plovoucího okna:",
+  "settings.pop_in_right": "Vpravo",
+  "settings.preferences": "Předvolby",
+  "settings.prepend_cw_re": "Při odpovídání přidat před varování o obsahu “re: ”",
+  "settings.preselect_on_reply": "Při odpovědi označit uživatelská jména",
+  "settings.preselect_on_reply_hint": "Při odpovídání na konverzaci s více účastníky se jména všech kromě prvního označí, aby šla jednoduše smazat",
+  "settings.rewrite_mentions": "Přepsat zmínky v zobrazených příspěvcích",
+  "settings.rewrite_mentions_acct": "Přepsat uživatelským jménem a doménou (pokud je účet na jiném serveru)",
+  "settings.rewrite_mentions_no": "Nepřepisovat zmínky",
+  "settings.rewrite_mentions_username": "Přepsat uživatelským jménem",
+  "settings.shared_settings_link": "předvolbách Mastodonu",
+  "settings.show_action_bar": "Zobrazit ve sbalených příspěvcích tlačítka s akcemi",
+  "settings.show_content_type_choice": "Zobrazit volbu formátu příspěvku",
+  "settings.show_reply_counter": "Zobrazit odhad počtu odpovědí",
+  "settings.side_arm": "Vedlejší odesílací tlačítko:",
+  "settings.side_arm.none": "Žádné",
+  "settings.side_arm_reply_mode": "Při odpovídání na příspěvek by vedlejší odesílací tlačítko mělo:",
+  "settings.side_arm_reply_mode.copy": "Použít úroveň soukromí příspěvku, na který odpovídáte",
+  "settings.side_arm_reply_mode.keep": "Použít svou nastavenou úroveň soukromí",
+  "settings.side_arm_reply_mode.restrict": "Zvýšit úroveň soukromí nejméně na úroveň příspěvku, na který odpovídáte",
+  "settings.status_icons": "Ikony u příspěvků",
+  "settings.status_icons_language": "Indikace jazyk",
+  "settings.status_icons_local_only": "Indikace lokálního příspěvku",
+  "settings.status_icons_media": "Indikace obrázků a anket",
+  "settings.status_icons_reply": "Indikace odpovědi",
+  "settings.status_icons_visibility": "Indikace úrovně soukromí",
+  "settings.tag_misleading_links": "Označit zavádějící odkazy",
+  "settings.tag_misleading_links.hint": "Zobrazit skutečný cíl u každého odkazu, který ho explicitně nezmiňuje",
+  "settings.wide_view": "Široké sloupce (pouze v režimu Desktop)",
+  "settings.wide_view_hint": "Sloupce se roztáhnout, aby lépe vyplnily dostupný prostor.",
+  "status.collapse": "Sbalit",
+  "status.has_audio": "Obsahuje audio",
+  "status.has_pictures": "Obsahuje obrázky",
+  "status.has_preview_card": "Obsahuje náhled odkazu",
+  "status.has_video": "Obsahuje video",
+  "status.in_reply_to": "Tento příspěvek je odpověď",
+  "status.is_poll": "Tento příspěvek je anketa",
+  "status.local_only": "Viditelné pouze z vaší instance",
+  "status.uncollapse": "Rozbalit"
+}
diff --git a/app/javascript/flavours/glitch/locales/cy.json b/app/javascript/flavours/glitch/locales/cy.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/cy.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/da.json b/app/javascript/flavours/glitch/locales/da.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/da.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/de.json b/app/javascript/flavours/glitch/locales/de.json
new file mode 100644
index 000000000..41f7010fc
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/de.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "Glitch-soc ist freie, quelloffene Software geforkt von Mastodon.",
+  "account.add_account_note": "Notiz für @{name} hinzufügen",
+  "account.disclaimer_full": "Die folgenden Informationen könnten das Profil des Nutzers unvollständig wiedergeben.",
+  "account.follows": "Folgt",
+  "account.joined": "Beigetreten am {date}",
+  "account.suspended_disclaimer_full": "Dieser Nutzer wurde durch einen Moderator gesperrt.",
+  "account.view_full_profile": "Vollständiges Profil anzeigen",
+  "account_note.cancel": "Abbrechen",
+  "account_note.edit": "Bearbeiten",
+  "account_note.glitch_placeholder": "Kein Kommentar angegeben",
+  "account_note.save": "Speichern",
+  "advanced_options.icon_title": "Erweiterte Optionen",
+  "advanced_options.local-only.long": "Nicht auf anderen Instanzen posten",
+  "advanced_options.local-only.short": "Nur lokal",
+  "advanced_options.local-only.tooltip": "Dieser Post ist nur lokal",
+  "advanced_options.threaded_mode.long": "Öffnet automatisch eine Antwort beim Schreiben",
+  "advanced_options.threaded_mode.short": "Thread-Modus",
+  "advanced_options.threaded_mode.tooltip": "Thread-Modus aktiviert",
+  "boost_modal.missing_description": "Dieser Toot enthält Medien ohne Beschreibung",
+  "column.favourited_by": "Favorisiert von",
+  "column.heading": "Sonstiges",
+  "column.reblogged_by": "Geteilt von",
+  "column.subheading": "Sonstige Optionen",
+  "column_header.profile": "Profil",
+  "column_subheading.lists": "Listen",
+  "column_subheading.navigation": "Navigation",
+  "community.column_settings.allow_local_only": "Nur-lokale Toots anzeigen",
+  "compose.attach": "Anhängen...",
+  "compose.attach.doodle": "Etwas zeichnen",
+  "compose.attach.upload": "Eine Datei hochladen",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Unformatierter Text",
+  "compose_form.poll.multiple_choices": "Mehrfachauswahl erlauben",
+  "compose_form.poll.single_choice": "Eine Auswahl erlauben",
+  "compose_form.spoiler": "Text hinter Warnung verbergen",
+  "confirmation_modal.do_not_ask_again": "Nicht erneut nach Bestätigung fragen",
+  "confirmations.deprecated_settings.confirm": "Mastodon-Einstellungen verwenden",
+  "confirmations.deprecated_settings.message": "Einige der von dir verwendeten, glitch-soc-spezifischen {app_settings} wurden durch Mastodon {preferences} ersetzt und werden überschrieben:",
+  "confirmations.missing_media_description.confirm": "Trotzdem absenden",
+  "confirmations.missing_media_description.edit": "Anhänge bearbeiten",
+  "confirmations.missing_media_description.message": "Mindestens einem Anhang fehlt eine Beschreibung. Denke darüber nach, alle Anhänge für Sehbeeinträchtigte zu beschreiben, bevor du den Toot absendest.",
+  "confirmations.unfilter.author": "Urheber",
+  "confirmations.unfilter.confirm": "Anzeigen",
+  "confirmations.unfilter.edit_filter": "Filter bearbeiten",
+  "confirmations.unfilter.filters": "Passende{count, plural, one {r} other {}} Filter",
+  "content-type.change": "Inhaltstyp",
+  "direct.group_by_conversations": "Nach Unterhaltung gruppieren",
+  "endorsed_accounts_editor.endorsed_accounts": "Empfohlene Konten",
+  "favourite_modal.combo": "Mit {combo} wird dieses Fenster beim nächsten Mal nicht mehr angezeigt",
+  "getting_started.onboarding": "Führe mich herum",
+  "home.column_settings.advanced": "Erweitert",
+  "home.column_settings.filter_regex": "Mit regulären Ausdrücken herausfiltern",
+  "home.column_settings.show_direct": "Direktnachrichten anzeigen",
+  "home.settings": "Spalteneinstellungen",
+  "keyboard_shortcuts.bookmark": "zu Lesezeichen hinzufügen",
+  "keyboard_shortcuts.secondary_toot": "Toot mit sekundärer Privatsphäreeinstellung absenden",
+  "keyboard_shortcuts.toggle_collapse": "Toots ein-/ausklappen",
+  "layout.auto": "Automatisch",
+  "layout.desktop": "Desktop",
+  "layout.hint.auto": "Automatisch das Layout anhand der Einstellung \"Erweitertes Webinterface verwenden\" und Bildschirmgröße auswählen.",
+  "layout.hint.desktop": "Das mehrspaltige Layout verwenden, unabhängig von der Einstellung \"Erweitertes Webinterface verwenden\".",
+  "layout.hint.single": "Das einspaltige Layout verwenden, unabhängig von der Einstellung \"Erweitertes Webinterface verwenden\".",
+  "layout.single": "Mobil",
+  "media_gallery.sensitive": "Empfindlich",
+  "moved_to_warning": "Dieses Konto ist als verschoben zu {moved_to_link} markiert und akzeptiert daher keine neuen Follower.",
+  "navigation_bar.app_settings": "App-Einstellungen",
+  "navigation_bar.featured_users": "Empfohlene Nutzer",
+  "navigation_bar.keyboard_shortcuts": "Tastaturkürzel",
+  "navigation_bar.misc": "Sonstiges",
+  "notification.markForDeletion": "Zum Entfernen auswählen",
+  "notification_purge.btn_all": "Alle\nauswählen",
+  "notification_purge.btn_apply": "Ausgewählte\nentfernen",
+  "notification_purge.btn_invert": "Auswahl\numkehren",
+  "notification_purge.btn_none": "Auswahl\naufheben",
+  "notification_purge.start": "Benachrichtigungen-Aufräumen-Modus starten",
+  "notifications.marked_clear": "Ausgewählte Benachrichtigungen entfernen",
+  "notifications.marked_clear_confirmation": "Möchtest du wirklich alle auswählten Benachrichtigungen für immer entfernen?",
+  "onboarding.done": "Fertig",
+  "onboarding.next": "Weiter",
+  "onboarding.page_five.public_timelines": "Die lokale Timeline zeigt öffentliche Posts von allen auf {domain}. Die föderierte Timeline zeigt öffentliche Posts von allen, denen Leute auf {domain} folgen. Das sind die öffentlichen Timelines, eine tolle Möglichkeit, neue Leute zu entdecken.",
+  "onboarding.page_four.home": "Die Startseite zeigt Posts von Leuten an, denen du folgst.",
+  "onboarding.page_four.notifications": "Die Benachrichtigungs-Spalte zeigt an, wenn jemand mit dir interagiert.",
+  "onboarding.page_one.federation": "{domain} ist eine \"Instanz\" von Mastodon. Mastodon ist ein Netzwerk aus unabhängigen Servern, die zusammen ein größeres soziales Netzwerk bilden. Diese Server nennen wir Instanzen.",
+  "onboarding.page_one.handle": "Du bist auf {domain}, also ist dein vollständiger Nutzername {handle}",
+  "onboarding.page_one.welcome": "Willkommen auf {domain}!",
+  "onboarding.page_six.admin": "Dein Instanz-Admin ist {admin}.",
+  "onboarding.page_six.almost_done": "Fast geschafft...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "Es gibt {apps} für iOS, Android und andere Plattformen.",
+  "onboarding.page_six.github": "{domain} läuft auf glitch-soc. glitch-soc ist ein freundlicher {fork} von {Mastodon}, und ist mit jeder Mastodon-App oder -Instanz kompatibel. glitch-soc ist komplett frei und quelloffen. Auf {github} kannst du Fehler melden, Features anfragen oder Code beitragen.",
+  "onboarding.page_six.guidelines": "Community-Richtlinien",
+  "onboarding.page_six.read_guidelines": "Bitte lies die {guidelines} von {domain}!",
+  "onboarding.page_six.various_app": "mobile Apps",
+  "onboarding.page_three.profile": "Bearbeite dein Profil, um deinen Avatar, \"Über mich\" und den Anzeigenamen zu ändern. Dort findest du auch andere Einstellungen.",
+  "onboarding.page_three.search": "Benutze die Suchleiste, um Leute zu finden und Hashtags anzusehen, wie etwa {illustration} und {introductions}. Um nach einer Person zu suchen, die nicht auf dieser Instanz ist, benutze deren vollständigen Nutzername.",
+  "onboarding.page_two.compose": "Schreibe Posts in der Verfassen-Spalte. Mit den Symbolen unten kannst du Bilder hochladen, Privatsphäre-Einstellungen ändern, und Inhaltswarnungen hinzufügen.",
+  "onboarding.skip": "Überspringen",
+  "settings.always_show_spoilers_field": "Das Inhaltswarnungs-Feld immer aktivieren",
+  "settings.auto_collapse": "Automatisches Einklappen",
+  "settings.auto_collapse_all": "Alles",
+  "settings.auto_collapse_lengthy": "Lange Toots",
+  "settings.auto_collapse_media": "Toots mit Anhängen",
+  "settings.auto_collapse_height": "Höhe (in Pixeln), ab der ein Toot als lang gilt",
+  "settings.auto_collapse_notifications": "Benachrichtigungen",
+  "settings.auto_collapse_reblogs": "Geteilte Toots",
+  "settings.auto_collapse_replies": "Antworten",
+  "settings.close": "Schließen",
+  "settings.collapsed_statuses": "Eingeklappte Toots",
+  "settings.compose_box_opts": "Verfassen-Box",
+  "settings.confirm_before_clearing_draft": "Zeige einen Bestätigungsdialog, bevor der derzeitige Entwurf verworfen wird",
+  "settings.confirm_boost_missing_media_description": "Zeige einen Bestätigungsdialog, bevor Toots mit Anhängen ohne Beschreibung geteilt werden",
+  "settings.confirm_missing_media_description": "Zeige einen Bestätigungsdialog, bevor Toots mit Anhängen ohne Beschreibung abgesendet werden",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Regulärer Ausdruck",
+  "settings.content_warnings_filter": "Inhaltswarnungen, die nicht ausgeklappt werden sollen:",
+  "settings.content_warnings_media_outside": "Medienanhänge außerhalb von Inhaltswarnungen anzeigen",
+  "settings.content_warnings_media_outside_hint": "Das ursprüngliche Verhalten von Mastodon wiederherstellen, in welchem Inhaltswarnungen keine Auswirkungen auf Anhänge haben",
+  "settings.content_warnings_shared_state": "Inhalt aller Kopien auf einmal ein-/ausblenden",
+  "settings.content_warnings_shared_state_hint": "Das ursprüngliche Verhalten von Mastodon wiederhertstellen, in welchem der Inhaltswarnungs-Knopf alle Kopien eines Posts auf einmal ein-/ausklappt. Das wird das automatische Einklappen jedweder Kopie eines Toots mit ausgeklappter Inhaltswarnung",
+  "settings.content_warnings_unfold_opts": "Optionen zum automatischen Ausklappen",
+  "settings.deprecated_setting": "Diese Einstellung wird nun von Mastodons {settings_page_link} gesteuert",
+  "settings.enable_collapsed": "Eingeklappte Toots aktivieren",
+  "settings.enable_collapsed_hint": "Eingeklappte Posts haben einen Teil ihres Inhalts verborgen, um weniger Platz am Bildschirm einzunehmen. Das passiert unabhängig von der Inhaltswarnfunktion",
+  "settings.enable_content_warnings_auto_unfold": "Inhaltswarnungen automatisch ausklappen",
+  "settings.general": "Allgemein",
+  "settings.hicolor_privacy_icons": "Eingefärbte Privatsphäre-Symbole",
+  "settings.hicolor_privacy_icons.hint": "Zeige Privatsphäre-Symbole in hellen und leicht zu unterscheidenden Farben",
+  "settings.image_backgrounds": "Bildhintergründe",
+  "settings.image_backgrounds_media": "Vorschau eingeklappter Toot-Anhänge",
+  "settings.image_backgrounds_media_hint": "Wenn der Post Anhänge hat, wird der erste als Hintergrund verwendet",
+  "settings.image_backgrounds_users": "Eingeklappten Toots einen Bild-Hintergrund geben",
+  "settings.inline_preview_cards": "Eingebettete Vorschaukarten für externe Links",
+  "settings.layout": "Layout:",
+  "settings.layout_opts": "Layout-Optionen",
+  "settings.media": "Medien",
+  "settings.media_fullwidth": "Medienvorschau in voller Breite",
+  "settings.media_letterbox": "Mediengröße anpassen",
+  "settings.media_letterbox_hint": "Medien runterskalieren und einpassen um die Bildbehälter zu füllen anstatt zu strecken und zuzuschneiden",
+  "settings.media_reveal_behind_cw": "Empfindliche Medien hinter Inhaltswarnungen standardmäßig anzeigen",
+  "settings.notifications.favicon_badge": "Favicon-Badge für ungelesene Benachrichtigungen",
+  "settings.notifications.favicon_badge.hint": "Ein Badge für ungelesene Benachrichtigungen zum Favicon hinzufügen",
+  "settings.notifications.tab_badge": "Badge für ungelesene Benachrichtigungen",
+  "settings.notifications.tab_badge.hint": "Ein Badge für ungelesene Benachrichtigungen in den Spaltensymbolen anzeigen, wenn die Benachrichtigungen nicht offen sind",
+  "settings.notifications_opts": "Benachrichtigungsoptionen",
+  "settings.pop_in_left": "Links",
+  "settings.pop_in_player": "Pop-In-Player aktivieren",
+  "settings.pop_in_position": "Position des Pop-In-Players:",
+  "settings.pop_in_right": "Rechts",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "\"re: \" beim Antworten an Inhaltswarnung voranstellen",
+  "settings.preselect_on_reply": "Nutzernamen bei Antwort vorauswählen",
+  "settings.preselect_on_reply_hint": "Beim Antworten auf eine Konversation alle Nutzernamen auswählen, die nach dem ersten kommen",
+  "settings.rewrite_mentions": "Erwähnungen in angezeigten Status umschreiben",
+  "settings.rewrite_mentions_acct": "Mit Nutzernamen und Domain umschreiben (wenn das Konto auf einer anderen Instanz ist)",
+  "settings.rewrite_mentions_no": "Erwähnungen nicht umschreiben",
+  "settings.rewrite_mentions_username": "Mit Nutzername umschreiben",
+  "settings.shared_settings_link": "Nutzereinstellungen",
+  "settings.show_action_bar": "Aktions-Knöpfe in eingeklappten Toots anzeigen",
+  "settings.show_content_type_choice": "Auswahl für die Inhaltsart beim Verfassen von Toots anzeigen",
+  "settings.show_reply_counter": "Schätzung der Antwortanzahl anzeigen",
+  "settings.side_arm": "Sekundärer Toot-Knopf:",
+  "settings.side_arm.none": "Nichts",
+  "settings.side_arm_reply_mode": "Beim Antworten auf einen Toot sollte der sekundäre Toot-Knopf:",
+  "settings.side_arm_reply_mode.copy": "Privatsphäre-Einstellung des zu beantwortenden Toot verwenden",
+  "settings.side_arm_reply_mode.keep": "Die eingestellte Privatsphäre beibehalten",
+  "settings.side_arm_reply_mode.restrict": "Privatsphäre-Einstellung auf die des zu beantwortenden Toot beschränken",
+  "settings.status_icons": "Toot-Symbole",
+  "settings.status_icons_language": "Sprach-Indikator",
+  "settings.status_icons_local_only": "\"nur Lokal\"-Indikator",
+  "settings.status_icons_media": "Medien- und Umfragen-Indikatoren",
+  "settings.status_icons_reply": "Antwort-Indikator",
+  "settings.status_icons_visibility": "Toot-Privatsphäre-Indikator",
+  "settings.swipe_to_change_columns": "Das Wechseln der Spalte durch Wischen erlauben (nur für die mobile Ansicht)",
+  "settings.tag_misleading_links": "Irreführende Links markieren",
+  "settings.tag_misleading_links.hint": "Füge eine visuelle Indikation mit dem Ziel-Host des Links zu jedem Link hinzu, bei dem dieser nicht explizit genannt wird",
+  "settings.wide_view": "Breite Ansicht (nur für den Desktop-Modus)",
+  "settings.wide_view_hint": "Verbreitert Spalten, um den verfügbaren Platz besser zu füllen.",
+  "status.collapse": "Einklappen",
+  "status.has_audio": "Hat angehängte Audiodateien",
+  "status.has_pictures": "Hat angehängte Bilder",
+  "status.has_preview_card": "Hat eine Vorschaukarte",
+  "status.has_video": "Hat angehängte Videos",
+  "status.in_reply_to": "Dieser Toot ist eine Antwort",
+  "status.is_poll": "Dieser Toot ist eine Umfrage",
+  "status.local_only": "Nur auf deiner Instanz sichtbar",
+  "status.sensitive_toggle": "Zum Anzeigen klicken",
+  "status.uncollapse": "Ausklappen",
+  "web_app_crash.change_your_settings": "Deine {settings} ändern",
+  "web_app_crash.content": "Du kannst folgende Dinge ausprobieren:",
+  "web_app_crash.debug_info": "Debug-Informationen",
+  "web_app_crash.disable_addons": "Browser-Add-ons oder eingebaute Übersetzungswerkzeuge deaktivieren",
+  "web_app_crash.issue_tracker": "Issue-Tracker",
+  "web_app_crash.reload": "neu laden",
+  "web_app_crash.reload_page": "Die Seite {reload}",
+  "web_app_crash.report_issue": "Einen Fehler im {issuetracker} melden",
+  "web_app_crash.settings": "Einstellungen",
+  "web_app_crash.title": "Es tut uns leid, aber mit der Mastodon-App ist etwas schiefgelaufen."
+}
diff --git a/app/javascript/flavours/glitch/locales/defaultMessages.json b/app/javascript/flavours/glitch/locales/defaultMessages.json
new file mode 100644
index 000000000..d7aec67ac
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/defaultMessages.json
@@ -0,0 +1,1064 @@
+[
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "We're sorry, but something went wrong with the Mastodon app.",
+        "id": "web_app_crash.title"
+      },
+      {
+        "defaultMessage": "You could try any of the following:",
+        "id": "web_app_crash.content"
+      },
+      {
+        "defaultMessage": "Disable browser add-ons or built-in translation tools",
+        "id": "web_app_crash.disable_addons"
+      },
+      {
+        "defaultMessage": "Report a bug in the {issuetracker}",
+        "id": "web_app_crash.report_issue"
+      },
+      {
+        "defaultMessage": "issue tracker",
+        "id": "web_app_crash.issue_tracker"
+      },
+      {
+        "defaultMessage": "Debug information",
+        "id": "web_app_crash.debug_info"
+      },
+      {
+        "defaultMessage": "{reload} the current page",
+        "id": "web_app_crash.reload_page"
+      },
+      {
+        "defaultMessage": "Reload",
+        "id": "web_app_crash.reload"
+      },
+      {
+        "defaultMessage": "Change your {settings}",
+        "id": "web_app_crash.change_your_settings"
+      },
+      {
+        "defaultMessage": "settings",
+        "id": "web_app_crash.settings"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/components/error_boundary.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Sensitive",
+        "id": "media_gallery.sensitive"
+      },
+      {
+        "defaultMessage": "Click to view",
+        "id": "status.sensitive_toggle"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/components/media_gallery.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Select\nall",
+        "id": "notification_purge.btn_all"
+      },
+      {
+        "defaultMessage": "Select\nnone",
+        "id": "notification_purge.btn_none"
+      },
+      {
+        "defaultMessage": "Invert\nselection",
+        "id": "notification_purge.btn_invert"
+      },
+      {
+        "defaultMessage": "Clear\nselected",
+        "id": "notification_purge.btn_apply"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/components/notification_purge_buttons.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Collapse",
+        "id": "status.collapse"
+      },
+      {
+        "defaultMessage": "Uncollapse",
+        "id": "status.uncollapse"
+      },
+      {
+        "defaultMessage": "This toot is a reply",
+        "id": "status.in_reply_to"
+      },
+      {
+        "defaultMessage": "Features an attached preview card",
+        "id": "status.has_preview_card"
+      },
+      {
+        "defaultMessage": "Features attached pictures",
+        "id": "status.has_pictures"
+      },
+      {
+        "defaultMessage": "This toot is a poll",
+        "id": "status.is_poll"
+      },
+      {
+        "defaultMessage": "Features attached videos",
+        "id": "status.has_video"
+      },
+      {
+        "defaultMessage": "Features attached audio files",
+        "id": "status.has_audio"
+      },
+      {
+        "defaultMessage": "Only visible from your instance",
+        "id": "status.local_only"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/components/status_icons.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Are you sure you want to permanently clear all selected notifications?",
+        "id": "notifications.marked_clear_confirmation"
+      },
+      {
+        "defaultMessage": "Clear selected notifications",
+        "id": "notifications.marked_clear"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/containers/notification_purge_buttons_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Show",
+        "id": "confirmations.unfilter.confirm"
+      },
+      {
+        "defaultMessage": "Author",
+        "id": "confirmations.unfilter.author"
+      },
+      {
+        "defaultMessage": "Matching {count, plural, one {filter} other {filters}}",
+        "id": "confirmations.unfilter.filters"
+      },
+      {
+        "defaultMessage": "Edit filter",
+        "id": "confirmations.unfilter.edit_filter"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/containers/status_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Glitch-soc is free open source software forked from Mastodon.",
+        "id": "about.fork_disclaimer"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/about/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "No comment provided",
+        "id": "account_note.glitch_placeholder"
+      },
+      {
+        "defaultMessage": "Cancel",
+        "id": "account_note.cancel"
+      },
+      {
+        "defaultMessage": "Save",
+        "id": "account_note.save"
+      },
+      {
+        "defaultMessage": "Edit",
+        "id": "account_note.edit"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/account/components/account_note.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "This user has been suspended by a moderator.",
+        "id": "account.suspended_disclaimer_full"
+      },
+      {
+        "defaultMessage": "Information below may reflect the user's profile incompletely.",
+        "id": "account.disclaimer_full"
+      },
+      {
+        "defaultMessage": "View full profile",
+        "id": "account.view_full_profile"
+      },
+      {
+        "defaultMessage": "Follows",
+        "id": "account.follows"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/account/components/action_bar.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Add note for @{name}",
+        "id": "account.add_account_note"
+      },
+      {
+        "defaultMessage": "Joined {date}",
+        "id": "account.joined"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/account/components/header.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Profile",
+        "id": "column_header.profile"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/account/components/profile_column_header.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Filter out by regular expressions",
+        "id": "home.column_settings.filter_regex"
+      },
+      {
+        "defaultMessage": "Column settings",
+        "id": "home.settings"
+      },
+      {
+        "defaultMessage": "Advanced",
+        "id": "home.column_settings.advanced"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/community_timeline/components/column_settings.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.",
+        "id": "confirmations.missing_media_description.message"
+      },
+      {
+        "defaultMessage": "Send anyway",
+        "id": "confirmations.missing_media_description.confirm"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/compose/components/compose_form.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "App settings",
+        "id": "navigation_bar.app_settings"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/compose/components/header.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Advanced options",
+        "id": "advanced_options.icon_title"
+      },
+      {
+        "defaultMessage": "Attach...",
+        "id": "compose.attach"
+      },
+      {
+        "defaultMessage": "Content type",
+        "id": "content-type.change"
+      },
+      {
+        "defaultMessage": "Draw something",
+        "id": "compose.attach.doodle"
+      },
+      {
+        "defaultMessage": "HTML",
+        "id": "compose.content-type.html"
+      },
+      {
+        "defaultMessage": "Do not post to other instances",
+        "id": "advanced_options.local-only.long"
+      },
+      {
+        "defaultMessage": "Local-only",
+        "id": "advanced_options.local-only.short"
+      },
+      {
+        "defaultMessage": "Markdown",
+        "id": "compose.content-type.markdown"
+      },
+      {
+        "defaultMessage": "Plain text",
+        "id": "compose.content-type.plain"
+      },
+      {
+        "defaultMessage": "Hide text behind warning",
+        "id": "compose_form.spoiler"
+      },
+      {
+        "defaultMessage": "Automatically opens a reply on posting",
+        "id": "advanced_options.threaded_mode.long"
+      },
+      {
+        "defaultMessage": "Threaded mode",
+        "id": "advanced_options.threaded_mode.short"
+      },
+      {
+        "defaultMessage": "Upload a file",
+        "id": "compose.attach.upload"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/compose/components/options.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Allow one choice",
+        "id": "compose_form.poll.single_choice"
+      },
+      {
+        "defaultMessage": "Allow multiple choices",
+        "id": "compose_form.poll.multiple_choices"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/compose/components/poll_form.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "This post is local-only",
+        "id": "advanced_options.local-only.tooltip"
+      },
+      {
+        "defaultMessage": "Threaded mode enabled",
+        "id": "advanced_options.threaded_mode.tooltip"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/compose/components/textarea_icons.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.",
+        "id": "confirmations.missing_media_description.message"
+      },
+      {
+        "defaultMessage": "Send anyway",
+        "id": "confirmations.missing_media_description.confirm"
+      },
+      {
+        "defaultMessage": "Edit media",
+        "id": "confirmations.missing_media_description.edit"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/compose/containers/compose_form_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Filter out by regular expressions",
+        "id": "home.column_settings.filter_regex"
+      },
+      {
+        "defaultMessage": "Column settings",
+        "id": "home.settings"
+      },
+      {
+        "defaultMessage": "Group by conversation",
+        "id": "direct.group_by_conversations"
+      },
+      {
+        "defaultMessage": "Advanced",
+        "id": "home.column_settings.advanced"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Favourited by",
+        "id": "column.favourited_by"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/favourites/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Misc",
+        "id": "column.heading"
+      },
+      {
+        "defaultMessage": "Miscellaneous options",
+        "id": "column.subheading"
+      },
+      {
+        "defaultMessage": "Extended information",
+        "id": "navigation_bar.info"
+      },
+      {
+        "defaultMessage": "Show me around",
+        "id": "getting_started.onboarding"
+      },
+      {
+        "defaultMessage": "Keyboard shortcuts",
+        "id": "navigation_bar.keyboard_shortcuts"
+      },
+      {
+        "defaultMessage": "Featured users",
+        "id": "navigation_bar.featured_users"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/getting_started_misc/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Navigation",
+        "id": "column_subheading.navigation"
+      },
+      {
+        "defaultMessage": "App settings",
+        "id": "navigation_bar.app_settings"
+      },
+      {
+        "defaultMessage": "Keyboard shortcuts",
+        "id": "navigation_bar.keyboard_shortcuts"
+      },
+      {
+        "defaultMessage": "Lists",
+        "id": "column_subheading.lists"
+      },
+      {
+        "defaultMessage": "Misc",
+        "id": "navigation_bar.misc"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/getting_started/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Filter out by regular expressions",
+        "id": "home.column_settings.filter_regex"
+      },
+      {
+        "defaultMessage": "Column settings",
+        "id": "home.settings"
+      },
+      {
+        "defaultMessage": "Show DMs",
+        "id": "home.column_settings.show_direct"
+      },
+      {
+        "defaultMessage": "Advanced",
+        "id": "home.column_settings.advanced"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/home_timeline/components/column_settings.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "to bookmark",
+        "id": "keyboard_shortcuts.bookmark"
+      },
+      {
+        "defaultMessage": "to collapse/uncollapse toots",
+        "id": "keyboard_shortcuts.toggle_collapse"
+      },
+      {
+        "defaultMessage": "to send toot using secondary privacy setting",
+        "id": "keyboard_shortcuts.secondary_toot"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/keyboard_shortcuts/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "General",
+        "id": "settings.general"
+      },
+      {
+        "defaultMessage": "Compose box",
+        "id": "settings.compose_box_opts"
+      },
+      {
+        "defaultMessage": "Content Warnings",
+        "id": "settings.content_warnings"
+      },
+      {
+        "defaultMessage": "Collapsed toots",
+        "id": "settings.collapsed_statuses"
+      },
+      {
+        "defaultMessage": "Media",
+        "id": "settings.media"
+      },
+      {
+        "defaultMessage": "Preferences",
+        "id": "settings.preferences"
+      },
+      {
+        "defaultMessage": "Close",
+        "id": "settings.close"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/local_settings/navigation/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Auto",
+        "id": "layout.auto"
+      },
+      {
+        "defaultMessage": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
+        "id": "layout.hint.auto"
+      },
+      {
+        "defaultMessage": "Desktop",
+        "id": "layout.desktop"
+      },
+      {
+        "defaultMessage": "Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+        "id": "layout.hint.desktop"
+      },
+      {
+        "defaultMessage": "Mobile",
+        "id": "layout.single"
+      },
+      {
+        "defaultMessage": "Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+        "id": "layout.hint.single"
+      },
+      {
+        "defaultMessage": "None",
+        "id": "settings.side_arm.none"
+      },
+      {
+        "defaultMessage": "Keep its set privacy",
+        "id": "settings.side_arm_reply_mode.keep"
+      },
+      {
+        "defaultMessage": "Copy privacy setting of the toot being replied to",
+        "id": "settings.side_arm_reply_mode.copy"
+      },
+      {
+        "defaultMessage": "Restrict privacy setting to that of the toot being replied to",
+        "id": "settings.side_arm_reply_mode.restrict"
+      },
+      {
+        "defaultMessage": "Regular expression",
+        "id": "settings.content_warnings.regexp"
+      },
+      {
+        "defaultMessage": "Do not rewrite mentions",
+        "id": "settings.rewrite_mentions_no"
+      },
+      {
+        "defaultMessage": "Rewrite with username and domain (when the account is remote)",
+        "id": "settings.rewrite_mentions_acct"
+      },
+      {
+        "defaultMessage": "Rewrite with username",
+        "id": "settings.rewrite_mentions_username"
+      },
+      {
+        "defaultMessage": "Left",
+        "id": "settings.pop_in_left"
+      },
+      {
+        "defaultMessage": "Right",
+        "id": "settings.pop_in_right"
+      },
+      {
+        "defaultMessage": "General",
+        "id": "settings.general"
+      },
+      {
+        "defaultMessage": "Display an estimate of the reply count",
+        "id": "settings.show_reply_counter"
+      },
+      {
+        "defaultMessage": "High color privacy icons",
+        "id": "settings.hicolor_privacy_icons"
+      },
+      {
+        "defaultMessage": "Display privacy icons in bright and easily distinguishable colors",
+        "id": "settings.hicolor_privacy_icons.hint"
+      },
+      {
+        "defaultMessage": "Show confirmation dialog before boosting toots lacking media descriptions",
+        "id": "settings.confirm_boost_missing_media_description"
+      },
+      {
+        "defaultMessage": "Tag misleading links",
+        "id": "settings.tag_misleading_links"
+      },
+      {
+        "defaultMessage": "Add a visual indication with the link target host to every link not mentioning it explicitly",
+        "id": "settings.tag_misleading_links.hint"
+      },
+      {
+        "defaultMessage": "Rewrite mentions in displayed statuses",
+        "id": "settings.rewrite_mentions"
+      },
+      {
+        "defaultMessage": "Notifications options",
+        "id": "settings.notifications_opts"
+      },
+      {
+        "defaultMessage": "Unread notifications badge",
+        "id": "settings.notifications.tab_badge"
+      },
+      {
+        "defaultMessage": "Display a badge for unread notifications in the column icons when the notifications column isn't open",
+        "id": "settings.notifications.tab_badge.hint"
+      },
+      {
+        "defaultMessage": "Unread notifications favicon badge",
+        "id": "settings.notifications.favicon_badge"
+      },
+      {
+        "defaultMessage": "Add a badge for unread notifications to the favicon",
+        "id": "settings.notifications.favicon_badge.hint"
+      },
+      {
+        "defaultMessage": "Toot icons",
+        "id": "settings.status_icons"
+      },
+      {
+        "defaultMessage": "Language indicator",
+        "id": "settings.status_icons_language"
+      },
+      {
+        "defaultMessage": "Reply indicator",
+        "id": "settings.status_icons_reply"
+      },
+      {
+        "defaultMessage": "Local-only indicator",
+        "id": "settings.status_icons_local_only"
+      },
+      {
+        "defaultMessage": "Media and poll indicators",
+        "id": "settings.status_icons_media"
+      },
+      {
+        "defaultMessage": "Toot privacy indicator",
+        "id": "settings.status_icons_visibility"
+      },
+      {
+        "defaultMessage": "Layout options",
+        "id": "settings.layout_opts"
+      },
+      {
+        "defaultMessage": "Layout:",
+        "id": "settings.layout"
+      },
+      {
+        "defaultMessage": "Wide view (Desktop mode only)",
+        "id": "settings.wide_view"
+      },
+      {
+        "defaultMessage": "Stretches columns to better fill the available space.",
+        "id": "settings.wide_view_hint"
+      },
+      {
+        "defaultMessage": "Compose box",
+        "id": "settings.compose_box_opts"
+      },
+      {
+        "defaultMessage": "Always enable the Content Warning field",
+        "id": "settings.always_show_spoilers_field"
+      },
+      {
+        "defaultMessage": "Prepend “re: ” to content warnings when replying",
+        "id": "settings.prepend_cw_re"
+      },
+      {
+        "defaultMessage": "Pre-select usernames on reply",
+        "id": "settings.preselect_on_reply"
+      },
+      {
+        "defaultMessage": "When replying to a conversation with multiple participants, pre-select usernames past the first",
+        "id": "settings.preselect_on_reply_hint"
+      },
+      {
+        "defaultMessage": "Show confirmation dialog before sending toots lacking media descriptions",
+        "id": "settings.confirm_missing_media_description"
+      },
+      {
+        "defaultMessage": "Show confirmation dialog before overwriting the message being composed",
+        "id": "settings.confirm_before_clearing_draft"
+      },
+      {
+        "defaultMessage": "Show content-type choice when authoring toots",
+        "id": "settings.show_content_type_choice"
+      },
+      {
+        "defaultMessage": "Secondary toot button:",
+        "id": "settings.side_arm"
+      },
+      {
+        "defaultMessage": "When replying to a toot, the secondary toot button should:",
+        "id": "settings.side_arm_reply_mode"
+      },
+      {
+        "defaultMessage": "Content warnings",
+        "id": "settings.content_warnings"
+      },
+      {
+        "defaultMessage": "Show/hide content of all copies at once",
+        "id": "settings.content_warnings_shared_state"
+      },
+      {
+        "defaultMessage": "Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW",
+        "id": "settings.content_warnings_shared_state_hint"
+      },
+      {
+        "defaultMessage": "Display media attachments outside content warnings",
+        "id": "settings.content_warnings_media_outside"
+      },
+      {
+        "defaultMessage": "Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments",
+        "id": "settings.content_warnings_media_outside_hint"
+      },
+      {
+        "defaultMessage": "Auto-unfolding options",
+        "id": "settings.content_warnings_unfold_opts"
+      },
+      {
+        "defaultMessage": "Automatically unfold content-warnings",
+        "id": "settings.enable_content_warnings_auto_unfold"
+      },
+      {
+        "defaultMessage": "This setting is now controlled from Mastodon's {settings_page_link}",
+        "id": "settings.deprecated_setting"
+      },
+      {
+        "defaultMessage": "user preferences",
+        "id": "settings.shared_settings_link"
+      },
+      {
+        "defaultMessage": "Content warnings to not automatically unfold:",
+        "id": "settings.content_warnings_filter"
+      },
+      {
+        "defaultMessage": "Collapsed toots",
+        "id": "settings.collapsed_statuses"
+      },
+      {
+        "defaultMessage": "Enable collapsed toots",
+        "id": "settings.enable_collapsed"
+      },
+      {
+        "defaultMessage": "Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature",
+        "id": "settings.enable_collapsed_hint"
+      },
+      {
+        "defaultMessage": "Show action buttons in collapsed toots",
+        "id": "settings.show_action_bar"
+      },
+      {
+        "defaultMessage": "Automatic collapsing",
+        "id": "settings.auto_collapse"
+      },
+      {
+        "defaultMessage": "Everything",
+        "id": "settings.auto_collapse_all"
+      },
+      {
+        "defaultMessage": "Notifications",
+        "id": "settings.auto_collapse_notifications"
+      },
+      {
+        "defaultMessage": "Lengthy toots",
+        "id": "settings.auto_collapse_lengthy"
+      },
+      {
+        "defaultMessage": "Boosts",
+        "id": "settings.auto_collapse_reblogs"
+      },
+      {
+        "defaultMessage": "Replies",
+        "id": "settings.auto_collapse_replies"
+      },
+      {
+        "defaultMessage": "Toots with media",
+        "id": "settings.auto_collapse_media"
+      },
+      {
+        "defaultMessage": "Image backgrounds",
+        "id": "settings.image_backgrounds"
+      },
+      {
+        "defaultMessage": "Give collapsed toots an image background",
+        "id": "settings.image_backgrounds_users"
+      },
+      {
+        "defaultMessage": "Preview collapsed toot media",
+        "id": "settings.image_backgrounds_media"
+      },
+      {
+        "defaultMessage": "If the post has any media attachment, use the first one as a background",
+        "id": "settings.image_backgrounds_media_hint"
+      },
+      {
+        "defaultMessage": "Media",
+        "id": "settings.media"
+      },
+      {
+        "defaultMessage": "Letterbox media",
+        "id": "settings.media_letterbox"
+      },
+      {
+        "defaultMessage": "Scale down and letterbox media to fill the image containers instead of stretching and cropping them",
+        "id": "settings.media_letterbox_hint"
+      },
+      {
+        "defaultMessage": "Full-width media previews",
+        "id": "settings.media_fullwidth"
+      },
+      {
+        "defaultMessage": "Inline preview cards for external links",
+        "id": "settings.inline_preview_cards"
+      },
+      {
+        "defaultMessage": "Reveal sensitive media behind a CW by default",
+        "id": "settings.media_reveal_behind_cw"
+      },
+      {
+        "defaultMessage": "Enable pop-in player",
+        "id": "settings.pop_in_player"
+      },
+      {
+        "defaultMessage": "Pop-in player position:",
+        "id": "settings.pop_in_position"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/local_settings/page/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Mark for deletion",
+        "id": "notification.markForDeletion"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/notifications/components/overlay.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Enter notification cleaning mode",
+        "id": "notification_purge.start"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/notifications/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Featured accounts",
+        "id": "endorsed_accounts_editor.endorsed_accounts"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/pinned_accounts_editor/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Filter out by regular expressions",
+        "id": "home.column_settings.filter_regex"
+      },
+      {
+        "defaultMessage": "Show local-only toots",
+        "id": "community.column_settings.allow_local_only"
+      },
+      {
+        "defaultMessage": "Advanced",
+        "id": "home.column_settings.advanced"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/public_timeline/components/column_settings.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Boosted by",
+        "id": "column.reblogged_by"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/reblogs/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "This toot contains some media without description",
+        "id": "boost_modal.missing_description"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/ui/components/boost_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Do not ask for confirmation again",
+        "id": "confirmation_modal.do_not_ask_again"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/ui/components/confirmation_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Use Mastodon preferences",
+        "id": "confirmations.deprecated_settings.confirm"
+      },
+      {
+        "defaultMessage": "Automatically unfold content-warnings",
+        "id": "settings.enable_content_warnings_auto_unfold"
+      },
+      {
+        "defaultMessage": "Allow swiping to change columns (Mobile only)",
+        "id": "settings.swipe_to_change_columns"
+      },
+      {
+        "defaultMessage": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
+        "id": "confirmations.deprecated_settings.message"
+      },
+      {
+        "defaultMessage": "App settings",
+        "id": "navigation_bar.app_settings"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "You can press {combo} to skip this next time",
+        "id": "favourite_modal.combo"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/ui/components/favourite_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "App settings",
+        "id": "navigation_bar.app_settings"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/ui/components/navigation_panel.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Welcome to {domain}!",
+        "id": "onboarding.page_one.welcome"
+      },
+      {
+        "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.",
+        "id": "onboarding.page_one.federation"
+      },
+      {
+        "defaultMessage": "You are on {domain}, so your full handle is {handle}",
+        "id": "onboarding.page_one.handle"
+      },
+      {
+        "defaultMessage": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+        "id": "onboarding.page_two.compose"
+      },
+      {
+        "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.",
+        "id": "onboarding.page_three.search"
+      },
+      {
+        "defaultMessage": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+        "id": "onboarding.page_three.profile"
+      },
+      {
+        "defaultMessage": "The home timeline shows posts from people you follow.",
+        "id": "onboarding.page_four.home"
+      },
+      {
+        "defaultMessage": "The notifications column shows when someone interacts with you.",
+        "id": "onboarding.page_four.notifications"
+      },
+      {
+        "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.",
+        "id": "onboarding.page_five.public_timelines"
+      },
+      {
+        "defaultMessage": "Your instance's admin is {admin}.",
+        "id": "onboarding.page_six.admin"
+      },
+      {
+        "defaultMessage": "Please read {domain}'s {guidelines}!",
+        "id": "onboarding.page_six.read_guidelines"
+      },
+      {
+        "defaultMessage": "community guidelines",
+        "id": "onboarding.page_six.guidelines"
+      },
+      {
+        "defaultMessage": "Almost done...",
+        "id": "onboarding.page_six.almost_done"
+      },
+      {
+        "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}.",
+        "id": "onboarding.page_six.github"
+      },
+      {
+        "defaultMessage": "There are {apps} available for iOS, Android and other platforms.",
+        "id": "onboarding.page_six.apps_available"
+      },
+      {
+        "defaultMessage": "mobile apps",
+        "id": "onboarding.page_six.various_app"
+      },
+      {
+        "defaultMessage": "Bon Appetoot!",
+        "id": "onboarding.page_six.appetoot"
+      },
+      {
+        "defaultMessage": "Next",
+        "id": "onboarding.next"
+      },
+      {
+        "defaultMessage": "Done",
+        "id": "onboarding.done"
+      },
+      {
+        "defaultMessage": "Skip",
+        "id": "onboarding.skip"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/ui/components/onboarding_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
+        "id": "moved_to_warning"
+      }
+    ],
+    "path": "app/javascript/flavours/glitch/features/ui/index.json"
+  }
+]
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/locales/el.json b/app/javascript/flavours/glitch/locales/el.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/el.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/en-GB.json b/app/javascript/flavours/glitch/locales/en-GB.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/en-GB.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json
new file mode 100644
index 000000000..d15c23e13
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/en.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "Glitch-soc is free open source software forked from Mastodon.",
+  "account.add_account_note": "Add note for @{name}",
+  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.follows": "Follows",
+  "account.joined": "Joined {date}",
+  "account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
+  "account.view_full_profile": "View full profile",
+  "account_note.cancel": "Cancel",
+  "account_note.edit": "Edit",
+  "account_note.glitch_placeholder": "No comment provided",
+  "account_note.save": "Save",
+  "advanced_options.icon_title": "Advanced options",
+  "advanced_options.local-only.long": "Do not post to other instances",
+  "advanced_options.local-only.short": "Local-only",
+  "advanced_options.local-only.tooltip": "This post is local-only",
+  "advanced_options.threaded_mode.long": "Automatically opens a reply on posting",
+  "advanced_options.threaded_mode.short": "Threaded mode",
+  "advanced_options.threaded_mode.tooltip": "Threaded mode enabled",
+  "boost_modal.missing_description": "This toot contains some media without description",
+  "column.favourited_by": "Favourited by",
+  "column.heading": "Misc",
+  "column.reblogged_by": "Boosted by",
+  "column.subheading": "Miscellaneous options",
+  "column_header.profile": "Profile",
+  "column_subheading.lists": "Lists",
+  "column_subheading.navigation": "Navigation",
+  "community.column_settings.allow_local_only": "Show local-only toots",
+  "compose.attach": "Attach...",
+  "compose.attach.doodle": "Draw something",
+  "compose.attach.upload": "Upload a file",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Plain text",
+  "compose_form.poll.multiple_choices": "Allow multiple choices",
+  "compose_form.poll.single_choice": "Allow one choice",
+  "compose_form.spoiler": "Hide text behind warning",
+  "confirmation_modal.do_not_ask_again": "Do not ask for confirmation again",
+  "confirmations.deprecated_settings.confirm": "Use Mastodon preferences",
+  "confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
+  "confirmations.missing_media_description.confirm": "Send anyway",
+  "confirmations.missing_media_description.edit": "Edit media",
+  "confirmations.missing_media_description.message": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.",
+  "confirmations.unfilter.author": "Author",
+  "confirmations.unfilter.confirm": "Show",
+  "confirmations.unfilter.edit_filter": "Edit filter",
+  "confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
+  "content-type.change": "Content type",
+  "direct.group_by_conversations": "Group by conversation",
+  "endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
+  "favourite_modal.combo": "You can press {combo} to skip this next time",
+  "getting_started.onboarding": "Show me around",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_direct": "Show DMs",
+  "home.settings": "Column settings",
+  "keyboard_shortcuts.bookmark": "to bookmark",
+  "keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
+  "keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
+  "layout.auto": "Auto",
+  "layout.desktop": "Desktop",
+  "layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
+  "layout.hint.desktop": "Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.hint.single": "Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.single": "Mobile",
+  "media_gallery.sensitive": "Sensitive",
+  "moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
+  "navigation_bar.app_settings": "App settings",
+  "navigation_bar.featured_users": "Featured users",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.misc": "Misc",
+  "notification.markForDeletion": "Mark for deletion",
+  "notification_purge.btn_all": "Select\nall",
+  "notification_purge.btn_apply": "Clear\nselected",
+  "notification_purge.btn_invert": "Invert\nselection",
+  "notification_purge.btn_none": "Select\nnone",
+  "notification_purge.start": "Enter notification cleaning mode",
+  "notifications.marked_clear": "Clear selected notifications",
+  "notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "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.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "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.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to {domain}!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "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}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "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.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "settings.always_show_spoilers_field": "Always enable the Content Warning field",
+  "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_height": "Height (in pixels) for a toot to be considered lengthy",
+  "settings.auto_collapse_notifications": "Notifications",
+  "settings.auto_collapse_reblogs": "Boosts",
+  "settings.auto_collapse_replies": "Replies",
+  "settings.close": "Close",
+  "settings.collapsed_statuses": "Collapsed toots",
+  "settings.compose_box_opts": "Compose box",
+  "settings.confirm_before_clearing_draft": "Show confirmation dialog before overwriting the message being composed",
+  "settings.confirm_boost_missing_media_description": "Show confirmation dialog before boosting toots lacking media descriptions",
+  "settings.confirm_missing_media_description": "Show confirmation dialog before sending toots lacking media descriptions",
+  "settings.content_warnings": "Content Warnings",
+  "settings.content_warnings.regexp": "Regular expression",
+  "settings.content_warnings_filter": "Content warnings to not automatically unfold:",
+  "settings.content_warnings_media_outside": "Display media attachments outside content warnings",
+  "settings.content_warnings_media_outside_hint": "Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments",
+  "settings.content_warnings_shared_state": "Show/hide content of all copies at once",
+  "settings.content_warnings_shared_state_hint": "Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW",
+  "settings.content_warnings_unfold_opts": "Auto-unfolding options",
+  "settings.deprecated_setting": "This setting is now controlled from Mastodon's {settings_page_link}",
+  "settings.enable_collapsed": "Enable collapsed toots",
+  "settings.enable_collapsed_hint": "Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature",
+  "settings.enable_content_warnings_auto_unfold": "Automatically unfold content-warnings",
+  "settings.general": "General",
+  "settings.hicolor_privacy_icons": "High color privacy icons",
+  "settings.hicolor_privacy_icons.hint": "Display privacy icons in bright and easily distinguishable colors",
+  "settings.image_backgrounds": "Image backgrounds",
+  "settings.image_backgrounds_media": "Preview collapsed toot media",
+  "settings.image_backgrounds_media_hint": "If the post has any media attachment, use the first one as a background",
+  "settings.image_backgrounds_users": "Give collapsed toots an image background",
+  "settings.inline_preview_cards": "Inline preview cards for external links",
+  "settings.layout": "Layout:",
+  "settings.layout_opts": "Layout options",
+  "settings.media": "Media",
+  "settings.media_fullwidth": "Full-width media previews",
+  "settings.media_letterbox": "Letterbox media",
+  "settings.media_letterbox_hint": "Scale down and letterbox media to fill the image containers instead of stretching and cropping them",
+  "settings.media_reveal_behind_cw": "Reveal sensitive media behind a CW by default",
+  "settings.notifications.favicon_badge": "Unread notifications favicon badge",
+  "settings.notifications.favicon_badge.hint": "Add a badge for unread notifications to the favicon",
+  "settings.notifications.tab_badge": "Unread notifications badge",
+  "settings.notifications.tab_badge.hint": "Display a badge for unread notifications in the column icons when the notifications column isn't open",
+  "settings.notifications_opts": "Notifications options",
+  "settings.pop_in_left": "Left",
+  "settings.pop_in_player": "Enable pop-in player",
+  "settings.pop_in_position": "Pop-in player position:",
+  "settings.pop_in_right": "Right",
+  "settings.preferences": "User preferences",
+  "settings.prepend_cw_re": "Prepend “re: ” to content warnings when replying",
+  "settings.preselect_on_reply": "Pre-select usernames on reply",
+  "settings.preselect_on_reply_hint": "When replying to a conversation with multiple participants, pre-select usernames past the first",
+  "settings.rewrite_mentions": "Rewrite mentions in displayed statuses",
+  "settings.rewrite_mentions_acct": "Rewrite with username and domain (when the account is remote)",
+  "settings.rewrite_mentions_no": "Do not rewrite mentions",
+  "settings.rewrite_mentions_username": "Rewrite with username",
+  "settings.shared_settings_link": "user preferences",
+  "settings.show_action_bar": "Show action buttons in collapsed toots",
+  "settings.show_content_type_choice": "Show content-type choice when authoring toots",
+  "settings.show_reply_counter": "Display an estimate of the reply count",
+  "settings.side_arm": "Secondary toot button:",
+  "settings.side_arm.none": "None",
+  "settings.side_arm_reply_mode": "When replying to a toot, the secondary toot button should:",
+  "settings.side_arm_reply_mode.copy": "Copy privacy setting of the toot being replied to",
+  "settings.side_arm_reply_mode.keep": "Keep its set privacy",
+  "settings.side_arm_reply_mode.restrict": "Restrict privacy setting to that of the toot being replied to",
+  "settings.status_icons": "Toot icons",
+  "settings.status_icons_language": "Language indicator",
+  "settings.status_icons_local_only": "Local-only indicator",
+  "settings.status_icons_media": "Media and poll indicators",
+  "settings.status_icons_reply": "Reply indicator",
+  "settings.status_icons_visibility": "Toot privacy indicator",
+  "settings.swipe_to_change_columns": "Allow swiping to change columns (Mobile only)",
+  "settings.tag_misleading_links": "Tag misleading links",
+  "settings.tag_misleading_links.hint": "Add a visual indication with the link target host to every link not mentioning it explicitly",
+  "settings.wide_view": "Wide view (Desktop mode only)",
+  "settings.wide_view_hint": "Stretches columns to better fill the available space.",
+  "status.collapse": "Collapse",
+  "status.has_audio": "Features attached audio files",
+  "status.has_pictures": "Features attached pictures",
+  "status.has_preview_card": "Features an attached preview card",
+  "status.has_video": "Features attached videos",
+  "status.in_reply_to": "This toot is a reply",
+  "status.is_poll": "This toot is a poll",
+  "status.local_only": "Only visible from your instance",
+  "status.sensitive_toggle": "Click to view",
+  "status.uncollapse": "Uncollapse",
+  "web_app_crash.change_your_settings": "Change your {settings}",
+  "web_app_crash.content": "You could try any of the following:",
+  "web_app_crash.debug_info": "Debug information",
+  "web_app_crash.disable_addons": "Disable browser add-ons or built-in translation tools",
+  "web_app_crash.issue_tracker": "issue tracker",
+  "web_app_crash.reload": "Reload",
+  "web_app_crash.reload_page": "{reload} the current page",
+  "web_app_crash.report_issue": "Report a bug in the {issuetracker}",
+  "web_app_crash.settings": "settings",
+  "web_app_crash.title": "We're sorry, but something went wrong with the Mastodon app."
+}
diff --git a/app/javascript/flavours/glitch/locales/eo.json b/app/javascript/flavours/glitch/locales/eo.json
new file mode 100644
index 000000000..91fb0fb5d
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/eo.json
@@ -0,0 +1,68 @@
+{
+  "account.add_account_note": "Aldoni noton por @{name}",
+  "account.disclaimer_full": "Subaj informoj povas nekomplete prezenti la profilon de la uzanto.",
+  "account.follows": "Sekvatoj",
+  "account.joined": "Kuniĝis {date}",
+  "account.suspended_disclaimer_full": "Ĉi tiu uzanto estis suspendita de moderiganto.",
+  "account.view_full_profile": "Vidi plenan profilon",
+  "account_note.cancel": "Nuligi",
+  "account_note.edit": "Redakti",
+  "account_note.save": "Konservi",
+  "advanced_options.icon_title": "Pliaj opcioj",
+  "advanced_options.local-only.long": "Ne afiŝi al aliaj instancoj",
+  "advanced_options.local-only.short": "Nur loka",
+  "advanced_options.local-only.tooltip": "Ĉi tiu afiŝo estas nur-loka",
+  "column.favourited_by": "Stelumita per",
+  "column.reblogged_by": "Diskonigita de",
+  "column.subheading": "Diversaj agordoj",
+  "column_header.profile": "Profilo",
+  "column_subheading.lists": "Listoj",
+  "community.column_settings.allow_local_only": "Montri nur-lokajn afiŝojn",
+  "compose.attach": "Aldoni…",
+  "compose.attach.doodle": "Desegni ion",
+  "compose.attach.upload": "Alŝuti dosieron",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Plata teksto",
+  "compose_form.poll.multiple_choices": "Permesi multajn elekteblojn",
+  "compose_form.poll.single_choice": "Permesi unu elekteblon",
+  "confirmations.unfilter.author": "Aŭtoro",
+  "confirmations.unfilter.confirm": "Montri",
+  "confirmations.unfilter.edit_filter": "Redakti filtrilon",
+  "navigation_bar.keyboard_shortcuts": "Fulmoklavoj",
+  "notification_purge.btn_all": "Selekti ĉiujn",
+  "notification_purge.btn_apply": "Forigi selektajn",
+  "notification_purge.btn_invert": "Inverti selekton",
+  "notification_purge.btn_none": "Elekti neniun",
+  "notifications.marked_clear": "Forigi selektajn sciigojn",
+  "onboarding.next": "Sekva",
+  "onboarding.page_one.federation": "{domain} estas \"instanco\" de Mastodon. Mastodon estas reto de sendependaj serviloj, ke kuniĝas por fari unu pli grandan socian reton. Ni nomas tiujn servilojn \"instancoj\".",
+  "onboarding.page_six.admin": "La administranto de via instanco estas {admin}.",
+  "onboarding.page_six.almost_done": "Preskaŭ finita…",
+  "onboarding.page_six.apps_available": "Estas {apps} disponeblaj por iOS, Android kaj aliaj sistemoj.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.various_app": "poŝtelefonaj aplikaĵoj",
+  "settings.auto_collapse_all": "Ĉiuj",
+  "settings.auto_collapse_lengthy": "Longaj afiŝoj",
+  "settings.auto_collapse_media": "Afiŝoj kun aŭdovidaĵoj",
+  "settings.auto_collapse_notifications": "Sciigoj",
+  "settings.auto_collapse_reblogs": "Diskonigoj",
+  "settings.auto_collapse_replies": "Respondoj",
+  "settings.close": "Fermi",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Regula esprimo",
+  "settings.preferences": "Preferences",
+  "settings.shared_settings_link": "preferoj de uzanto",
+  "settings.side_arm": "Duaranga butono por afiŝi:",
+  "settings.side_arm.none": "Neniu",
+  "settings.status_icons": "Ikonoj sur la afiŝoj",
+  "settings.status_icons_language": "Indikilo de lingvo",
+  "settings.status_icons_media": "Indikilo de aŭdovidaĵojn kaj balotenketo",
+  "settings.status_icons_reply": "Indikilo de respondoj",
+  "settings.status_icons_visibility": "Indikilo de privateco de afiŝo",
+  "status.local_only": "Videbla nur el via instanco",
+  "web_app_crash.change_your_settings": "Ŝanĝi viajn {settings}",
+  "web_app_crash.reload": "Reŝarĝi",
+  "web_app_crash.reload_page": "{reload} la nunan paĝon",
+  "web_app_crash.settings": "agordojn"
+}
diff --git a/app/javascript/flavours/glitch/locales/es-AR.json b/app/javascript/flavours/glitch/locales/es-AR.json
new file mode 100644
index 000000000..df88d989d
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/es-AR.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.",
+  "account.add_account_note": "Añadir nota para @{name}",
+  "account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.",
+  "account.follows": "Sigue",
+  "account.joined": "Unido el {date}",
+  "account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
+  "account.view_full_profile": "Ver perfil completo",
+  "account_note.cancel": "Cancelar",
+  "account_note.edit": "Editar",
+  "account_note.glitch_placeholder": "No se proporcionó comentario alguno",
+  "account_note.save": "Guardar",
+  "advanced_options.icon_title": "Opciones avanzadas",
+  "advanced_options.local-only.long": "No publicar a otras instancias",
+  "advanced_options.local-only.short": "Local",
+  "advanced_options.local-only.tooltip": "Este toot es local",
+  "advanced_options.threaded_mode.long": "Al publicar abre automáticamente una respuesta",
+  "advanced_options.threaded_mode.short": "Modo hilo",
+  "advanced_options.threaded_mode.tooltip": "Modo hilo habilitado",
+  "boost_modal.missing_description": "Esta publicación contiene medios sin descripción",
+  "column.favourited_by": "Marcado como favorito por",
+  "column.heading": "Misc",
+  "column.reblogged_by": "Impulsado por",
+  "column.subheading": "Opciones misceláneas",
+  "column_header.profile": "Perfil",
+  "column_subheading.lists": "Listas",
+  "column_subheading.navigation": "Navegación",
+  "community.column_settings.allow_local_only": "Mostrar sólo toots locales",
+  "compose.attach": "Adjuntar...",
+  "compose.attach.doodle": "Dibujar algo",
+  "compose.attach.upload": "Subir un archivo",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Texto plano",
+  "compose_form.poll.multiple_choices": "Permitir múltiples opciones",
+  "compose_form.poll.single_choice": "Permitir sólo una opción",
+  "compose_form.spoiler": "Esconder el texto detrás de la advertencia",
+  "confirmation_modal.do_not_ask_again": "No preguntar por la confirmación de nuevo",
+  "confirmations.deprecated_settings.confirm": "Usar las preferencias de Mastodon",
+  "confirmations.deprecated_settings.message": "Algunas de las {app_settings} de glitch-soc, específicas para el dispositivo que estás usando han sido reemplazadas en las {preferences} de Mastodon y serán sobreescritas:",
+  "confirmations.missing_media_description.confirm": "Enviar de todos modos",
+  "confirmations.missing_media_description.edit": "Editar medios",
+  "confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
+  "confirmations.unfilter.author": "Publicado por",
+  "confirmations.unfilter.confirm": "Mostrar",
+  "confirmations.unfilter.edit_filter": "Editar filtro",
+  "confirmations.unfilter.filters": "Coincidencia con {count, plural, one {filtro} other {filtros}}",
+  "content-type.change": "Tipo de contenido",
+  "direct.group_by_conversations": "Agrupar por conversación",
+  "endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
+  "favourite_modal.combo": "Puedes presionar {combo} para omitir esto la próxima vez",
+  "getting_started.onboarding": "Paseo inicial",
+  "home.column_settings.advanced": "Avanzado",
+  "home.column_settings.filter_regex": "Filtrar por expresiones regulares",
+  "home.column_settings.show_direct": "Mostrar mensajes directos",
+  "home.settings": "Configuraciones de columna",
+  "keyboard_shortcuts.bookmark": "a marcadores",
+  "keyboard_shortcuts.secondary_toot": "para enviar un toot usando lac onfiguración de privacidad secundaria",
+  "keyboard_shortcuts.toggle_collapse": "para colapsar/descolapsar toots",
+  "layout.auto": "Automático",
+  "layout.desktop": "Escritorio",
+  "layout.hint.auto": "Seleccionar un diseño automáticamente basado en \"Habilitar interface web avanzada\" y tamaño de pantalla",
+  "layout.hint.desktop": "Utiliza el diseño multi-columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
+  "layout.hint.single": "Utiliza el diseño de una columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
+  "layout.single": "Móvil",
+  "media_gallery.sensitive": "Sensible",
+  "moved_to_warning": "Esta cuenta está marcada como movida a {moved_to_link}, y por lo tanto no aceptará nuevos seguimientos.",
+  "navigation_bar.app_settings": "Ajustes de aplicación",
+  "navigation_bar.featured_users": "Usuarios destacados",
+  "navigation_bar.keyboard_shortcuts": "Atajos de teclado",
+  "navigation_bar.misc": "Misc",
+  "notification.markForDeletion": "Marcar para borrar",
+  "notification_purge.btn_all": "Seleccionar\ntodo",
+  "notification_purge.btn_apply": "Borrar\nselección",
+  "notification_purge.btn_invert": "Invertir\nselección",
+  "notification_purge.btn_none": "Seleccionar\nnada",
+  "notification_purge.start": "Entrar en modo de limpieza de notificaciones",
+  "notifications.marked_clear": "Limpiar notificaciones seleccionadas",
+  "notifications.marked_clear_confirmation": "¿Deseas borrar permanentemente todas las notificaciones seleccionadas?",
+  "onboarding.done": "Hecho",
+  "onboarding.next": "Siguiente",
+  "onboarding.page_five.public_timelines": "La línea de tiempo local muestra mensajes públicos de todos en {domain}. La línea de tiempo federada muestra mensajes públicos de todos aquellos que en {domain} siguen a otros servidores. Estas son las líneas cronológicas públicas, una gran manera de descubrir gente nueva.",
+  "onboarding.page_four.home": "La línea de tiempo principal muestra los mensajes de la gente que sigues.",
+  "onboarding.page_four.notifications": "La columna de notificaciones muestra cuando alguien interactúa contigo.",
+  "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.handle": "Estás en {domain}, así que tu alias completo es {handle}",
+  "onboarding.page_one.welcome": "¡Bienvenidx a {domain}!",
+  "onboarding.page_six.admin": "El administrador de tu instancia es {admin}.",
+  "onboarding.page_six.almost_done": "Casi listo...",
+  "onboarding.page_six.appetoot": "¡A tootear!",
+  "onboarding.page_six.apps_available": "Hay {apps} disponibles para iOS, Android y otras plataformas.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "normas de la comunidad",
+  "onboarding.page_six.read_guidelines": "¡Por favor lee las {guidelines} de {domain}!",
+  "onboarding.page_six.various_app": "aplicaciones para móviles",
+  "onboarding.page_three.profile": "Edita tu perfil para cambiar tu avatar, biografía y nombre para mostrar. Ahí, también encontrarás otras preferencias.",
+  "onboarding.page_three.search": "Usa la barra de búsqueda para encontrar gente y mirar las etiquetas (hashtags), como {illustration} y {introductions}. Para buscar a una persona que no esté en esta instancia, utiliza su alias completo.",
+  "onboarding.page_two.compose": "Escribe mensajes desde la columna de composición. Puedes subir imágenes, cambiar la configuración de privacidad y añadir advertencias de contenido con los iconos de abajo.",
+  "onboarding.skip": "Saltar",
+  "settings.always_show_spoilers_field": "Siempre mostrar el campo de advertencia de contenido",
+  "settings.auto_collapse": "Colapsar automáticamente",
+  "settings.auto_collapse_all": "Todo",
+  "settings.auto_collapse_lengthy": "Toots largos",
+  "settings.auto_collapse_media": "Toots con medios",
+  "settings.auto_collapse_height": "Altura (en pixeles) para que un toot sea considerado largo",
+  "settings.auto_collapse_notifications": "Notificaciones",
+  "settings.auto_collapse_reblogs": "Retoots",
+  "settings.auto_collapse_replies": "Respuestas",
+  "settings.close": "Cerrar",
+  "settings.collapsed_statuses": "Toots colapsados",
+  "settings.compose_box_opts": "Cuadro de redacción",
+  "settings.confirm_before_clearing_draft": "Mostrar diálogo de confirmación antes de sobreescribir un mensaje estabas escribiendo",
+  "settings.confirm_boost_missing_media_description": "Mostrar diálogo de confirmación antes de retootear publicaciones con medios sin descripción",
+  "settings.confirm_missing_media_description": "Mostrar diálogo de confirmación antes de publicar toots con medios sin descripción",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Regexp (expresión regular)",
+  "settings.content_warnings_filter": "No descolapsar estas advertencias de contenido:",
+  "settings.content_warnings_media_outside": "Mostrar archivos adjuntos fuera de las advertencias de contenido",
+  "settings.content_warnings_media_outside_hint": "Reproduce el comportamiento normal de Mastodon teniendo al tener el interruptor de advertencia de contenido activado, no afectando los archivos adjuntos",
+  "settings.content_warnings_shared_state": "Mostrar/ocultar el contenido de todas las copias a la vez",
+  "settings.content_warnings_shared_state_hint": "Reproduce el comportamiento normal de Mastodon al hacer que el botón Advertencia de contenido afecte a todas las copias de un mensaje a la vez. Esto evitará el colapso automático de cualquier copia de un toot con CW desplegado",
+  "settings.content_warnings_unfold_opts": "Opciones de Auto-desplegado",
+  "settings.deprecated_setting": "Esta configuración ahora está controlada desde {settings_page_link} de Mastodon",
+  "settings.enable_collapsed": "Habilitar toots colapsados",
+  "settings.enable_collapsed_hint": "Las publicaciones colapsadas tienen partes de su contenido ocultas para ocupar menos espacio en pantalla. Esto es distinto de la función Advertencia de Contenido",
+  "settings.enable_content_warnings_auto_unfold": "Descolapsar automáticamente advertencias de contenido",
+  "settings.general": "General",
+  "settings.hicolor_privacy_icons": "Íconos de privacidad más visibles",
+  "settings.hicolor_privacy_icons.hint": "Mostrar iconos de privacidad en colores brillantes y fácilmente distinguibles",
+  "settings.image_backgrounds": "Fondos de imágenes",
+  "settings.image_backgrounds_media": "Vista previa de medios de toots colapsados",
+  "settings.image_backgrounds_media_hint": "Si la publicación tiene algún archivo adjunto, utilice el primero como fondo",
+  "settings.image_backgrounds_users": "Darle fondo de imagen a toots colapsados",
+  "settings.inline_preview_cards": "Vista previa para enlaces externos",
+  "settings.layout": "Diseño",
+  "settings.layout_opts": "Opciones de diseño",
+  "settings.media": "Medios",
+  "settings.media_fullwidth": "Ancho completo al mostrar medios ",
+  "settings.media_letterbox": "Mantener proporciones al mostrar medios",
+  "settings.media_letterbox_hint": "Escalar medios para que llenen el espacio del contenedor sin cambiar sus proporciones sin recortarlos",
+  "settings.media_reveal_behind_cw": "Siempre mostrar medios sensibles dentro de las advertencias de contenido",
+  "settings.notifications.favicon_badge": "Marcador de notificaciones en el favicon",
+  "settings.notifications.favicon_badge.hint": "Muestra un marcador de notificaciones sin leer en el favicon",
+  "settings.notifications.tab_badge": "Marcador de notificaciones no leídas",
+  "settings.notifications.tab_badge.hint": "Muestra un marcador de notificaciones sin leer en el ícono de notificaciones cuando dicha columna no está abierta",
+  "settings.notifications_opts": "Opciones de notificaciones",
+  "settings.pop_in_left": "Izquierda",
+  "settings.pop_in_player": "Habilitar reproductor emergente",
+  "settings.pop_in_position": "Posición del reproductor:",
+  "settings.pop_in_right": "Derecha",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "Anteponer \"re: \" a las advertencias de contenido al responder",
+  "settings.preselect_on_reply": "Preseleccionar nombres de usuarix al responder",
+  "settings.preselect_on_reply_hint": "Al responder a conversaciones con múltiples participantes, preselecciona los nombres de usuarix subsecuentes del/la primerx",
+  "settings.rewrite_mentions": "Reescribir menciones in publicaciones mostradas",
+  "settings.rewrite_mentions_acct": "Reescribir con nombre de usuarix y dominio (para cuentas remotas)",
+  "settings.rewrite_mentions_no": "No reescribir menciones",
+  "settings.rewrite_mentions_username": "Reescribir con nombre de usuarix",
+  "settings.shared_settings_link": "preferencias de usuario",
+  "settings.show_action_bar": "Mostrar botones de acción en toots colapsados",
+  "settings.show_content_type_choice": "Mostrar selección de tipo de contenido al crear toots",
+  "settings.show_reply_counter": "Mostrar un conteo estimado de respuestas",
+  "settings.side_arm": "Botón secundario:",
+  "settings.side_arm.none": "Ninguno",
+  "settings.side_arm_reply_mode": "Al responder a un toot, el botón de toot secundario debe:",
+  "settings.side_arm_reply_mode.copy": "Copiar opción de privacidad del toot al que estás respondiendo",
+  "settings.side_arm_reply_mode.keep": "Conservar opción de privacidad",
+  "settings.side_arm_reply_mode.restrict": "Restringir la opción de privacidad a la misma del toot al que estás respondiendo",
+  "settings.status_icons": "Iconos del toot",
+  "settings.status_icons_language": "Indicador de lenguaje",
+  "settings.status_icons_local_only": "Indicador de sólo local",
+  "settings.status_icons_media": "Indicadores de medios y encuestas",
+  "settings.status_icons_reply": "Indicador de respuesta",
+  "settings.status_icons_visibility": "Indicador de privacidad de toot",
+  "settings.swipe_to_change_columns": "Permitir deslizar para cambiar columnas (Sólo en móvil)",
+  "settings.tag_misleading_links": "Marcar enlaces engañosos",
+  "settings.tag_misleading_links.hint": "Añadir una indicación visual indicando el destino de los enlace que no los mencionen explícitamente",
+  "settings.wide_view": "Vista amplia (solo modo de escritorio)",
+  "settings.wide_view_hint": "Expande las columnas para llenar mejor el espacio disponible.",
+  "status.collapse": "Colapsar",
+  "status.has_audio": "Contiene archivos de audio",
+  "status.has_pictures": "Contiene imágenes adjuntas",
+  "status.has_preview_card": "Contiene una tarjeta de vista previa adjunta",
+  "status.has_video": "Contiene videos adjuntos",
+  "status.in_reply_to": "Esta publicación es una respuesta",
+  "status.is_poll": "Esta publicación es una encuesta",
+  "status.local_only": "Sólo visible para tu instancia",
+  "status.sensitive_toggle": "Haga clic para ver",
+  "status.uncollapse": "Descolapsar",
+  "web_app_crash.change_your_settings": "Cambiar las {settings}",
+  "web_app_crash.content": "Puedes probar lo siguiente:",
+  "web_app_crash.debug_info": "Información de depuración",
+  "web_app_crash.disable_addons": "Desactivar complementos del navegador o herramientas de traducción integradas",
+  "web_app_crash.issue_tracker": "rastreador de problemas",
+  "web_app_crash.reload": "Recargar",
+  "web_app_crash.reload_page": "{reload} la página actual",
+  "web_app_crash.report_issue": "Reportar un bug en el {issuetracker}",
+  "web_app_crash.settings": "configuraciones",
+  "web_app_crash.title": "Lo sentimos, pero algo salió mal con la app de Mastodon."
+}
diff --git a/app/javascript/flavours/glitch/locales/es-MX.json b/app/javascript/flavours/glitch/locales/es-MX.json
new file mode 100644
index 000000000..0251ece62
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/es-MX.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.",
+  "account.add_account_note": "Añadir nota para @{name}",
+  "account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.",
+  "account.follows": "Seguir",
+  "account.joined": "Unido {date}",
+  "account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
+  "account.view_full_profile": "Ver perfil completo",
+  "account_note.cancel": "Cancelar",
+  "account_note.edit": "Editar",
+  "account_note.glitch_placeholder": "No se proporcionó comentario alguno",
+  "account_note.save": "Guardar",
+  "advanced_options.icon_title": "Opciones avanzadas",
+  "advanced_options.local-only.long": "No publicar a otras instancias",
+  "advanced_options.local-only.short": "Local",
+  "advanced_options.local-only.tooltip": "Este toot es local",
+  "advanced_options.threaded_mode.long": "Al publicar abre automáticamente una respuesta",
+  "advanced_options.threaded_mode.short": "Modo hilo",
+  "advanced_options.threaded_mode.tooltip": "Modo hilo habilitado",
+  "boost_modal.missing_description": "Esta publicación contiene medios sin descripción",
+  "column.favourited_by": "Marcado como favorito por",
+  "column.heading": "Misc",
+  "column.reblogged_by": "Impulsado por",
+  "column.subheading": "Opciones misceláneas",
+  "column_header.profile": "Perfil",
+  "column_subheading.lists": "Listas",
+  "column_subheading.navigation": "Navegación",
+  "community.column_settings.allow_local_only": "Mostrar sólo toots locales",
+  "compose.attach": "Adjuntar...",
+  "compose.attach.doodle": "Dibujar algo",
+  "compose.attach.upload": "Subir un archivo",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Texto plano",
+  "compose_form.poll.multiple_choices": "Permitir múltiples opciones",
+  "compose_form.poll.single_choice": "Permitir sólo una opción",
+  "compose_form.spoiler": "Esconder el texto detrás de la advertencia",
+  "confirmation_modal.do_not_ask_again": "No preguntar por la confirmación de nuevo",
+  "confirmations.deprecated_settings.confirm": "Usar las preferencias de Mastodon",
+  "confirmations.deprecated_settings.message": "Algunas de las {app_settings} de glitch-soc, específicas para el dispositivo que estás usando han sido reemplazadas en las {preferences} de Mastodon y serán sobreescritas:",
+  "confirmations.missing_media_description.confirm": "Enviar de todos modos",
+  "confirmations.missing_media_description.edit": "Editar medios",
+  "confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
+  "confirmations.unfilter.author": "Publicado por",
+  "confirmations.unfilter.confirm": "Mostrar",
+  "confirmations.unfilter.edit_filter": "Editar filtro",
+  "confirmations.unfilter.filters": "Coincidencia con {count, plural, one {filtro} other {filtros}}",
+  "content-type.change": "Tipo de contenido",
+  "direct.group_by_conversations": "Agrupar por conversación",
+  "endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
+  "favourite_modal.combo": "Puedes presionar {combo} para omitir esto la próxima vez",
+  "getting_started.onboarding": "Paseo inicial",
+  "home.column_settings.advanced": "Avanzado",
+  "home.column_settings.filter_regex": "Filtrar por expresiones regulares",
+  "home.column_settings.show_direct": "Mostrar mensajes directos",
+  "home.settings": "Configuraciones de columna",
+  "keyboard_shortcuts.bookmark": "a marcadores",
+  "keyboard_shortcuts.secondary_toot": "para enviar un toot usando lac onfiguración de privacidad secundaria",
+  "keyboard_shortcuts.toggle_collapse": "para colapsar/descolapsar toots",
+  "layout.auto": "Automático",
+  "layout.desktop": "Escritorio",
+  "layout.hint.auto": "Seleccionar un diseño automáticamente basado en \"Habilitar interface web avanzada\" y tamaño de pantalla",
+  "layout.hint.desktop": "Utiliza el diseño multi-columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
+  "layout.hint.single": "Utiliza el diseño de una columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
+  "layout.single": "Móvil",
+  "media_gallery.sensitive": "Sensible",
+  "moved_to_warning": "Esta cuenta está marcada como movida a {moved_to_link}, y por lo tanto no aceptará nuevos seguimientos.",
+  "navigation_bar.app_settings": "Ajustes de aplicación",
+  "navigation_bar.featured_users": "Usuarios destacados",
+  "navigation_bar.keyboard_shortcuts": "Atajos de teclado",
+  "navigation_bar.misc": "Misc",
+  "notification.markForDeletion": "Marcar para borrar",
+  "notification_purge.btn_all": "Seleccionar\ntodo",
+  "notification_purge.btn_apply": "Borrar\nselección",
+  "notification_purge.btn_invert": "Invertir\nselección",
+  "notification_purge.btn_none": "Seleccionar\nnada",
+  "notification_purge.start": "Entrar en modo de limpieza de notificaciones",
+  "notifications.marked_clear": "Limpiar notificaciones seleccionadas",
+  "notifications.marked_clear_confirmation": "¿Deseas borrar permanentemente todas las notificaciones seleccionadas?",
+  "onboarding.done": "Hecho",
+  "onboarding.next": "Siguiente",
+  "onboarding.page_five.public_timelines": "La línea de tiempo local muestra mensajes públicos de todos en {domain}. La línea de tiempo federada muestra mensajes públicos de todos aquellos que en {domain} siguen a otros servidores. Estas son las líneas cronológicas públicas, una gran manera de descubrir gente nueva.",
+  "onboarding.page_four.home": "La línea de tiempo principal muestra los mensajes de la gente que sigues.",
+  "onboarding.page_four.notifications": "La columna de notificaciones muestra cuando alguien interactúa contigo.",
+  "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.handle": "Estás en {domain}, así que tu alias completo es {handle}",
+  "onboarding.page_one.welcome": "¡Bienvenidx a {domain}!",
+  "onboarding.page_six.admin": "El administrador de tu instancia es {admin}.",
+  "onboarding.page_six.almost_done": "Casi listo...",
+  "onboarding.page_six.appetoot": "¡A tootear!",
+  "onboarding.page_six.apps_available": "Hay {apps} disponibles para iOS, Android y otras plataformas.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "normas de la comunidad",
+  "onboarding.page_six.read_guidelines": "¡Por favor lee las {guidelines} de {domain}!",
+  "onboarding.page_six.various_app": "aplicaciones para móviles",
+  "onboarding.page_three.profile": "Edita tu perfil para cambiar tu avatar, biografía y nombre para mostrar. Ahí, también encontrarás otras preferencias.",
+  "onboarding.page_three.search": "Usa la barra de búsqueda para encontrar gente y mirar las etiquetas (hashtags), como {illustration} y {introductions}. Para buscar a una persona que no esté en esta instancia, utiliza su alias completo.",
+  "onboarding.page_two.compose": "Escribe mensajes desde la columna de composición. Puedes subir imágenes, cambiar la configuración de privacidad y añadir advertencias de contenido con los iconos de abajo.",
+  "onboarding.skip": "Saltar",
+  "settings.always_show_spoilers_field": "Siempre mostrar el campo de advertencia de contenido",
+  "settings.auto_collapse": "Colapsar automáticamente",
+  "settings.auto_collapse_all": "Todo",
+  "settings.auto_collapse_lengthy": "Toots largos",
+  "settings.auto_collapse_media": "Toots con medios",
+  "settings.auto_collapse_height": "Altura (en pixeles) para que un toot sea considerado largo",
+  "settings.auto_collapse_notifications": "Notificaciones",
+  "settings.auto_collapse_reblogs": "Retoots",
+  "settings.auto_collapse_replies": "Respuestas",
+  "settings.close": "Cerrar",
+  "settings.collapsed_statuses": "Toots colapsados",
+  "settings.compose_box_opts": "Cuadro de redacción",
+  "settings.confirm_before_clearing_draft": "Mostrar diálogo de confirmación antes de sobreescribir un mensaje estabas escribiendo",
+  "settings.confirm_boost_missing_media_description": "Mostrar diálogo de confirmación antes de retootear publicaciones con medios sin descripción",
+  "settings.confirm_missing_media_description": "Mostrar diálogo de confirmación antes de publicar toots con medios sin descripción",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Regexp (expresión regular)",
+  "settings.content_warnings_filter": "No descolapsar estas advertencias de contenido:",
+  "settings.content_warnings_media_outside": "Mostrar archivos adjuntos fuera de las advertencias de contenido",
+  "settings.content_warnings_media_outside_hint": "Reproduce el comportamiento normal de Mastodon teniendo al tener el interruptor de advertencia de contenido activado, no afectando los archivos adjuntos",
+  "settings.content_warnings_shared_state": "Mostrar/ocultar el contenido de todas las copias a la vez",
+  "settings.content_warnings_shared_state_hint": "Reproduce el comportamiento normal de Mastodon al hacer que el botón Advertencia de contenido afecte a todas las copias de un mensaje a la vez. Esto evitará el colapso automático de cualquier copia de un toot con CW desplegado",
+  "settings.content_warnings_unfold_opts": "Opciones de Auto-desplegado",
+  "settings.deprecated_setting": "Esta configuración ahora está controlada desde {settings_page_link} de Mastodon",
+  "settings.enable_collapsed": "Habilitar toots colapsados",
+  "settings.enable_collapsed_hint": "Las publicaciones colapsadas tienen partes de su contenido ocultas para ocupar menos espacio en pantalla. Esto es distinto de la función Advertencia de Contenido",
+  "settings.enable_content_warnings_auto_unfold": "Descolapsar automáticamente advertencias de contenido",
+  "settings.general": "General",
+  "settings.hicolor_privacy_icons": "Íconos de privacidad más visibles",
+  "settings.hicolor_privacy_icons.hint": "Mostrar iconos de privacidad en colores brillantes y fácilmente distinguibles",
+  "settings.image_backgrounds": "Fondos de imágenes",
+  "settings.image_backgrounds_media": "Vista previa de medios de toots colapsados",
+  "settings.image_backgrounds_media_hint": "Si la publicación tiene algún archivo adjunto, utilice el primero como fondo",
+  "settings.image_backgrounds_users": "Darle fondo de imagen a toots colapsados",
+  "settings.inline_preview_cards": "Vista previa para enlaces externos",
+  "settings.layout": "Diseño",
+  "settings.layout_opts": "Opciones de diseño",
+  "settings.media": "Medios",
+  "settings.media_fullwidth": "Ancho completo al mostrar medios ",
+  "settings.media_letterbox": "Mantener proporciones al mostrar medios",
+  "settings.media_letterbox_hint": "Escalar medios para que llenen el espacio del contenedor sin cambiar sus proporciones sin recortarlos",
+  "settings.media_reveal_behind_cw": "Siempre mostrar medios sensibles dentro de las advertencias de contenido",
+  "settings.notifications.favicon_badge": "Marcador de notificaciones en el favicon",
+  "settings.notifications.favicon_badge.hint": "Muestra un marcador de notificaciones sin leer en el favicon",
+  "settings.notifications.tab_badge": "Marcador de notificaciones no leídas",
+  "settings.notifications.tab_badge.hint": "Muestra un marcador de notificaciones sin leer en el ícono de notificaciones cuando dicha columna no está abierta",
+  "settings.notifications_opts": "Opciones de notificaciones",
+  "settings.pop_in_left": "Izquierda",
+  "settings.pop_in_player": "Habilitar reproductor emergente",
+  "settings.pop_in_position": "Posición del reproductor:",
+  "settings.pop_in_right": "Derecha",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "Anteponer \"re: \" a las advertencias de contenido al responder",
+  "settings.preselect_on_reply": "Preseleccionar nombres de usuarix al responder",
+  "settings.preselect_on_reply_hint": "Al responder a conversaciones con múltiples participantes, preselecciona los nombres de usuarix subsecuentes del/la primerx",
+  "settings.rewrite_mentions": "Reescribir menciones in publicaciones mostradas",
+  "settings.rewrite_mentions_acct": "Reescribir con nombre de usuarix y dominio (para cuentas remotas)",
+  "settings.rewrite_mentions_no": "No reescribir menciones",
+  "settings.rewrite_mentions_username": "Reescribir con nombre de usuarix",
+  "settings.shared_settings_link": "preferencias de usuario",
+  "settings.show_action_bar": "Mostrar botones de acción en toots colapsados",
+  "settings.show_content_type_choice": "Mostrar selección de tipo de contenido al crear toots",
+  "settings.show_reply_counter": "Mostrar un conteo estimado de respuestas",
+  "settings.side_arm": "Botón secundario:",
+  "settings.side_arm.none": "Ninguno",
+  "settings.side_arm_reply_mode": "Al responder a un toot, el botón de toot secundario debe:",
+  "settings.side_arm_reply_mode.copy": "Copiar opción de privacidad del toot al que estás respondiendo",
+  "settings.side_arm_reply_mode.keep": "Conservar opción de privacidad",
+  "settings.side_arm_reply_mode.restrict": "Restringir la opción de privacidad a la misma del toot al que estás respondiendo",
+  "settings.status_icons": "Iconos del toot",
+  "settings.status_icons_language": "Indicador de lenguaje",
+  "settings.status_icons_local_only": "Indicador de sólo local",
+  "settings.status_icons_media": "Indicadores de medios y encuestas",
+  "settings.status_icons_reply": "Indicador de respuesta",
+  "settings.status_icons_visibility": "Indicador de privacidad de toot",
+  "settings.swipe_to_change_columns": "Permitir deslizar para cambiar columnas (Sólo en móvil)",
+  "settings.tag_misleading_links": "Marcar enlaces engañosos",
+  "settings.tag_misleading_links.hint": "Añadir una indicación visual indicando el destino de los enlace que no los mencionen explícitamente",
+  "settings.wide_view": "Vista amplia (solo modo de escritorio)",
+  "settings.wide_view_hint": "Expande las columnas para llenar mejor el espacio disponible.",
+  "status.collapse": "Colapsar",
+  "status.has_audio": "Contiene archivos de audio",
+  "status.has_pictures": "Contiene imágenes adjuntas",
+  "status.has_preview_card": "Contiene una tarjeta de vista previa adjunta",
+  "status.has_video": "Contiene videos adjuntos",
+  "status.in_reply_to": "Esta publicación es una respuesta",
+  "status.is_poll": "Esta publicación es una encuesta",
+  "status.local_only": "Sólo visible para tu instancia",
+  "status.sensitive_toggle": "Haga clic para ver",
+  "status.uncollapse": "Descolapsar",
+  "web_app_crash.change_your_settings": "Cambiar las {settings}",
+  "web_app_crash.content": "Puedes probar lo siguiente:",
+  "web_app_crash.debug_info": "Información de depuración",
+  "web_app_crash.disable_addons": "Desactivar complementos del navegador o herramientas de traducción integradas",
+  "web_app_crash.issue_tracker": "rastreador de problemas",
+  "web_app_crash.reload": "Recargar",
+  "web_app_crash.reload_page": "{reload} la página actual",
+  "web_app_crash.report_issue": "Reportar un bug en el {issuetracker}",
+  "web_app_crash.settings": "configuraciones",
+  "web_app_crash.title": "Lo sentimos, pero algo salió mal con la app de Mastodon."
+}
diff --git a/app/javascript/flavours/glitch/locales/es.json b/app/javascript/flavours/glitch/locales/es.json
new file mode 100644
index 000000000..6033cea38
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/es.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "Glitch-soc es software gratuito, de código abierto, bifurcado de Mastodon.",
+  "account.add_account_note": "Añadir nota para @{name}",
+  "account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.",
+  "account.follows": "Sigue",
+  "account.joined": "Unido el {date}",
+  "account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
+  "account.view_full_profile": "Ver perfil completo",
+  "account_note.cancel": "Cancelar",
+  "account_note.edit": "Editar",
+  "account_note.glitch_placeholder": "No se proporcionó comentario alguno",
+  "account_note.save": "Guardar",
+  "advanced_options.icon_title": "Opciones avanzadas",
+  "advanced_options.local-only.long": "No publicar a otras instancias",
+  "advanced_options.local-only.short": "Sólo local",
+  "advanced_options.local-only.tooltip": "Esta publicación es sólo local",
+  "advanced_options.threaded_mode.long": "Abre automáticamente una respuesta al publicar",
+  "advanced_options.threaded_mode.short": "Modo hilo",
+  "advanced_options.threaded_mode.tooltip": "Modo hilo habilitado",
+  "boost_modal.missing_description": "Esta publicación contiene medios sin descripción",
+  "column.favourited_by": "Marcado como favorito por",
+  "column.heading": "Misc",
+  "column.reblogged_by": "Impulsado por",
+  "column.subheading": "Opciones misceláneas",
+  "column_header.profile": "Perfil",
+  "column_subheading.lists": "Listas",
+  "column_subheading.navigation": "Navegación",
+  "community.column_settings.allow_local_only": "Mostrar sólo toots locales",
+  "compose.attach": "Adjuntar...",
+  "compose.attach.doodle": "Dibujar algo",
+  "compose.attach.upload": "Subir un archivo",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Texto plano",
+  "compose_form.poll.multiple_choices": "Permitir múltiples opciones",
+  "compose_form.poll.single_choice": "Permitir sólo una opción",
+  "compose_form.spoiler": "Esconder el texto detrás de la advertencia",
+  "confirmation_modal.do_not_ask_again": "No preguntar por la confirmación de nuevo",
+  "confirmations.deprecated_settings.confirm": "Usar las preferencias de Mastodon",
+  "confirmations.deprecated_settings.message": "Algunas de las {app_settings} de glitch-soc, específicas para el dispositivo que estás usando han sido reemplazadas en las {preferences} de Mastodon y serán sobreescritas:",
+  "confirmations.missing_media_description.confirm": "Enviar de todos modos",
+  "confirmations.missing_media_description.edit": "Editar medios",
+  "confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
+  "confirmations.unfilter.author": "Autor",
+  "confirmations.unfilter.confirm": "Mostrar",
+  "confirmations.unfilter.edit_filter": "Editar filtro",
+  "confirmations.unfilter.filters": "Coincidiendo {count, plural, one {filtro} other {filtros}}",
+  "content-type.change": "Tipo de contenido",
+  "direct.group_by_conversations": "Agrupar por conversación",
+  "endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
+  "favourite_modal.combo": "Puedes presionar {combo} para omitir esto la próxima vez",
+  "getting_started.onboarding": "Paseo inicial",
+  "home.column_settings.advanced": "Avanzado",
+  "home.column_settings.filter_regex": "Filtrar por expresiones regulares",
+  "home.column_settings.show_direct": "Mostrar mensajes directos",
+  "home.settings": "Configuraciones de columna",
+  "keyboard_shortcuts.bookmark": "a marcadores",
+  "keyboard_shortcuts.secondary_toot": "para enviar un toot usando lac onfiguración de privacidad secundaria",
+  "keyboard_shortcuts.toggle_collapse": "para colapsar/descolapsar toots",
+  "layout.auto": "Automático",
+  "layout.desktop": "Escritorio",
+  "layout.hint.auto": "Seleccionar un diseño automáticamente basado en \"Habilitar interface web avanzada\" y el tamaño de la pantalla.",
+  "layout.hint.desktop": "Utiliza el diseño multi-columna sin importar \"Habilitar interface web avanzada\" o el tamaño de la pantalla.",
+  "layout.hint.single": "Utiliza el diseño de una columna sin importar \"Habilitar interface web avanzada\" o el tamaño de la pantalla.",
+  "layout.single": "Móvil",
+  "media_gallery.sensitive": "Sensible",
+  "moved_to_warning": "Esta cuenta está marcada como movida a {moved_to_link}, y por lo tanto no aceptará nuevos seguimientos.",
+  "navigation_bar.app_settings": "Ajustes de la aplicación",
+  "navigation_bar.featured_users": "Usuarios destacados",
+  "navigation_bar.keyboard_shortcuts": "Atajos de teclado",
+  "navigation_bar.misc": "Misc",
+  "notification.markForDeletion": "Marcar para borrado",
+  "notification_purge.btn_all": "Seleccionar\ntodo",
+  "notification_purge.btn_apply": "Borrar\nselección",
+  "notification_purge.btn_invert": "Invertir\nselección",
+  "notification_purge.btn_none": "Seleccionar\nninguno",
+  "notification_purge.start": "Entrar en modo de limpieza de notificaciones",
+  "notifications.marked_clear": "Limpiar las notificaciones seleccionadas",
+  "notifications.marked_clear_confirmation": "¿Estás seguro de borrar permanentemente todas las notificaciones seleccionadas?",
+  "onboarding.done": "Hecho",
+  "onboarding.next": "Siguiente",
+  "onboarding.page_five.public_timelines": "La línea de tiempo local muestra mensajes públicos de todos en {domain}. La línea de tiempo federada muestra mensajes públicos de todos aquellos que en {domain} siguen a otros servidores. Estas son las líneas cronológicas públicas, una gran manera de descubrir gente nueva.",
+  "onboarding.page_four.home": "La línea de tiempo principal muestra los mensajes de la gente que sigues.",
+  "onboarding.page_four.notifications": "La columna de notificaciones muestra cuando alguien interactúa contigo.",
+  "onboarding.page_one.federation": "{domain} es una \"instancia\" de Mastodon. Mastodon es una red de servidores independientes uniéndose para crear una red social más grande. A estos servidores los llamamos instancias.",
+  "onboarding.page_one.handle": "Estás en {domain}, así que tu alias completo es {handle}",
+  "onboarding.page_one.welcome": "¡Bienvenido a {domain}!",
+  "onboarding.page_six.admin": "El administrador de tu instancia es {admin}.",
+  "onboarding.page_six.almost_done": "Casi listo...",
+  "onboarding.page_six.appetoot": "¡A tootear!",
+  "onboarding.page_six.apps_available": "Hay {apps} disponibles para iOS, Android y otras plataformas.",
+  "onboarding.page_six.github": "{domain} usa Glitchsoc. Glitchsoc es una bifurcación {fork} amigable de {Mastodon}, y es compatible con cualquier instancia o aplicación de Mastodon. Glitchsoc es completamente gratuito y de código abierto. Puedes reportar errores, solicitar funciones o contribuir al código en {github}.",
+  "onboarding.page_six.guidelines": "normas de la comunidad",
+  "onboarding.page_six.read_guidelines": "¡Por favor lee las {guidelines} de {domain}!",
+  "onboarding.page_six.various_app": "aplicaciones para móviles",
+  "onboarding.page_three.profile": "Edita tu perfil para cambiar tu avatar, biografía y nombre para mostrar. Ahí, también encontrarás otras preferencias.",
+  "onboarding.page_three.search": "Usa la barra de búsqueda para encontrar gente y mirar las etiquetas (hashtags), como {illustration} y {introductions}. Para buscar a una persona que no esté en esta instancia, utiliza su alias completo.",
+  "onboarding.page_two.compose": "Escribe mensajes desde la columna de composición. Puedes subir imágenes, cambiar la configuración de privacidad y añadir advertencias de contenido con los iconos de abajo.",
+  "onboarding.skip": "Saltar",
+  "settings.always_show_spoilers_field": "Siempre mostrar el campo de advertencia de contenido",
+  "settings.auto_collapse": "Colapsar automáticamente",
+  "settings.auto_collapse_all": "Todo",
+  "settings.auto_collapse_lengthy": "Publicaciones largas",
+  "settings.auto_collapse_media": "Publicaciones multimedia",
+  "settings.auto_collapse_height": "Altura (en pixeles) para que un toot sea considerado largo",
+  "settings.auto_collapse_notifications": "Notificaciones",
+  "settings.auto_collapse_reblogs": "Impulsos",
+  "settings.auto_collapse_replies": "Respuestas",
+  "settings.close": "Cerrar",
+  "settings.collapsed_statuses": "Publicaciones colapsadas",
+  "settings.compose_box_opts": "Cuadro de redacción",
+  "settings.confirm_before_clearing_draft": "Mostrar diálogo de confirmación antes de sobreescribir el mensaje siendo redactado",
+  "settings.confirm_boost_missing_media_description": "Mostrar diálogo de confirmación antes de impulsar publicaciones con medios sin descripciones",
+  "settings.confirm_missing_media_description": "Mostrar diálogo de confirmación antes de enviar publicaciones con medios sin descripciones",
+  "settings.content_warnings": "Advertencias de contenido",
+  "settings.content_warnings.regexp": "Regexp (expresión regular)",
+  "settings.content_warnings_filter": "No descolapsar estas advertencias de contenido:",
+  "settings.content_warnings_media_outside": "Mostrar archivos adjuntos fuera de las advertencias de contenido",
+  "settings.content_warnings_media_outside_hint": "Reproduce el comportamiento normal de Mastodon teniendo al tener el interruptor de advertencia de contenido activado, no afectando los archivos adjuntos",
+  "settings.content_warnings_shared_state": "Mostrar/ocultar el contenido de todas las copias a la vez",
+  "settings.content_warnings_shared_state_hint": "Reproduce el comportamiento normal de Mastodon al hacer que el botón Advertencia de contenido afecte a todas las copias de un mensaje a la vez. Esto evitará el colapso automático de cualquier copia de un toot con CW desplegado",
+  "settings.content_warnings_unfold_opts": "Opciones de Auto-desplegado",
+  "settings.deprecated_setting": "Esta configuración ahora está controlada desde {settings_page_link} de Mastodon",
+  "settings.enable_collapsed": "Habilitar publicaciones colapsadas",
+  "settings.enable_collapsed_hint": "Las publicaciones colapsadas tienen partes de su contenido ocultas para ocupar menos espacio en pantalla. Esto es distinto de la función Advertencia de Contenido",
+  "settings.enable_content_warnings_auto_unfold": "Desplegar automáticamente advertencias de contenido",
+  "settings.general": "General",
+  "settings.hicolor_privacy_icons": "Íconos de privacidad más visibles",
+  "settings.hicolor_privacy_icons.hint": "Mostrar iconos de privacidad en colores brillantes y fácilmente distinguibles",
+  "settings.image_backgrounds": "Fondos de imágenes",
+  "settings.image_backgrounds_media": "Vista previa de medios de publicaciones colapsadas",
+  "settings.image_backgrounds_media_hint": "Si la publicación tiene algún archivo adjunto, utilice el primero como fondo",
+  "settings.image_backgrounds_users": "Darle fondo de imagen a publicaciones colapsadas",
+  "settings.inline_preview_cards": "Vista previa para enlaces externos",
+  "settings.layout": "Diseño",
+  "settings.layout_opts": "Opciones de diseño",
+  "settings.media": "Medios",
+  "settings.media_fullwidth": "Ancho completo al mostrar medios ",
+  "settings.media_letterbox": "Mantener proporciones al mostrar medios",
+  "settings.media_letterbox_hint": "Escalar medios para que llenen el espacio del contenedor sin cambiar sus proporciones sin recortarlos",
+  "settings.media_reveal_behind_cw": "Siempre mostrar medios sensibles dentro de las advertencias de contenido",
+  "settings.notifications.favicon_badge": "Marcador de notificaciones en el favicon",
+  "settings.notifications.favicon_badge.hint": "Muestra un marcador de notificaciones sin leer en el favicon",
+  "settings.notifications.tab_badge": "Marcador de notificaciones no leídas",
+  "settings.notifications.tab_badge.hint": "Muestra un marcador de notificaciones sin leer en el ícono de notificaciones cuando dicha columna no está abierta",
+  "settings.notifications_opts": "Opciones de notificaciones",
+  "settings.pop_in_left": "Izquierda",
+  "settings.pop_in_player": "Habilitar reproductor emergente",
+  "settings.pop_in_position": "Posición del reproductor:",
+  "settings.pop_in_right": "Derecha",
+  "settings.preferences": "Preferencias del usuario",
+  "settings.prepend_cw_re": "Anteponer \"re: \" a las advertencias de contenido al responder",
+  "settings.preselect_on_reply": "Preseleccionar nombres de usuarios al responder",
+  "settings.preselect_on_reply_hint": "Al responder a conversaciones con múltiples participantes, preselecciona los nombres de usuario subsecuentes al primero",
+  "settings.rewrite_mentions": "Reescribir menciones in publicaciones mostradas",
+  "settings.rewrite_mentions_acct": "Reescribir con el nombre de usuario y dominio (para las cuentas remotas)",
+  "settings.rewrite_mentions_no": "No reescribir menciones",
+  "settings.rewrite_mentions_username": "Reescribir con nombre de usuario",
+  "settings.shared_settings_link": "preferencias de usuario",
+  "settings.show_action_bar": "Mostrar botones de acción en publicaciones colapsadas",
+  "settings.show_content_type_choice": "Mostrar selección de tipo de contenido al crear publicaciones",
+  "settings.show_reply_counter": "Mostrar un conteo estimado de respuestas",
+  "settings.side_arm": "Botón secundario:",
+  "settings.side_arm.none": "Ninguno",
+  "settings.side_arm_reply_mode": "Al responder a una publicación, el botón de publicación secundario debe:",
+  "settings.side_arm_reply_mode.copy": "Copiar opción de privacidad de la publicación a la que estás respondiendo",
+  "settings.side_arm_reply_mode.keep": "Conservar opción de privacidad",
+  "settings.side_arm_reply_mode.restrict": "Restringir la opción de privacidad a la misma de la publicación a la que estás respondiendo",
+  "settings.status_icons": "Iconos del toot",
+  "settings.status_icons_language": "Indicador de lenguaje",
+  "settings.status_icons_local_only": "Indicador de sólo local",
+  "settings.status_icons_media": "Indicadores de medios y encuestas",
+  "settings.status_icons_reply": "Indicador de respuesta",
+  "settings.status_icons_visibility": "Indicador de privacidad de toot",
+  "settings.swipe_to_change_columns": "Permitir deslizar para cambiar columnas (Sólo en móvil)",
+  "settings.tag_misleading_links": "Marcar enlaces engañosos",
+  "settings.tag_misleading_links.hint": "Añadir una indicación visual indicando el destino de los enlace que no los mencionen explícitamente",
+  "settings.wide_view": "Vista amplia (solo modo de escritorio)",
+  "settings.wide_view_hint": "Expande las columnas para llenar mejor el espacio disponible.",
+  "status.collapse": "Colapsar",
+  "status.has_audio": "Contiene archivos de audio",
+  "status.has_pictures": "Contiene imágenes adjuntas",
+  "status.has_preview_card": "Contiene una tarjeta de vista previa adjunta",
+  "status.has_video": "Contiene videos adjuntos",
+  "status.in_reply_to": "Esta publicación es una respuesta",
+  "status.is_poll": "Esta publicación es una encuesta",
+  "status.local_only": "Sólo visible para tu instancia",
+  "status.sensitive_toggle": "Haga clic para ver",
+  "status.uncollapse": "Descolapsar",
+  "web_app_crash.change_your_settings": "Cambiar las {settings}",
+  "web_app_crash.content": "Puedes probar lo siguiente:",
+  "web_app_crash.debug_info": "Información de depuración",
+  "web_app_crash.disable_addons": "Desactivar complementos del navegador o herramientas de traducción integradas",
+  "web_app_crash.issue_tracker": "rastreador de problemas",
+  "web_app_crash.reload": "Recargar",
+  "web_app_crash.reload_page": "{reload} la página actual",
+  "web_app_crash.report_issue": "Reportar un bug en el {issuetracker}",
+  "web_app_crash.settings": "configuraciones",
+  "web_app_crash.title": "Lo sentimos, pero algo salió mal con la app de Mastodon."
+}
diff --git a/app/javascript/flavours/glitch/locales/et.json b/app/javascript/flavours/glitch/locales/et.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/et.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/eu.json b/app/javascript/flavours/glitch/locales/eu.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/eu.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/fa.json b/app/javascript/flavours/glitch/locales/fa.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fa.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/fi.json b/app/javascript/flavours/glitch/locales/fi.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fi.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/fo.json b/app/javascript/flavours/glitch/locales/fo.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fo.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/fr-QC.json b/app/javascript/flavours/glitch/locales/fr-QC.json
new file mode 100644
index 000000000..ec42f666d
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fr-QC.json
@@ -0,0 +1,199 @@
+{
+  "about.fork_disclaimer": "Glitch-soc est un logiciel gratuit et open source, fork de Mastodon.",
+  "account.add_account_note": "Ajouter une note pour @{name}",
+  "account.disclaimer_full": "Les informations ci-dessous peuvent être incomplètes.",
+  "account.follows": "Abonnements",
+  "account.joined": "Ici depuis {date}",
+  "account.suspended_disclaimer_full": "Cet utilisateur a été suspendu par un modérateur.",
+  "account.view_full_profile": "Voir le profil complet",
+  "account_note.cancel": "Annuler",
+  "account_note.edit": "Éditer",
+  "account_note.glitch_placeholder": "Aucun commentaire fourni",
+  "account_note.save": "Sauvegarder",
+  "advanced_options.icon_title": "Options avancées",
+  "advanced_options.local-only.long": "Ne pas envoyer aux autres instances",
+  "advanced_options.local-only.short": "Uniquement en local",
+  "advanced_options.local-only.tooltip": "Ce post est uniquement local",
+  "advanced_options.threaded_mode.long": "Ouvre automatiquement une réponse lors de la publication",
+  "advanced_options.threaded_mode.short": "Mode thread",
+  "advanced_options.threaded_mode.tooltip": "Mode thread activé",
+  "boost_modal.missing_description": "Ce post contient des médias sans description",
+  "column.favourited_by": "Ajouté en favori par",
+  "column.heading": "Divers",
+  "column.reblogged_by": "Partagé par",
+  "column.subheading": "Autres options",
+  "column_header.profile": "Profil",
+  "column_subheading.lists": "Listes",
+  "column_subheading.navigation": "Navigation",
+  "community.column_settings.allow_local_only": "Afficher seulement les posts locaux",
+  "compose.attach": "Joindre…",
+  "compose.attach.doodle": "Dessiner quelque chose",
+  "compose.attach.upload": "Téléverser un fichier",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Text brut",
+  "compose_form.poll.multiple_choices": "Choix multiples",
+  "compose_form.poll.single_choice": "Choix unique",
+  "compose_form.spoiler": "Cacher le texte derrière un avertissement",
+  "confirmation_modal.do_not_ask_again": "Ne plus demander confirmation",
+  "confirmations.deprecated_settings.confirm": "Utiliser les préférences de Mastodon",
+  "confirmations.deprecated_settings.message": "Certaines {app_settings} de glitch-soc que vous utilisez ont été remplacées par les {preferences} de Mastodon et seront remplacées :",
+  "confirmations.missing_media_description.confirm": "Envoyer quand même",
+  "confirmations.missing_media_description.edit": "Modifier le média",
+  "confirmations.missing_media_description.message": "Au moins un média joint manque d'une description. Pensez à décrire tous les médias attachés pour les malvoyant·e·s avant de publier votre post.",
+  "confirmations.unfilter.author": "Auteur",
+  "confirmations.unfilter.confirm": "Afficher",
+  "confirmations.unfilter.edit_filter": "Modifier le filtre",
+  "confirmations.unfilter.filters": "Correspondance avec {count, plural, one {un filtre} other {plusieurs filtres}}",
+  "content-type.change": "Type de contenu",
+  "direct.group_by_conversations": "Grouper par conversation",
+  "endorsed_accounts_editor.endorsed_accounts": "Comptes mis en avant",
+  "favourite_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci la prochaine fois",
+  "getting_started.onboarding": "Montre-moi les alentours",
+  "home.column_settings.advanced": "Avancé",
+  "home.column_settings.filter_regex": "Filtrer par expression régulière",
+  "home.column_settings.show_direct": "Afficher les MPs",
+  "home.settings": "Paramètres de la colonne",
+  "keyboard_shortcuts.bookmark": "ajouter aux marque-pages",
+  "keyboard_shortcuts.secondary_toot": "Envoyer le post en utilisant les paramètres secondaires de confidentialité",
+  "keyboard_shortcuts.toggle_collapse": "Plier/déplier les posts",
+  "layout.auto": "Auto",
+  "layout.desktop": "Ordinateur",
+  "layout.hint.auto": "Choisir automatiquement la mise en page selon l'option \"Activer l'interface Web avancée\" et la taille d'écran.",
+  "layout.hint.desktop": "Utiliser la mise en page en plusieurs colonnes indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
+  "layout.hint.single": "Utiliser la mise en page à colonne unique indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
+  "layout.single": "Téléphone",
+  "media_gallery.sensitive": "Sensible",
+  "moved_to_warning": "Ce compte a déménagé vers {moved_to_link} et ne peut donc plus accepter de nouveaux abonné·e·s.",
+  "navigation_bar.app_settings": "Paramètres de l'application",
+  "navigation_bar.featured_users": "Utilisateurs mis en avant",
+  "navigation_bar.keyboard_shortcuts": "Raccourcis clavier",
+  "navigation_bar.misc": "Autres",
+  "notification.markForDeletion": "Ajouter aux éléments à supprimer",
+  "notification_purge.btn_all": "Sélectionner\ntout",
+  "notification_purge.btn_apply": "Effacer\nla sélection",
+  "notification_purge.btn_invert": "Inverser\nla sélection",
+  "notification_purge.btn_none": "Annuler\nla sélection",
+  "notification_purge.start": "Activer le mode de nettoyage des notifications",
+  "notifications.marked_clear": "Effacer les notifications sélectionnées",
+  "notifications.marked_clear_confirmation": "Voulez-vous vraiment effacer de manière permanente toutes les notifications sélectionnées ?",
+  "onboarding.done": "Terminé",
+  "onboarding.next": "Suivant",
+  "onboarding.page_five.public_timelines": "Le fil local affiche les posts publics de tout le monde sur {domain}. Le fil global affiche les posts publics de tous les comptes que les personnes de {domain} suivent. Ce sont les fils publics, une façon formidable de découvrir de nouvelles personnes.",
+  "onboarding.page_four.home": "L'accueil affiche les posts des personnes que vous suivez.",
+  "onboarding.page_four.notifications": "La colonne de notifications vous montre lorsque quelqu'un interagit avec vous.",
+  "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.handle": "Vous êtes sur {domain}, donc votre nom d'utilisateur complet est {handle}",
+  "onboarding.page_one.welcome": "Bievenue sur {domain} !",
+  "onboarding.page_six.admin": "Votre admin d’instance est {admin}.",
+  "onboarding.page_six.almost_done": "C'est bientôt fini...",
+  "onboarding.page_six.appetoot": "Bon appétoot !",
+  "onboarding.page_six.apps_available": "Il y a des {apps} disponibles pour iOS, Android et d'autres plateformes.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "règles de la communauté",
+  "onboarding.page_six.read_guidelines": "Veuillez lire les {guidelines} de {domain} !",
+  "onboarding.page_six.various_app": "applications mobiles",
+  "onboarding.page_three.profile": "Modifiez votre profil pour changer votre avatar, biographie et nom public. Ici, vous trouverez également d'autres options.",
+  "onboarding.page_three.search": "Utilisez la barre de recherche pour trouver des personnes et regarder les hashtags comme {illustration} et {introductions}. Pour chercher une personne n'étant pas sur cette instance, utilisez son nom d'utilisateur complet.",
+  "onboarding.page_two.compose": "Écrivez des posts depuis la colonne de rédaction. Vous pouvez téléverser des images, changer la confidentialité et ajouter des avertissements de contenu avec les boutons ci-dessous.",
+  "onboarding.skip": "Passer",
+  "settings.always_show_spoilers_field": "Toujours activer le champ de rédaction de l'avertissement de contenu",
+  "settings.auto_collapse": "Repliage automatique",
+  "settings.auto_collapse_all": "Tout",
+  "settings.auto_collapse_lengthy": "Posts longs",
+  "settings.auto_collapse_media": "Posts avec média",
+  "settings.auto_collapse_notifications": "Notifications",
+  "settings.auto_collapse_reblogs": "Boosts",
+  "settings.auto_collapse_replies": "Réponses",
+  "settings.close": "Fermer",
+  "settings.collapsed_statuses": "Posts repliés",
+  "settings.compose_box_opts": "Zone de rédaction",
+  "settings.confirm_before_clearing_draft": "Afficher une fenêtre de confirmation avant d'écraser le message en cours de rédaction",
+  "settings.confirm_boost_missing_media_description": "Afficher une fenêtre de confirmation avant de partager des posts manquant de description des médias",
+  "settings.confirm_missing_media_description": "Afficher une fenêtre de confirmation avant de publier des posts manquant de description de média",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Expression rationnelle",
+  "settings.content_warnings_filter": "Avertissement de contenu à ne pas automatiquement déplier :",
+  "settings.content_warnings_media_outside": "Afficher les médias en dehors des avertissements de contenu",
+  "settings.content_warnings_media_outside_hint": "Reproduit le comportement par défaut de Mastodon, les médias attachés ne sont plus affectés par le bouton d'affichage d'un post avec avertissement",
+  "settings.content_warnings_shared_state": "Affiche/cache le contenu de toutes les copies à la fois",
+  "settings.content_warnings_shared_state_hint": "Reproduit le comportement par défaut de Mastodon, le bouton d'avertissement de contenu affecte toutes les copies d'un post à la fois. Cela empêchera le repliement automatique de n'importe quelle copie d'un post avec un avertissement déplié",
+  "settings.content_warnings_unfold_opts": "Options de dépliement automatique",
+  "settings.deprecated_setting": "Cette option est maintenant définie par les {settings_page_link} de Mastodon",
+  "settings.enable_collapsed": "Activer le repliement des posts",
+  "settings.enable_collapsed_hint": "Les posts repliés ont une partie de leur contenu caché pour libérer de l'espace sur l'écran. C'est une option différente de l'avertissement de contenu",
+  "settings.enable_content_warnings_auto_unfold": "Déplier automatiquement les avertissements de contenu",
+  "settings.general": "Général",
+  "settings.hicolor_privacy_icons": "Indicateurs de confidentialité en couleurs",
+  "settings.hicolor_privacy_icons.hint": "Affiche les indicateurs de confidentialité dans des couleurs facilement distinguables",
+  "settings.image_backgrounds": "Images en arrière-plan",
+  "settings.image_backgrounds_media": "Prévisualiser les médias d'un post replié",
+  "settings.image_backgrounds_media_hint": "Si le post a un média attaché, utiliser le premier comme arrière-plan du post",
+  "settings.image_backgrounds_users": "Donner aux posts repliés une image en arrière-plan",
+  "settings.inline_preview_cards": "Cartes d'aperçu pour les liens externes",
+  "settings.layout": "Mise en page :",
+  "settings.layout_opts": "Mise en page",
+  "settings.media": "Média",
+  "settings.media_fullwidth": "Utiliser toute la largeur pour les aperçus",
+  "settings.media_letterbox": "Afficher les médias en Letterbox",
+  "settings.media_letterbox_hint": "Réduit le média et utilise une letterbox pour afficher l'image entière plutôt que de l'étirer et de la rogner",
+  "settings.media_reveal_behind_cw": "Toujours afficher les médias sensibles avec avertissement",
+  "settings.notifications.favicon_badge": "Badge de notifications non lues dans la favicon",
+  "settings.notifications.favicon_badge.hint": "Ajoute un badge dans la favicon pour alerter d'une notification non lue",
+  "settings.notifications.tab_badge": "Badge de notifications non lues",
+  "settings.notifications.tab_badge.hint": "Affiche un badge de notifications non lues dans les icônes des colonnes quand la colonne n'est pas ouverte",
+  "settings.notifications_opts": "Options des notifications",
+  "settings.pop_in_left": "Gauche",
+  "settings.pop_in_player": "Activer le lecteur pop-in",
+  "settings.pop_in_position": "Position du lecteur pop-in :",
+  "settings.pop_in_right": "Droite",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "Préfixer les avertissements avec \"re: \" lors d'une réponse",
+  "settings.preselect_on_reply": "Présélectionner les noms d’utilisateur·rices lors de la réponse",
+  "settings.preselect_on_reply_hint": "Présélectionner les noms d'utilisateurs après le premier lors d'une réponse à une conversation à plusieurs participants",
+  "settings.rewrite_mentions": "Réécrire les mentions dans les posts affichés",
+  "settings.rewrite_mentions_acct": "Réécrire avec le nom d'utilisateur·rice et le domaine (lorsque le compte est distant)",
+  "settings.rewrite_mentions_no": "Ne pas réécrire les mentions",
+  "settings.rewrite_mentions_username": "Réécrire avec le nom d’utilisateur·rice",
+  "settings.shared_settings_link": "préférences de l'utilisateur",
+  "settings.show_action_bar": "Afficher les boutons d'action dans les posts repliés",
+  "settings.show_content_type_choice": "Afficher le choix du type de contenu lors de la création des posts",
+  "settings.show_reply_counter": "Afficher une estimation du nombre de réponses",
+  "settings.side_arm": "Bouton secondaire de publication :",
+  "settings.side_arm.none": "Aucun",
+  "settings.side_arm_reply_mode": "Quand vous répondez à un post, le bouton secondaire de publication devrait :",
+  "settings.side_arm_reply_mode.copy": "Copier la confidentialité du post auquel vous répondez",
+  "settings.side_arm_reply_mode.keep": "Garder la confidentialité établie",
+  "settings.side_arm_reply_mode.restrict": "Restreindre la confidentialité de la réponse à celle du post auquel vous répondez",
+  "settings.status_icons": "Icônes des posts",
+  "settings.status_icons_language": "Indicateur de langue",
+  "settings.status_icons_local_only": "Indicateur de post local",
+  "settings.status_icons_media": "Indicateur de médias et sondage",
+  "settings.status_icons_reply": "Indicateur de réponses",
+  "settings.status_icons_visibility": "Indicateur de la confidentialité du post",
+  "settings.swipe_to_change_columns": "Glissement latéral pour changer de colonne (mobile uniquement)",
+  "settings.tag_misleading_links": "Étiqueter les liens trompeurs",
+  "settings.tag_misleading_links.hint": "Ajouter une indication visuelle avec l'hôte cible du lien à chaque lien ne le mentionnant pas explicitement",
+  "settings.wide_view": "Vue élargie (mode ordinateur uniquement)",
+  "settings.wide_view_hint": "Étire les colonnes pour mieux remplir l'espace disponible.",
+  "status.collapse": "Replier",
+  "status.has_audio": "Contient des fichiers audio attachés",
+  "status.has_pictures": "Contient des images attachées",
+  "status.has_preview_card": "Contient une carte de prévisualisation attachée",
+  "status.has_video": "Contient des vidéos attachées",
+  "status.in_reply_to": "Ce post est une réponse",
+  "status.is_poll": "Ce post est un sondage",
+  "status.local_only": "Visible uniquement depuis votre instance",
+  "status.sensitive_toggle": "Cliquer pour voir",
+  "status.uncollapse": "Déplier",
+  "web_app_crash.change_your_settings": "Changez vos {settings}",
+  "web_app_crash.content": "Voici les différentes options qui s'offrent à vous :",
+  "web_app_crash.debug_info": "Informations de débogage",
+  "web_app_crash.disable_addons": "Désactivez les extensions de votre navigateur, ainsi que les outils de traduction intégrés",
+  "web_app_crash.issue_tracker": "traqueur d'erreurs",
+  "web_app_crash.reload": "Rafraichir",
+  "web_app_crash.reload_page": "{reload} la page actuelle",
+  "web_app_crash.report_issue": "Signalez un bug dans le {issuetracker}",
+  "web_app_crash.settings": "paramètres",
+  "web_app_crash.title": "Nous sommes navrés, mais quelque chose s'est mal passé dans l'application Mastodon."
+}
diff --git a/app/javascript/flavours/glitch/locales/fr.json b/app/javascript/flavours/glitch/locales/fr.json
new file mode 100644
index 000000000..ec42f666d
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fr.json
@@ -0,0 +1,199 @@
+{
+  "about.fork_disclaimer": "Glitch-soc est un logiciel gratuit et open source, fork de Mastodon.",
+  "account.add_account_note": "Ajouter une note pour @{name}",
+  "account.disclaimer_full": "Les informations ci-dessous peuvent être incomplètes.",
+  "account.follows": "Abonnements",
+  "account.joined": "Ici depuis {date}",
+  "account.suspended_disclaimer_full": "Cet utilisateur a été suspendu par un modérateur.",
+  "account.view_full_profile": "Voir le profil complet",
+  "account_note.cancel": "Annuler",
+  "account_note.edit": "Éditer",
+  "account_note.glitch_placeholder": "Aucun commentaire fourni",
+  "account_note.save": "Sauvegarder",
+  "advanced_options.icon_title": "Options avancées",
+  "advanced_options.local-only.long": "Ne pas envoyer aux autres instances",
+  "advanced_options.local-only.short": "Uniquement en local",
+  "advanced_options.local-only.tooltip": "Ce post est uniquement local",
+  "advanced_options.threaded_mode.long": "Ouvre automatiquement une réponse lors de la publication",
+  "advanced_options.threaded_mode.short": "Mode thread",
+  "advanced_options.threaded_mode.tooltip": "Mode thread activé",
+  "boost_modal.missing_description": "Ce post contient des médias sans description",
+  "column.favourited_by": "Ajouté en favori par",
+  "column.heading": "Divers",
+  "column.reblogged_by": "Partagé par",
+  "column.subheading": "Autres options",
+  "column_header.profile": "Profil",
+  "column_subheading.lists": "Listes",
+  "column_subheading.navigation": "Navigation",
+  "community.column_settings.allow_local_only": "Afficher seulement les posts locaux",
+  "compose.attach": "Joindre…",
+  "compose.attach.doodle": "Dessiner quelque chose",
+  "compose.attach.upload": "Téléverser un fichier",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Text brut",
+  "compose_form.poll.multiple_choices": "Choix multiples",
+  "compose_form.poll.single_choice": "Choix unique",
+  "compose_form.spoiler": "Cacher le texte derrière un avertissement",
+  "confirmation_modal.do_not_ask_again": "Ne plus demander confirmation",
+  "confirmations.deprecated_settings.confirm": "Utiliser les préférences de Mastodon",
+  "confirmations.deprecated_settings.message": "Certaines {app_settings} de glitch-soc que vous utilisez ont été remplacées par les {preferences} de Mastodon et seront remplacées :",
+  "confirmations.missing_media_description.confirm": "Envoyer quand même",
+  "confirmations.missing_media_description.edit": "Modifier le média",
+  "confirmations.missing_media_description.message": "Au moins un média joint manque d'une description. Pensez à décrire tous les médias attachés pour les malvoyant·e·s avant de publier votre post.",
+  "confirmations.unfilter.author": "Auteur",
+  "confirmations.unfilter.confirm": "Afficher",
+  "confirmations.unfilter.edit_filter": "Modifier le filtre",
+  "confirmations.unfilter.filters": "Correspondance avec {count, plural, one {un filtre} other {plusieurs filtres}}",
+  "content-type.change": "Type de contenu",
+  "direct.group_by_conversations": "Grouper par conversation",
+  "endorsed_accounts_editor.endorsed_accounts": "Comptes mis en avant",
+  "favourite_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci la prochaine fois",
+  "getting_started.onboarding": "Montre-moi les alentours",
+  "home.column_settings.advanced": "Avancé",
+  "home.column_settings.filter_regex": "Filtrer par expression régulière",
+  "home.column_settings.show_direct": "Afficher les MPs",
+  "home.settings": "Paramètres de la colonne",
+  "keyboard_shortcuts.bookmark": "ajouter aux marque-pages",
+  "keyboard_shortcuts.secondary_toot": "Envoyer le post en utilisant les paramètres secondaires de confidentialité",
+  "keyboard_shortcuts.toggle_collapse": "Plier/déplier les posts",
+  "layout.auto": "Auto",
+  "layout.desktop": "Ordinateur",
+  "layout.hint.auto": "Choisir automatiquement la mise en page selon l'option \"Activer l'interface Web avancée\" et la taille d'écran.",
+  "layout.hint.desktop": "Utiliser la mise en page en plusieurs colonnes indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
+  "layout.hint.single": "Utiliser la mise en page à colonne unique indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
+  "layout.single": "Téléphone",
+  "media_gallery.sensitive": "Sensible",
+  "moved_to_warning": "Ce compte a déménagé vers {moved_to_link} et ne peut donc plus accepter de nouveaux abonné·e·s.",
+  "navigation_bar.app_settings": "Paramètres de l'application",
+  "navigation_bar.featured_users": "Utilisateurs mis en avant",
+  "navigation_bar.keyboard_shortcuts": "Raccourcis clavier",
+  "navigation_bar.misc": "Autres",
+  "notification.markForDeletion": "Ajouter aux éléments à supprimer",
+  "notification_purge.btn_all": "Sélectionner\ntout",
+  "notification_purge.btn_apply": "Effacer\nla sélection",
+  "notification_purge.btn_invert": "Inverser\nla sélection",
+  "notification_purge.btn_none": "Annuler\nla sélection",
+  "notification_purge.start": "Activer le mode de nettoyage des notifications",
+  "notifications.marked_clear": "Effacer les notifications sélectionnées",
+  "notifications.marked_clear_confirmation": "Voulez-vous vraiment effacer de manière permanente toutes les notifications sélectionnées ?",
+  "onboarding.done": "Terminé",
+  "onboarding.next": "Suivant",
+  "onboarding.page_five.public_timelines": "Le fil local affiche les posts publics de tout le monde sur {domain}. Le fil global affiche les posts publics de tous les comptes que les personnes de {domain} suivent. Ce sont les fils publics, une façon formidable de découvrir de nouvelles personnes.",
+  "onboarding.page_four.home": "L'accueil affiche les posts des personnes que vous suivez.",
+  "onboarding.page_four.notifications": "La colonne de notifications vous montre lorsque quelqu'un interagit avec vous.",
+  "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.handle": "Vous êtes sur {domain}, donc votre nom d'utilisateur complet est {handle}",
+  "onboarding.page_one.welcome": "Bievenue sur {domain} !",
+  "onboarding.page_six.admin": "Votre admin d’instance est {admin}.",
+  "onboarding.page_six.almost_done": "C'est bientôt fini...",
+  "onboarding.page_six.appetoot": "Bon appétoot !",
+  "onboarding.page_six.apps_available": "Il y a des {apps} disponibles pour iOS, Android et d'autres plateformes.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "règles de la communauté",
+  "onboarding.page_six.read_guidelines": "Veuillez lire les {guidelines} de {domain} !",
+  "onboarding.page_six.various_app": "applications mobiles",
+  "onboarding.page_three.profile": "Modifiez votre profil pour changer votre avatar, biographie et nom public. Ici, vous trouverez également d'autres options.",
+  "onboarding.page_three.search": "Utilisez la barre de recherche pour trouver des personnes et regarder les hashtags comme {illustration} et {introductions}. Pour chercher une personne n'étant pas sur cette instance, utilisez son nom d'utilisateur complet.",
+  "onboarding.page_two.compose": "Écrivez des posts depuis la colonne de rédaction. Vous pouvez téléverser des images, changer la confidentialité et ajouter des avertissements de contenu avec les boutons ci-dessous.",
+  "onboarding.skip": "Passer",
+  "settings.always_show_spoilers_field": "Toujours activer le champ de rédaction de l'avertissement de contenu",
+  "settings.auto_collapse": "Repliage automatique",
+  "settings.auto_collapse_all": "Tout",
+  "settings.auto_collapse_lengthy": "Posts longs",
+  "settings.auto_collapse_media": "Posts avec média",
+  "settings.auto_collapse_notifications": "Notifications",
+  "settings.auto_collapse_reblogs": "Boosts",
+  "settings.auto_collapse_replies": "Réponses",
+  "settings.close": "Fermer",
+  "settings.collapsed_statuses": "Posts repliés",
+  "settings.compose_box_opts": "Zone de rédaction",
+  "settings.confirm_before_clearing_draft": "Afficher une fenêtre de confirmation avant d'écraser le message en cours de rédaction",
+  "settings.confirm_boost_missing_media_description": "Afficher une fenêtre de confirmation avant de partager des posts manquant de description des médias",
+  "settings.confirm_missing_media_description": "Afficher une fenêtre de confirmation avant de publier des posts manquant de description de média",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Expression rationnelle",
+  "settings.content_warnings_filter": "Avertissement de contenu à ne pas automatiquement déplier :",
+  "settings.content_warnings_media_outside": "Afficher les médias en dehors des avertissements de contenu",
+  "settings.content_warnings_media_outside_hint": "Reproduit le comportement par défaut de Mastodon, les médias attachés ne sont plus affectés par le bouton d'affichage d'un post avec avertissement",
+  "settings.content_warnings_shared_state": "Affiche/cache le contenu de toutes les copies à la fois",
+  "settings.content_warnings_shared_state_hint": "Reproduit le comportement par défaut de Mastodon, le bouton d'avertissement de contenu affecte toutes les copies d'un post à la fois. Cela empêchera le repliement automatique de n'importe quelle copie d'un post avec un avertissement déplié",
+  "settings.content_warnings_unfold_opts": "Options de dépliement automatique",
+  "settings.deprecated_setting": "Cette option est maintenant définie par les {settings_page_link} de Mastodon",
+  "settings.enable_collapsed": "Activer le repliement des posts",
+  "settings.enable_collapsed_hint": "Les posts repliés ont une partie de leur contenu caché pour libérer de l'espace sur l'écran. C'est une option différente de l'avertissement de contenu",
+  "settings.enable_content_warnings_auto_unfold": "Déplier automatiquement les avertissements de contenu",
+  "settings.general": "Général",
+  "settings.hicolor_privacy_icons": "Indicateurs de confidentialité en couleurs",
+  "settings.hicolor_privacy_icons.hint": "Affiche les indicateurs de confidentialité dans des couleurs facilement distinguables",
+  "settings.image_backgrounds": "Images en arrière-plan",
+  "settings.image_backgrounds_media": "Prévisualiser les médias d'un post replié",
+  "settings.image_backgrounds_media_hint": "Si le post a un média attaché, utiliser le premier comme arrière-plan du post",
+  "settings.image_backgrounds_users": "Donner aux posts repliés une image en arrière-plan",
+  "settings.inline_preview_cards": "Cartes d'aperçu pour les liens externes",
+  "settings.layout": "Mise en page :",
+  "settings.layout_opts": "Mise en page",
+  "settings.media": "Média",
+  "settings.media_fullwidth": "Utiliser toute la largeur pour les aperçus",
+  "settings.media_letterbox": "Afficher les médias en Letterbox",
+  "settings.media_letterbox_hint": "Réduit le média et utilise une letterbox pour afficher l'image entière plutôt que de l'étirer et de la rogner",
+  "settings.media_reveal_behind_cw": "Toujours afficher les médias sensibles avec avertissement",
+  "settings.notifications.favicon_badge": "Badge de notifications non lues dans la favicon",
+  "settings.notifications.favicon_badge.hint": "Ajoute un badge dans la favicon pour alerter d'une notification non lue",
+  "settings.notifications.tab_badge": "Badge de notifications non lues",
+  "settings.notifications.tab_badge.hint": "Affiche un badge de notifications non lues dans les icônes des colonnes quand la colonne n'est pas ouverte",
+  "settings.notifications_opts": "Options des notifications",
+  "settings.pop_in_left": "Gauche",
+  "settings.pop_in_player": "Activer le lecteur pop-in",
+  "settings.pop_in_position": "Position du lecteur pop-in :",
+  "settings.pop_in_right": "Droite",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "Préfixer les avertissements avec \"re: \" lors d'une réponse",
+  "settings.preselect_on_reply": "Présélectionner les noms d’utilisateur·rices lors de la réponse",
+  "settings.preselect_on_reply_hint": "Présélectionner les noms d'utilisateurs après le premier lors d'une réponse à une conversation à plusieurs participants",
+  "settings.rewrite_mentions": "Réécrire les mentions dans les posts affichés",
+  "settings.rewrite_mentions_acct": "Réécrire avec le nom d'utilisateur·rice et le domaine (lorsque le compte est distant)",
+  "settings.rewrite_mentions_no": "Ne pas réécrire les mentions",
+  "settings.rewrite_mentions_username": "Réécrire avec le nom d’utilisateur·rice",
+  "settings.shared_settings_link": "préférences de l'utilisateur",
+  "settings.show_action_bar": "Afficher les boutons d'action dans les posts repliés",
+  "settings.show_content_type_choice": "Afficher le choix du type de contenu lors de la création des posts",
+  "settings.show_reply_counter": "Afficher une estimation du nombre de réponses",
+  "settings.side_arm": "Bouton secondaire de publication :",
+  "settings.side_arm.none": "Aucun",
+  "settings.side_arm_reply_mode": "Quand vous répondez à un post, le bouton secondaire de publication devrait :",
+  "settings.side_arm_reply_mode.copy": "Copier la confidentialité du post auquel vous répondez",
+  "settings.side_arm_reply_mode.keep": "Garder la confidentialité établie",
+  "settings.side_arm_reply_mode.restrict": "Restreindre la confidentialité de la réponse à celle du post auquel vous répondez",
+  "settings.status_icons": "Icônes des posts",
+  "settings.status_icons_language": "Indicateur de langue",
+  "settings.status_icons_local_only": "Indicateur de post local",
+  "settings.status_icons_media": "Indicateur de médias et sondage",
+  "settings.status_icons_reply": "Indicateur de réponses",
+  "settings.status_icons_visibility": "Indicateur de la confidentialité du post",
+  "settings.swipe_to_change_columns": "Glissement latéral pour changer de colonne (mobile uniquement)",
+  "settings.tag_misleading_links": "Étiqueter les liens trompeurs",
+  "settings.tag_misleading_links.hint": "Ajouter une indication visuelle avec l'hôte cible du lien à chaque lien ne le mentionnant pas explicitement",
+  "settings.wide_view": "Vue élargie (mode ordinateur uniquement)",
+  "settings.wide_view_hint": "Étire les colonnes pour mieux remplir l'espace disponible.",
+  "status.collapse": "Replier",
+  "status.has_audio": "Contient des fichiers audio attachés",
+  "status.has_pictures": "Contient des images attachées",
+  "status.has_preview_card": "Contient une carte de prévisualisation attachée",
+  "status.has_video": "Contient des vidéos attachées",
+  "status.in_reply_to": "Ce post est une réponse",
+  "status.is_poll": "Ce post est un sondage",
+  "status.local_only": "Visible uniquement depuis votre instance",
+  "status.sensitive_toggle": "Cliquer pour voir",
+  "status.uncollapse": "Déplier",
+  "web_app_crash.change_your_settings": "Changez vos {settings}",
+  "web_app_crash.content": "Voici les différentes options qui s'offrent à vous :",
+  "web_app_crash.debug_info": "Informations de débogage",
+  "web_app_crash.disable_addons": "Désactivez les extensions de votre navigateur, ainsi que les outils de traduction intégrés",
+  "web_app_crash.issue_tracker": "traqueur d'erreurs",
+  "web_app_crash.reload": "Rafraichir",
+  "web_app_crash.reload_page": "{reload} la page actuelle",
+  "web_app_crash.report_issue": "Signalez un bug dans le {issuetracker}",
+  "web_app_crash.settings": "paramètres",
+  "web_app_crash.title": "Nous sommes navrés, mais quelque chose s'est mal passé dans l'application Mastodon."
+}
diff --git a/app/javascript/flavours/glitch/locales/fy.json b/app/javascript/flavours/glitch/locales/fy.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/fy.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/ga.json b/app/javascript/flavours/glitch/locales/ga.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ga.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/gd.json b/app/javascript/flavours/glitch/locales/gd.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/gd.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/gl.json b/app/javascript/flavours/glitch/locales/gl.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/gl.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/he.json b/app/javascript/flavours/glitch/locales/he.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/he.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/hi.json b/app/javascript/flavours/glitch/locales/hi.json
new file mode 100644
index 000000000..f6eb75f84
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/hi.json
@@ -0,0 +1,18 @@
+{
+  "about.fork_disclaimer": "ग्लिच-सोक एक मुफ्त और ओपन सोर्स सॉफ़्टवेर है जो मैस्टोडॉन से फोर्क किया गया है",
+  "account.add_account_note": "@{name} के लिए कोई नोट लिखें",
+  "account.follows": "फ़ॉलोज़",
+  "account.joined": "ज़ोईन करने की {date}",
+  "account.suspended_disclaimer_full": "यह यूज़र एक मॉडरेटर द्वारा सस्पेंड कर दिया गया है",
+  "account.view_full_profile": "पूरी प्रोफ़ाइल देखें",
+  "account_note.cancel": "कैन्सल",
+  "account_note.edit": "एडिट या सम्पादन करें",
+  "account_note.glitch_placeholder": "कोई कॉमेंट नहीं दिया गया है",
+  "account_note.save": "सेव",
+  "advanced_options.icon_title": "एडवांस्ड ऑप्शन्स",
+  "advanced_options.local-only.long": "दूसरे इंस्टेंसों में पोस्ट ना करें",
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/hr.json b/app/javascript/flavours/glitch/locales/hr.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/hr.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/hu.json b/app/javascript/flavours/glitch/locales/hu.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/hu.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/hy.json b/app/javascript/flavours/glitch/locales/hy.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/hy.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/id.json b/app/javascript/flavours/glitch/locales/id.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/id.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ig.json b/app/javascript/flavours/glitch/locales/ig.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ig.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/io.json b/app/javascript/flavours/glitch/locales/io.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/io.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/is.json b/app/javascript/flavours/glitch/locales/is.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/is.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/it.json b/app/javascript/flavours/glitch/locales/it.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/it.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ja.json b/app/javascript/flavours/glitch/locales/ja.json
new file mode 100644
index 000000000..610cd7525
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ja.json
@@ -0,0 +1,124 @@
+{
+  "account.add_account_note": "@{name}のメモを追加",
+  "account.disclaimer_full": "このユーザー情報は不正確な可能性があります。",
+  "account.follows": "フォロー",
+  "account.suspended_disclaimer_full": "このユーザーはモデレータにより停止されました。",
+  "account.view_full_profile": "正確な情報を見る",
+  "account_note.cancel": "キャンセル",
+  "account_note.edit": "編集",
+  "account_note.glitch_placeholder": "メモがありません",
+  "account_note.save": "保存",
+  "advanced_options.icon_title": "高度な設定",
+  "advanced_options.local-only.long": "他のインスタンスには投稿されません",
+  "advanced_options.local-only.short": "ローカル限定",
+  "advanced_options.local-only.tooltip": "この投稿はローカル限定投稿です",
+  "advanced_options.threaded_mode.long": "投稿時に自動的に返信するように設定します",
+  "advanced_options.threaded_mode.short": "スレッドモード",
+  "advanced_options.threaded_mode.tooltip": "スレッドモードを有効にする",
+  "boost_modal.missing_description": "このトゥートには少なくとも1つの画像に説明が付与されていません",
+  "community.column_settings.allow_local_only": "ローカル限定投稿を表示する",
+  "compose.attach": "添付...",
+  "compose.attach.doodle": "お絵描きをする",
+  "compose.attach.upload": "ファイルをアップロード",
+  "compose.content-type.markdown": "マークダウン",
+  "compose.content-type.plain": "プレーンテキスト",
+  "compose_form.poll.multiple_choices": "複数回答を許可",
+  "compose_form.poll.single_choice": "単一回答を許可",
+  "compose_form.spoiler": "本文は警告の後ろに隠す",
+  "confirmation_modal.do_not_ask_again": "もう1度尋ねない",
+  "confirmations.missing_media_description.confirm": "このまま投稿",
+  "confirmations.missing_media_description.edit": "メディアを編集",
+  "confirmations.missing_media_description.message": "少なくとも1つの画像に視覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。",
+  "confirmations.unfilter.author": "筆者",
+  "confirmations.unfilter.confirm": "見る",
+  "confirmations.unfilter.edit_filter": "フィルターを編集",
+  "confirmations.unfilter.filters": "適用されたフィルター",
+  "content-type.change": "コンテンツ形式を変更",
+  "endorsed_accounts_editor.endorsed_accounts": "紹介しているユーザー",
+  "favourite_modal.combo": "次からは {combo} を押せば、これをスキップできます。",
+  "getting_started.onboarding": "解説を表示",
+  "home.column_settings.advanced": "高度",
+  "home.column_settings.filter_regex": "正規表現でフィルター",
+  "home.column_settings.show_direct": "DMを表示",
+  "keyboard_shortcuts.bookmark": "ブックマーク",
+  "keyboard_shortcuts.secondary_toot": "セカンダリートゥートの公開範囲でトゥートする",
+  "keyboard_shortcuts.toggle_collapse": "折りたたむ/折りたたみを解除",
+  "layout.auto": "自動",
+  "layout.desktop": "デスクトップ",
+  "layout.single": "モバイル",
+  "moved_to_warning": "このアカウント{moved_to_link}に引っ越したため、新しいフォロワーを受け入れていません。",
+  "navigation_bar.app_settings": "アプリ設定",
+  "navigation_bar.featured_users": "紹介しているアカウント",
+  "navigation_bar.misc": "その他",
+  "notification.markForDeletion": "選択",
+  "notification_purge.btn_all": "すべて\n選択",
+  "notification_purge.btn_apply": "選択したものを\n削除",
+  "notification_purge.btn_invert": "選択を\n反転",
+  "notification_purge.btn_none": "選択\n解除",
+  "notifications.marked_clear": "選択した通知を削除する",
+  "notifications.marked_clear_confirmation": "削除した全ての通知を完全に削除してもよろしいですか?",
+  "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.always_show_spoilers_field": "常にコンテンツワーニング設定を表示する(指定がない場合は通常投稿)",
+  "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.compose_box_opts": "コンポーズボックス設定",
+  "settings.confirm_before_clearing_draft": "作成しているメッセージが上書きされる前に確認ダイアログを表示する",
+  "settings.confirm_boost_missing_media_description": "メディアの説明が欠けているトゥートをブーストする前に確認ダイアログを表示する",
+  "settings.confirm_missing_media_description": "画像に対する補助記載がないときに投稿前の警告を表示する",
+  "settings.content_warnings": "コンテンツワーニング",
+  "settings.content_warnings.regexp": "正規表現",
+  "settings.content_warnings_filter": "説明に指定した文字が含まれているものを自動で展開しないようにする",
+  "settings.enable_collapsed": "トゥート折りたたみを有効にする",
+  "settings.enable_content_warnings_auto_unfold": "コンテンツワーニング指定されている投稿を常に表示する",
+  "settings.general": "一般",
+  "settings.hicolor_privacy_icons": "ハイカラーの公開範囲アイコン",
+  "settings.hicolor_privacy_icons.hint": "公開範囲アイコンを明るく表示し見分けやすい色にします",
+  "settings.image_backgrounds": "画像背景",
+  "settings.image_backgrounds_media": "折りたまれたメディア付きトゥートをプレビュー",
+  "settings.image_backgrounds_users": "折りたまれたトゥートの背景を変更する",
+  "settings.inline_preview_cards": "外部リンクに埋め込みプレビューを有効にする",
+  "settings.layout": "レイアウト",
+  "settings.layout_opts": "レイアウトの設定",
+  "settings.media": "メディア",
+  "settings.media_fullwidth": "全幅メディアプレビュー",
+  "settings.media_letterbox": "メディアをレターボックス式で表示",
+  "settings.media_reveal_behind_cw": "既定で警告指定されているトゥートの閲覧注意メディアを表示する",
+  "settings.notifications.favicon_badge": "通知アイコンに未読件数を表示する",
+  "settings.notifications.tab_badge": "未読の通知があるとき、通知アイコンにマークを表示する",
+  "settings.notifications_opts": "通知の設定",
+  "settings.pop_in_left": "左",
+  "settings.pop_in_player": "ポップインプレイヤーを有効化する",
+  "settings.pop_in_position": "ポップインプレーヤーの位置:",
+  "settings.pop_in_right": "右",
+  "settings.preferences": "ユーザー設定",
+  "settings.prepend_cw_re": "返信するとき警告に \"re: \"を付加する",
+  "settings.preselect_on_reply": "返信するときユーザー名を事前選択する",
+  "settings.rewrite_mentions": "表示されたトゥートの返信先表示を書き換える",
+  "settings.rewrite_mentions_acct": "ユーザー名とドメイン名(アカウントがリモートの場合)を表示するように書き換える",
+  "settings.rewrite_mentions_no": "書き換えない",
+  "settings.rewrite_mentions_username": "ユーザー名を表示するように書き換える",
+  "settings.show_action_bar": "アクションバーを表示",
+  "settings.show_content_type_choice": "トゥートを書くときコンテンツ形式の選択ボタンを表示する",
+  "settings.show_reply_counter": "投稿に対するリプライの数を表示する",
+  "settings.side_arm": "セカンダリートゥートボタン",
+  "settings.side_arm.none": "表示しない",
+  "settings.side_arm_reply_mode": "返信時の投稿範囲",
+  "settings.side_arm_reply_mode.copy": "返信先の投稿範囲を利用する",
+  "settings.side_arm_reply_mode.keep": "セカンダリートゥートボタンの設定を維持する",
+  "settings.side_arm_reply_mode.restrict": "返信先の投稿範囲に制限する",
+  "settings.swipe_to_change_columns": "スワイプでカラムを切り替え可能にする(モバイルのみ)",
+  "settings.tag_misleading_links": "誤解を招くリンクにタグをつける",
+  "settings.tag_misleading_links.hint": "明示的に言及していないすべてのリンクに、リンクターゲットホストを含む視覚的な表示を追加します",
+  "settings.wide_view": "ワイドビュー(デスクトップ レイアウトのみ)",
+  "status.collapse": "折りたたむ",
+  "status.uncollapse": "折りたたみを解除"
+}
diff --git a/app/javascript/flavours/glitch/locales/ka.json b/app/javascript/flavours/glitch/locales/ka.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ka.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/kab.json b/app/javascript/flavours/glitch/locales/kab.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/kab.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/kk.json b/app/javascript/flavours/glitch/locales/kk.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/kk.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/kn.json b/app/javascript/flavours/glitch/locales/kn.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/kn.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ko.json b/app/javascript/flavours/glitch/locales/ko.json
new file mode 100644
index 000000000..fae7d2227
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ko.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "글리치는 마스토돈에서 포크한 자유 오픈소스 소프트웨어입니다.",
+  "account.add_account_note": "@{name} 님에 대한 메모 추가",
+  "account.disclaimer_full": "아래에 있는 정보들은 사용자의 프로필을 완벽하게 나타내지 못하고 있을 수도 있습니다.",
+  "account.follows": "팔로우",
+  "account.joined": "{date}에 가입함",
+  "account.suspended_disclaimer_full": "이 사용자는 중재자에 의해 정지되었습니다.",
+  "account.view_full_profile": "전체 프로필 보기",
+  "account_note.cancel": "취소",
+  "account_note.edit": "편집",
+  "account_note.glitch_placeholder": "코멘트가 없습니다",
+  "account_note.save": "저장",
+  "advanced_options.icon_title": "고급 옵션",
+  "advanced_options.local-only.long": "다른 서버에 게시하지 않기",
+  "advanced_options.local-only.short": "로컬 전용",
+  "advanced_options.local-only.tooltip": "이 게시물은 로컬 전용입니다",
+  "advanced_options.threaded_mode.long": "글을 작성하고 자동으로 답글 열기",
+  "advanced_options.threaded_mode.short": "글타래 모드",
+  "advanced_options.threaded_mode.tooltip": "글타래 모드 활성화됨",
+  "boost_modal.missing_description": "이 게시물은 설명이 없는 미디어를 포함하고 있습니다",
+  "column.favourited_by": "즐겨찾기 한 사람",
+  "column.heading": "기타",
+  "column.reblogged_by": "부스트 한 사람",
+  "column.subheading": "다양한 옵션",
+  "column_header.profile": "프로필",
+  "column_subheading.lists": "리스트",
+  "column_subheading.navigation": "탐색",
+  "community.column_settings.allow_local_only": "로컬 전용 글 보기",
+  "compose.attach": "첨부…",
+  "compose.attach.doodle": "뭔가 그려보세요",
+  "compose.attach.upload": "파일 업로드",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "마크다운",
+  "compose.content-type.plain": "일반 텍스트",
+  "compose_form.poll.multiple_choices": "여러 개 선택 가능",
+  "compose_form.poll.single_choice": "하나만 선택 가능",
+  "compose_form.spoiler": "경고 메시지로 숨기기",
+  "confirmation_modal.do_not_ask_again": "다음부터 확인창을 띄우지 않기",
+  "confirmations.deprecated_settings.confirm": "마스토돈 설정 사용",
+  "confirmations.deprecated_settings.message": "사용하던 몇몇 기기별 글리치 {app_settings}은 마스토돈 {preferences}으로 대체되었습니다:",
+  "confirmations.missing_media_description.confirm": "그냥 보내기",
+  "confirmations.missing_media_description.edit": "미디어 편집",
+  "confirmations.missing_media_description.message": "하나 이상의 미디어에 대해 설명을 작성하지 않았습니다. 시각장애인을 위해 모든 미디어에 설명을 추가하는 것을 고려해주세요.",
+  "confirmations.unfilter.author": "작성자",
+  "confirmations.unfilter.confirm": "보기",
+  "confirmations.unfilter.edit_filter": "필터 편집",
+  "confirmations.unfilter.filters": "적용된 {count, plural, one {필터} other {필터들}}",
+  "content-type.change": "콘텐트 타입",
+  "direct.group_by_conversations": "대화별로 묶기",
+  "endorsed_accounts_editor.endorsed_accounts": "추천하는 계정들",
+  "favourite_modal.combo": "다음엔 {combo}를 눌러 건너뛸 수 있습니다",
+  "getting_started.onboarding": "둘러보기",
+  "home.column_settings.advanced": "고급",
+  "home.column_settings.filter_regex": "정규표현식으로 필터",
+  "home.column_settings.show_direct": "DM 보여주기",
+  "home.settings": "컬럼 설정",
+  "keyboard_shortcuts.bookmark": "북마크",
+  "keyboard_shortcuts.secondary_toot": "보조 프라이버시 설정으로 글 보내기",
+  "keyboard_shortcuts.toggle_collapse": "글 접거나 펼치기",
+  "layout.auto": "자동",
+  "layout.desktop": "데스크탑",
+  "layout.hint.auto": "“고급 웹 인터페이스 활성화” 설정과 화면 크기에 따라 자동으로 레이아웃을 고릅니다.",
+  "layout.hint.desktop": "“고급 웹 인터페이스 활성화” 설정이나 화면 크기에 관계 없이 멀티 컬럼 레이아웃을 사용합니다.",
+  "layout.hint.single": "“고급 웹 인터페이스 활성화” 설정이나 화면 크기에 관계 없이 싱글 컬럼 레이아웃을 사용합니다.",
+  "layout.single": "모바일",
+  "media_gallery.sensitive": "민감함",
+  "moved_to_warning": "이 계정은 {moved_to_link}로 이동한 것으로 표시되었고, 새 팔로우를 받지 않는 것 같습니다.",
+  "navigation_bar.app_settings": "앱 설정",
+  "navigation_bar.featured_users": "추천된 계정들",
+  "navigation_bar.keyboard_shortcuts": "키보드 단축기",
+  "navigation_bar.misc": "다양한 옵션들",
+  "notification.markForDeletion": "삭제하기 위해 표시",
+  "notification_purge.btn_all": "전체선택",
+  "notification_purge.btn_apply": "선택된 알림 삭제",
+  "notification_purge.btn_invert": "선택반전",
+  "notification_purge.btn_none": "전체선택해제",
+  "notification_purge.start": "알림 삭제모드로 들어가기",
+  "notifications.marked_clear": "선택된 알림 모두 삭제",
+  "notifications.marked_clear_confirmation": "정말로 선택된 알림들을 영구적으로 삭제할까요?",
+  "onboarding.done": "완료",
+  "onboarding.next": "다음",
+  "onboarding.page_five.public_timelines": "로컬 타임라인은 {domain}에 있는 모든 사람의 공개글을 보여줍니다. 연합 타임라인은 {domain}에 있는 사람들이 팔로우 하는 모든 사람의 공개글을 보여줍니다. 이것들은 공개 타임라인이라고 불리며, 새로운 사람들을 발견할 수 있는 좋은 방법입니다.",
+  "onboarding.page_four.home": "홈 타임라인은 당신이 팔로우 한 사람들의 글을 보여줍니다.",
+  "onboarding.page_four.notifications": "알림 컬럼은 누군가가 당신과 상호작용한 것들을 보여줍니다.",
+  "onboarding.page_one.federation": "{domain}은 마스토돈의 '인스턴스'입니다. 마스토돈은 하나의 거대한 소셜 네트워크를 만들기 위해 참여한 서버들의 네트워크입니다. 우린 이 서버들을 인스턴스라고 부릅니다.",
+  "onboarding.page_one.handle": "당신은 {domain}에 속해 있으며, 전체 핸들은 {handle} 입니다.",
+  "onboarding.page_one.welcome": "{domain}에 오신 것을 환영합니다!",
+  "onboarding.page_six.admin": "우리 서버의 관리자는 {admin} 님입니다.",
+  "onboarding.page_six.almost_done": "거의 다 되었습니다…",
+  "onboarding.page_six.appetoot": "본 아페툿!",
+  "onboarding.page_six.apps_available": "iOS, 안드로이드, 그리고 다른 플랫폼들을 위한 {apps}이 존재합니다.",
+  "onboarding.page_six.github": "{domain}은 글리치를 통해 구동 됩니다. 글리치는 {Mastodon}의 {fork}입니다, 그리고 어떤 마스토돈 인스턴스나 앱과도 호환 됩니다. 글리치는 완전한 자유 오픈소스입니다. {github}에서 버그를 리포팅 하거나, 기능을 제안하거나, 코드를 기여할 수 있습니다.",
+  "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}이라든지 {introcustions} 같은 것으로요. 이 인스턴스에 있지 않은 사람을 찾으려면, 전체 핸들을 사용하세요.",
+  "onboarding.page_two.compose": "작성 컬럼에서 게시물을 작성하세요. 그림을 업로드 할 수 있고, 공개설정을 바꿀 수도 있으며, 아래 아이콘을 통해 열람주의 텍스트를 설정할 수 있습니다.",
+  "onboarding.skip": "건너뛰기",
+  "settings.always_show_spoilers_field": "열람주의 항목을 언제나 활성화",
+  "settings.auto_collapse": "자동으로 접기",
+  "settings.auto_collapse_all": "모두",
+  "settings.auto_collapse_lengthy": "긴 글",
+  "settings.auto_collapse_media": "미디어 포함 글",
+  "settings.auto_collapse_height": "길이가 긴 것으로 간주할 툿의 높이 (픽셀 단위)",
+  "settings.auto_collapse_notifications": "알림",
+  "settings.auto_collapse_reblogs": "부스트",
+  "settings.auto_collapse_replies": "답글",
+  "settings.close": "닫기",
+  "settings.collapsed_statuses": "접힌 글",
+  "settings.compose_box_opts": "작성 상자",
+  "settings.confirm_before_clearing_draft": "작성 중인 메시지를 덮어씌우기 전에 확인창을 보여주기",
+  "settings.confirm_boost_missing_media_description": "미디어 설명이 없는 글을 부스트하려 할 때 확인창을 보여주기",
+  "settings.confirm_missing_media_description": "미디어 설명이 없는 글을 작성하려 할 때 확인창을 보여주기",
+  "settings.content_warnings": "열람주의",
+  "settings.content_warnings.regexp": "정규표현식",
+  "settings.content_warnings_filter": "자동으로 펼치지 않을 열람주의 문구:",
+  "settings.content_warnings_media_outside": "미디어 첨부를 열람주의 바깥에 보이기",
+  "settings.content_warnings_media_outside_hint": "마스토돈 원본처럼 열람주의 토글이 미디어 첨부에는 영향을 미치지 않게 합니다",
+  "settings.content_warnings_shared_state": "동일한 글의 열람주의를 한번에 열고 닫기",
+  "settings.content_warnings_shared_state_hint": "마스토돈 원본처럼 열람주의 버튼이 동일한 모든 글에 대해 영향을 미치게 합니다. 펼쳐진 열람주의 글이 자동으로 다시 접히는 것을 방지합니다",
+  "settings.content_warnings_unfold_opts": "자동 펼치기 옵션",
+  "settings.deprecated_setting": "이 설정은 마스토돈의 {settings_page_link}에서 관리됩니다",
+  "settings.enable_collapsed": "접힌 글 활성화",
+  "settings.enable_collapsed_hint": "접힌 게시물을 콘텐츠의 일부분을 가려서 공간을 적게 차지합니다. 열람주의 기능과는 다릅니다",
+  "settings.enable_content_warnings_auto_unfold": "자동으로 열람주의 펼치기",
+  "settings.general": "일반",
+  "settings.hicolor_privacy_icons": "높은 채도의 공개설정 아이콘",
+  "settings.hicolor_privacy_icons.hint": "공개설정 아이콘들을 밝고 구분하기 쉬운 색으로 표시합니다",
+  "settings.image_backgrounds": "이미지 배경",
+  "settings.image_backgrounds_media": "접힌 글의 미디어 미리보기",
+  "settings.image_backgrounds_media_hint": "게시물이 미디어 첨부를 포함한다면, 첫번째를 배경으로 사용합니다",
+  "settings.image_backgrounds_users": "접힌 글에 이미지 배경 주기",
+  "settings.inline_preview_cards": "외부 링크에 대한 미리보기 카드를 같이 표시",
+  "settings.layout": "레이아웃:",
+  "settings.layout_opts": "레이아웃 옵션",
+  "settings.media": "미디어",
+  "settings.media_fullwidth": "최대폭 미디어 미리보기",
+  "settings.media_letterbox": "레터박스 미디어",
+  "settings.media_letterbox_hint": "확대하고 자르는 대신 축소하고 레터박스에 넣어 이미지를 보여줍니다",
+  "settings.media_reveal_behind_cw": "열람주의로 가려진 미디어를 기본으로 펼쳐 둡니다",
+  "settings.notifications.favicon_badge": "읽지 않은 알림 파비콘 배지",
+  "settings.notifications.favicon_badge.hint": "읽지 않은 알림 배지를 파비콘에 추가합니다",
+  "settings.notifications.tab_badge": "읽지 않은 알림 배지",
+  "settings.notifications.tab_badge.hint": "알림 컬럼이 열려 있지 않을 때 알림 컬럼에 알림이 있다는 배지를 표시합니다",
+  "settings.notifications_opts": "알림 옵션",
+  "settings.pop_in_left": "왼쪽",
+  "settings.pop_in_player": "떠있는 재생기 활성화",
+  "settings.pop_in_position": "떠있는 재생기 위치:",
+  "settings.pop_in_right": "오른쪽",
+  "settings.preferences": "사용자 설정",
+  "settings.prepend_cw_re": "열람주의가 달린 글에 답장을 할 때 열람주의 문구 앞에 “re: ”를 추가합니다",
+  "settings.preselect_on_reply": "답글 달 때 사용자명 미리 선택",
+  "settings.preselect_on_reply_hint": "답글을 달 때 이미 멘션 된 사람의 사용자명을 미리 블럭으로 설정해 놓습니다",
+  "settings.rewrite_mentions": "표시되는 게시물의 멘션 표시 바꾸기",
+  "settings.rewrite_mentions_acct": "사용자명과 도메인으로 바꾸기(계정이 원격일 때)",
+  "settings.rewrite_mentions_no": "멘션을 그대로 두기",
+  "settings.rewrite_mentions_username": "사용자명으로 바꾸기",
+  "settings.shared_settings_link": "사용자 설정",
+  "settings.show_action_bar": "접힌 글에 액션 버튼들 보이기",
+  "settings.show_content_type_choice": "글을 작성할 때 콘텐트 타입을 고를 수 있도록 합니다",
+  "settings.show_reply_counter": "대략적인 답글 개수를 표시합니다",
+  "settings.side_arm": "보조 작성 버튼:",
+  "settings.side_arm.none": "없음",
+  "settings.side_arm_reply_mode": "답글을 작성할 때:",
+  "settings.side_arm_reply_mode.copy": "답글을 달려는 글의 공개설정을 복사합니다",
+  "settings.side_arm_reply_mode.keep": "보조 작성 버튼의 공개설정을 유지합니다",
+  "settings.side_arm_reply_mode.restrict": "답글을 달려는 글의 공개설정에 맞게 제한합니다",
+  "settings.status_icons": "게시물 아이콘",
+  "settings.status_icons_language": "언어 표시",
+  "settings.status_icons_local_only": "로컬 전용 표시",
+  "settings.status_icons_media": "미디어와 투표 표시",
+  "settings.status_icons_reply": "답글 표시",
+  "settings.status_icons_visibility": "툿 공개설정 표시",
+  "settings.swipe_to_change_columns": "스와이프하여 컬럼간 전환을 허용합니다 (모바일 전용)",
+  "settings.tag_misleading_links": "오해의 소지가 있는 링크를 표시합니다",
+  "settings.tag_misleading_links.hint": "링크에 명시적으로 주소가 없는 경우엔 대상 호스트를 보이도록 표시합니다",
+  "settings.wide_view": "넓은 뷰 (데스크탑 모드 전용)",
+  "settings.wide_view_hint": "컬럼들을 늘려서 활용 가능한 공간을 사용합니다.",
+  "status.collapse": "접기",
+  "status.has_audio": "소리 파일이 첨부되어 있습니다",
+  "status.has_pictures": "그림 파일이 첨부되어 있습니다",
+  "status.has_preview_card": "미리보기 카드가 첨부되어 있습니다",
+  "status.has_video": "영상이 첨부되어 있습니다",
+  "status.in_reply_to": "이 글은 답글입니다",
+  "status.is_poll": "이 글은 설문입니다",
+  "status.local_only": "당신의 서버에서만 보입니다",
+  "status.sensitive_toggle": "클릭해서 보기",
+  "status.uncollapse": "펼치기",
+  "web_app_crash.change_your_settings": "{settings}을 바꾸세요",
+  "web_app_crash.content": "이것들을 시도해 볼 수 있습니다:",
+  "web_app_crash.debug_info": "디버그 정보",
+  "web_app_crash.disable_addons": "브라우저 애드온이나 기본 번역 도구를 비활성화 합니다",
+  "web_app_crash.issue_tracker": "이슈 트래커",
+  "web_app_crash.reload": "새로고침",
+  "web_app_crash.reload_page": "이 페이지를 {reload}",
+  "web_app_crash.report_issue": "{issuetracker}에 버그 제보",
+  "web_app_crash.settings": "설정",
+  "web_app_crash.title": "죄송합니다, 하지만 마스토돈 앱이 뭔가 잘못되었습니다."
+}
diff --git a/app/javascript/flavours/glitch/locales/ku.json b/app/javascript/flavours/glitch/locales/ku.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ku.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/kw.json b/app/javascript/flavours/glitch/locales/kw.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/kw.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/la.json b/app/javascript/flavours/glitch/locales/la.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/la.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/lt.json b/app/javascript/flavours/glitch/locales/lt.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/lt.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/lv.json b/app/javascript/flavours/glitch/locales/lv.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/lv.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/mk.json b/app/javascript/flavours/glitch/locales/mk.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/mk.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ml.json b/app/javascript/flavours/glitch/locales/ml.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ml.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/mr.json b/app/javascript/flavours/glitch/locales/mr.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/mr.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ms.json b/app/javascript/flavours/glitch/locales/ms.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ms.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/my.json b/app/javascript/flavours/glitch/locales/my.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/my.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/nl.json b/app/javascript/flavours/glitch/locales/nl.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/nl.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/nn.json b/app/javascript/flavours/glitch/locales/nn.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/nn.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/no.json b/app/javascript/flavours/glitch/locales/no.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/no.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/oc.json b/app/javascript/flavours/glitch/locales/oc.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/oc.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/pa.json b/app/javascript/flavours/glitch/locales/pa.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/pa.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/pl.json b/app/javascript/flavours/glitch/locales/pl.json
new file mode 100644
index 000000000..0d8deb512
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/pl.json
@@ -0,0 +1,197 @@
+{
+  "about.fork_disclaimer": "Glitch-soc jest wolnym i otwartym oprogramowaniem wywodzącym się z Mastodonu.",
+  "account.add_account_note": "Dodaj notatkę dla @{name}",
+  "account.disclaimer_full": "Poniższe informacje mogą niekompletnie odzwierciedlać profil tego użytkownika.",
+  "account.follows": "Obserwuje",
+  "account.joined": "Konto utworzono {date}",
+  "account.suspended_disclaimer_full": "Użytkownik został zawieszony przez moderatora.",
+  "account.view_full_profile": "Pokaż pełny profil",
+  "account_note.cancel": "Anuluj",
+  "account_note.edit": "Edytuj",
+  "account_note.glitch_placeholder": "Brak komentarza",
+  "account_note.save": "Zapisz",
+  "advanced_options.icon_title": "Ustawienia zaawansowane",
+  "advanced_options.local-only.long": "Nie wysyłaj na inne instancje",
+  "advanced_options.local-only.short": "Tylko lokalnie",
+  "advanced_options.local-only.tooltip": "Ten wpis jest widoczny tylko lokalnie",
+  "advanced_options.threaded_mode.long": "Przechodzi do tworzenia odpowiedzi po publikacji wpisu",
+  "advanced_options.threaded_mode.short": "Tryb wątków",
+  "advanced_options.threaded_mode.tooltip": "Włączono tryb wątków",
+  "boost_modal.missing_description": "Ten wpis zawiera multimedialne załączniki bez opisu",
+  "column.favourited_by": "Polubiony przez",
+  "column.heading": "Różne",
+  "column.reblogged_by": "Podbity przez",
+  "column.subheading": "Różne opcje",
+  "column_header.profile": "Profil",
+  "column_subheading.lists": "Listy",
+  "column_subheading.navigation": "Nawigacja",
+  "community.column_settings.allow_local_only": "Pokazuj wyłącznie wpisy lokalne",
+  "compose.attach": "Załącz coś",
+  "compose.attach.doodle": "Narysuj coś",
+  "compose.attach.upload": "Wyślij plik",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Czysty tekst",
+  "compose_form.poll.multiple_choices": "Pozwól na wybór wielokrotny",
+  "compose_form.poll.single_choice": "Pozwól na tylko jeden wybór",
+  "compose_form.spoiler": "Ukryj tekst za ostrzeżeniem",
+  "confirmation_modal.do_not_ask_again": "Więcej nie pytaj się o potwierdzenie",
+  "confirmations.deprecated_settings.confirm": "Użyj preferencji Mastodonu",
+  "confirmations.missing_media_description.confirm": "Zignoruj i wyślij",
+  "confirmations.missing_media_description.edit": "Edytuj załącznik multimedialny",
+  "confirmations.missing_media_description.message": "Co najmniej jednemu załącznikowi multimedialnemu brakuje opisu. Z uwagi na osoby z zaburzeniami widzenia rozważ opisanie wszystkich załączników przed opublikowaniem wpisu.",
+  "confirmations.unfilter.author": "Autor",
+  "confirmations.unfilter.confirm": "Pokaż",
+  "confirmations.unfilter.edit_filter": "Edytuj filtr",
+  "content-type.change": "Typ zawartości",
+  "direct.group_by_conversations": "Grupuj rozmowami",
+  "endorsed_accounts_editor.endorsed_accounts": "Wybrane konta",
+  "favourite_modal.combo": "Możesz nacisnąć {combo}, aby pominąć to następnym razem",
+  "getting_started.onboarding": "Rozejrzyj się",
+  "home.column_settings.advanced": "Zaawansowane",
+  "home.column_settings.filter_regex": "Filtruj, używając wyrażeń regularnych",
+  "home.column_settings.show_direct": "Pokaż wiadomości bezpośrednie",
+  "home.settings": "Ustawienia kolumn",
+  "keyboard_shortcuts.bookmark": "aby dodać do ulubionych",
+  "keyboard_shortcuts.secondary_toot": "aby opublikować wpis używając dodatkowych ustawień prywatności",
+  "keyboard_shortcuts.toggle_collapse": "aby zwinąć/rozwinąć wpisy",
+  "layout.auto": "Automatyczny",
+  "layout.desktop": "Desktopowy",
+  "layout.hint.auto": "Automatycznie wybierz układ na podstawie ustawienia „Włącz zaawansowany interfejs użytkownika” i rozmiaru ekranu.",
+  "layout.hint.desktop": "Użyj układu wielokolumnowego niezależnie od ustawienia „Włącz zaawansowany interfejs użytkownika” i rozmiaru ekranu.",
+  "layout.hint.single": "Użyj układu jednokolumnowego niezależnie od ustawienia „Włącz zaawansowany interfejs użytkownika” i rozmiaru ekranu.",
+  "layout.single": "Mobilny",
+  "media_gallery.sensitive": "Zawartość wrażliwa",
+  "moved_to_warning": "To konto oznaczone jest jako przeniesione do {moved_to_link} i może z tego powodu nie akceptować nowych obserwujących.",
+  "navigation_bar.app_settings": "Ustawienia aplikacji",
+  "navigation_bar.featured_users": "Użytkownicy wyróżnieni",
+  "navigation_bar.keyboard_shortcuts": "Skróty klawiszowe",
+  "navigation_bar.misc": "Różne",
+  "notification.markForDeletion": "Oznacz do usunięcia",
+  "notification_purge.btn_all": "Zaznacz\nwszystkie",
+  "notification_purge.btn_apply": "Usuń\nzaznaczone",
+  "notification_purge.btn_invert": "Odwróć\nzaznaczenie",
+  "notification_purge.btn_none": "Odznacz\nwszystkie",
+  "notification_purge.start": "Przejdź do trybu usuwania powiadomień",
+  "notifications.marked_clear": "Usuń zaznaczone powiadomienia",
+  "notifications.marked_clear_confirmation": "Czy na pewno chcesz bezpowrtonie usunąć wszystkie powiadomienia?",
+  "onboarding.done": "Zakończ",
+  "onboarding.next": "Następny",
+  "onboarding.page_five.public_timelines": "Lokalna oś czasu pokazuje publiczne posty wszystkich użytkowników {domain}. Globalna oś czasu pokazuje publiczne posty wszystkich użytkowników obserwowanych przez osoby z {domain}. Te publiczne osi czasu są dobrą metodą na poznawanie nowych ludzi.",
+  "onboarding.page_four.home": "Domowa oś czasowa pokazuje wpisy ludzi, których obserwujesz.",
+  "onboarding.page_four.notifications": "Kolumna powiadomień pokazuje interakcje innych z tobą.",
+  "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.handle": "Jesteś na serwerze {domain}, więc twój pełny adres to {handle}",
+  "onboarding.page_one.welcome": "Witamy na {domain}!",
+  "onboarding.page_six.admin": "Administratorem twojego serwera jest {admin}.",
+  "onboarding.page_six.almost_done": "Prawie gotowe…",
+  "onboarding.page_six.apps_available": "Na Android, iOS i inne systemy są dostępne {apps}.",
+  "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}.",
+  "onboarding.page_six.guidelines": "wytyczne społeczności",
+  "onboarding.page_six.read_guidelines": "Proszę przeczytać {guidelines} {domain}!",
+  "onboarding.page_six.various_app": "aplikacje mobilne",
+  "onboarding.page_three.profile": "Edytuj Twój profil, aby zmienić awatar, biogram i widoczną nazwę. Znajdziesz tam również inne ustawienia.",
+  "onboarding.page_three.search": "Użyj paska wyszukiwania aby znaleźć osoby i hasztagi, takie jak {illustration} i {introductions}. Aby znaleźć osobę niebędącą na tym serwerze użyj jej pełnego adresu.",
+  "onboarding.page_two.compose": "Twórz nowe wpisy w lewej kolumnie. Możesz wysłać zdjęcia, zmienić ustawienia prywatności i ukryć wpis za ostrzeżeniem używając poniższych ikon.",
+  "onboarding.skip": "Pomiń",
+  "settings.always_show_spoilers_field": "Zawsze pokazuj pole ostrzeżenia o zawartości",
+  "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_height": "Wysokość (w pikselach) powyżej której wpis będzie uznawany za długi",
+  "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.compose_box_opts": "Pole edycji",
+  "settings.confirm_before_clearing_draft": "Wymuś potwierdzenie przez nadpisaniem aktualnie edytowanego wpisu",
+  "settings.confirm_boost_missing_media_description": "Wymuś potwierdzenie przed podbiciem wpisów z brakującym opisem załączników multimedialnych",
+  "settings.confirm_missing_media_description": "Wymuś potwierdzenie przed opublikowaniem wpisu z brakującymi opisami załączników multimedialnych",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Wyrażenie regularne",
+  "settings.content_warnings_filter": "Ostrzeżenia o zawartości nieodkrywane automatycznie:",
+  "settings.content_warnings_media_outside": "Wyświetlaj załączniki multimedialne poza ostrzeżeniem o zawartości",
+  "settings.content_warnings_media_outside_hint": "Nie ukrywaj załączników multimedialnych, gdy wpis jest ukryty za ostrzeżeniem, tak jak robi to niezmodyfikowany Mastodon",
+  "settings.content_warnings_shared_state": "Pokaż/ukryj zawartość wszystkich kopii jednocześnie",
+  "settings.content_warnings_shared_state_hint": "Zachowaj się tak, jak niezmodyfikowany Mastodon, tj. wymuś działanie przycisku ostrzeżenia o zawartości na wszystkie kopie danego wpisu. Włączenie tego ustawienia spowoduje wyłączenie automatycznego zwijania kopii wpisów z odkrytym ostrzeżeniem o zawartości.",
+  "settings.content_warnings_unfold_opts": "Opcje automatycznego odkrywania",
+  "settings.deprecated_setting": "To ustawienie jest teraz kontrolowane przez {settings_page_link}",
+  "settings.enable_collapsed": "Włącz zwijanie wpisów",
+  "settings.enable_collapsed_hint": "Zwinięte wpisy są częściowo ukryte, przez co zajmują mniej miejsca. Ta opcja różni się od ukrywania wpisów za ostrzeżeniem",
+  "settings.enable_content_warnings_auto_unfold": "Automatycznie odkrywaj wpisy ukryte za ostrzeżeniem",
+  "settings.general": "Ogólne",
+  "settings.hicolor_privacy_icons": "Ikony ustawień prywatności o jaskrawych kolorach",
+  "settings.hicolor_privacy_icons.hint": "Wyświetl ikony ustawień prywatności używając łatwo rozróżnialnych kolorów",
+  "settings.image_backgrounds": "Obrazy w tle",
+  "settings.image_backgrounds_media": "Wyświetlaj zawartość multimedialną zwiniętych wpisów",
+  "settings.image_backgrounds_media_hint": "Jeśli wpis ma co najmniej jeden załącznik multimedialny, użyj pierwszego z nich, jako tła.",
+  "settings.image_backgrounds_users": "Nadaj tło zwiniętym wpisom",
+  "settings.inline_preview_cards": "Karty podglądu zewnętrznych linków w tekście",
+  "settings.layout": "Układ",
+  "settings.layout_opts": "Opcje układu",
+  "settings.media": "Zawartość multimedialna",
+  "settings.media_fullwidth": "Podgląd zawartości multimedialnej o pełnej szerokości",
+  "settings.media_letterbox": "Dopasuj proporcje multimedialnych załączników",
+  "settings.media_letterbox_hint": "Przeskaluj multimedialne załączniki w sposób umożliwiający zachowanie proporcji.",
+  "settings.media_reveal_behind_cw": "Domyślnie odkrywaj załączniki multimedialne wpisów ukrytych za ostrzeżeniem",
+  "settings.notifications.favicon_badge": "Znacznik nieprzeczytanych powiadomień ikony ulubionych",
+  "settings.notifications.favicon_badge.hint": "Dodaj znacznik nieprzeczytanych powiadomień do ikony ulubionych.",
+  "settings.notifications.tab_badge": "Znacznik nieprzeczytanych powiadomień",
+  "settings.notifications.tab_badge.hint": "Dodaj znacznik nieprzeczytanych powiadomień do ikon kolumn, gdy kolumna powiadomień jest zamknięta.",
+  "settings.notifications_opts": "Opcje powiadomień",
+  "settings.pop_in_left": "Po lewej",
+  "settings.pop_in_player": "Włącz odtwarzacz w wyskakującym okienku",
+  "settings.pop_in_position": "Pozycja wyskakującego okienka:",
+  "settings.pop_in_right": "Po prawej",
+  "settings.preferences": "Preferencje użytkownika",
+  "settings.prepend_cw_re": "Dodaj „re: ” na początku ostrzeżenia o zawartości podczas odpowiadania na wpis z ostrzeżeniem",
+  "settings.preselect_on_reply": "Automatycznie wybierz adresy podczas odpowiadania",
+  "settings.preselect_on_reply_hint": "Podczas odpowiadania w rozmowie z kilkoma uczestnikami automatycznie wybierz adresy inne niż pierwszy.",
+  "settings.rewrite_mentions": "Przerabianie nawiązań w wyświetlonych statusach",
+  "settings.rewrite_mentions_acct": "Przerób na pełny adres, gdy konto jest z innego serwera",
+  "settings.rewrite_mentions_no": "Nie przerabiaj",
+  "settings.rewrite_mentions_username": "Przerób na nazwę użytkownika",
+  "settings.shared_settings_link": "ustawienia użytkownika",
+  "settings.show_action_bar": "Pokazuj przyciski akcji pod zwiniętymi wpisami",
+  "settings.show_content_type_choice": "Podczas tworzenia wpisów umożliw wybór typu zawartości",
+  "settings.show_reply_counter": "Wyświetl szacowaną ilości odpowiedzi",
+  "settings.side_arm": "Drugi przycisk wysyłania",
+  "settings.side_arm.none": "Żaden",
+  "settings.side_arm_reply_mode": "Podczas odpowiadania na wpis, dodatkowy przycisk publikowania powinien:",
+  "settings.side_arm_reply_mode.copy": "Powielić ustawienia prywatności wpisu, na który publikowana jest odpowiedź",
+  "settings.side_arm_reply_mode.keep": "Zachować wcześniej ustawiony tryb prywatności",
+  "settings.side_arm_reply_mode.restrict": "Ograniczyć ustawienia prywatności do tych używanych przez wpis, na który publikowana jest odpowiedź",
+  "settings.status_icons": "Ikony wpisów",
+  "settings.status_icons_language": "Wskaźnik języka",
+  "settings.status_icons_local_only": "Wskaźnik wpisu lokalnego",
+  "settings.status_icons_media": "Wskaźniki załączników multimedialnych i ankiet",
+  "settings.status_icons_reply": "Wskaźnik odpowiedzi",
+  "settings.status_icons_visibility": "Wskaźnik ustawień prywatności wpisu",
+  "settings.swipe_to_change_columns": "W wypadku wersji mobilnej pozwól na zmianę kolumny przez przesunięcie palcem",
+  "settings.tag_misleading_links": "Oznacz mylące linki",
+  "settings.tag_misleading_links.hint": "Dodaj oznaczenie domeny do każdego linku, który nie ma jej w swojej treści",
+  "settings.wide_view": "Szeroki widok (tylko w trybie desktopowym)",
+  "settings.wide_view_hint": "Wykorzystaj więcej dostępnego miejsca, rozciągając kolumny.",
+  "status.collapse": "Zwiń",
+  "status.has_audio": "Posiada załączone pliki dźwiękowe",
+  "status.has_pictures": "Posiada załączone obrazki",
+  "status.has_preview_card": "Posiada załączoną kartę podglądu",
+  "status.has_video": "Posiada załączone wideo",
+  "status.in_reply_to": "Ten wpis jest odpowiedzią",
+  "status.is_poll": "Ten wpis zawiera ankietę",
+  "status.local_only": "Widoczne tylko na twoim serwerze",
+  "status.sensitive_toggle": "Kliknij, aby zobaczyć",
+  "status.uncollapse": "Rozwiń",
+  "web_app_crash.change_your_settings": "Zmień swoje {settings}",
+  "web_app_crash.content": "Możesz spróbować:",
+  "web_app_crash.debug_info": "Informacje pomocne w debugowaniu",
+  "web_app_crash.disable_addons": "Wyłączyć dodatki Twojej przeglądarki lub wbudowane narzędzia do tłumaczenia",
+  "web_app_crash.issue_tracker": "stronie śledzenia błędów",
+  "web_app_crash.reload": "Odświeżyć",
+  "web_app_crash.reload_page": "{reload} tą stronę",
+  "web_app_crash.report_issue": "Zgłosić błąd na {issuetracker}",
+  "web_app_crash.settings": "ustawienia",
+  "web_app_crash.title": "Przepraszamy, ale coś jest nie tak z tą stroną Mastodonu."
+}
diff --git a/app/javascript/flavours/glitch/locales/pt-BR.json b/app/javascript/flavours/glitch/locales/pt-BR.json
new file mode 100644
index 000000000..37451ebe4
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/pt-BR.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "O Glitch-soc é um software gratuito de código aberto bifurcado a partir do Mastodon.",
+  "account.add_account_note": "Adicionar nota para @{name}",
+  "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de forma incompleta.",
+  "account.follows": "Segue",
+  "account.joined": "Entrou em {date}",
+  "account.suspended_disclaimer_full": "Este usuário foi suspenso por um moderador.",
+  "account.view_full_profile": "Ver o perfil completo",
+  "account_note.cancel": "Cancelar",
+  "account_note.edit": "Editar",
+  "account_note.glitch_placeholder": "Nenhum comentário fornecido",
+  "account_note.save": "Salvar",
+  "advanced_options.icon_title": "Opções avançadas",
+  "advanced_options.local-only.long": "Não publicar em outras instâncias",
+  "advanced_options.local-only.short": "Apenas localmente",
+  "advanced_options.local-only.tooltip": "Este post é somente local",
+  "advanced_options.threaded_mode.long": "Abrir automaticamente uma resposta ao postar",
+  "advanced_options.threaded_mode.short": "Modo de discussão",
+  "advanced_options.threaded_mode.tooltip": "Modo de discussão ativado",
+  "boost_modal.missing_description": "Este toot contém algumas mídias sem descrição",
+  "column.favourited_by": "Favoritado por",
+  "column.heading": "Diversos",
+  "column.reblogged_by": "Inpulsionado por",
+  "column.subheading": "Opções diversas",
+  "column_header.profile": "Perfil",
+  "column_subheading.lists": "Listas",
+  "column_subheading.navigation": "Navegação",
+  "community.column_settings.allow_local_only": "Mostrar os toots apenas locais",
+  "compose.attach": "Anexar...",
+  "compose.attach.doodle": "Desenhe algo",
+  "compose.attach.upload": "Enviar um arquivo",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Texto sem formatação",
+  "compose_form.poll.multiple_choices": "Permitir múltipla escolha",
+  "compose_form.poll.single_choice": "Permitir uma escolha",
+  "compose_form.spoiler": "Ocultar texto atrás do aviso",
+  "confirmation_modal.do_not_ask_again": "Não pedir confirmação novamente",
+  "confirmations.deprecated_settings.confirm": "Usar preferências do Mastodon",
+  "confirmations.deprecated_settings.message": "Alguns dos {app_settings} específicos do dispositivo que você está usando foram substituídos por Mastodon {preferences} e serão substituídos:",
+  "confirmations.missing_media_description.confirm": "Enviar mesmo assim",
+  "confirmations.missing_media_description.edit": "Editar mídia",
+  "confirmations.missing_media_description.message": "Pelo menos um anexo de mídia não tem uma descrição. Considere descrever todos os anexos de mídia para deficientes visuais antes de enviar seu toot.",
+  "confirmations.unfilter.author": "Autor",
+  "confirmations.unfilter.confirm": "Exibir",
+  "confirmations.unfilter.edit_filter": "Editar filtro",
+  "confirmations.unfilter.filters": "Correspondência de {count, plural, one {filtro} other {filtros}}",
+  "content-type.change": "Tipo de conteúdo",
+  "direct.group_by_conversations": "Agrupar por conversa",
+  "endorsed_accounts_editor.endorsed_accounts": "Contas em destaque",
+  "favourite_modal.combo": "Você pode pressionar {combo} para pular isso da próxima vez",
+  "getting_started.onboarding": "Mostre-me ao redor",
+  "home.column_settings.advanced": "Avançado",
+  "home.column_settings.filter_regex": "Filtrar com uma expressão regular",
+  "home.column_settings.show_direct": "Mostrar DMs",
+  "home.settings": "Configurações da coluna",
+  "keyboard_shortcuts.bookmark": "para marcar",
+  "keyboard_shortcuts.secondary_toot": "para enviar toot usando a configuração de privacidade secundária",
+  "keyboard_shortcuts.toggle_collapse": "para recolher/mostrar toots",
+  "layout.auto": "Automático",
+  "layout.desktop": "Área de trabalho",
+  "layout.hint.auto": "Escolher automaticamente o layout baseado na configuração \"Habilitar interface web avançada\" e o tamanho da tela.",
+  "layout.hint.desktop": "Use o layout de várias colunas independentemente da configuração \"Habilitar interface web avançada\" ou do tamanho da tela.",
+  "layout.hint.single": "Use o layout de uma coluna independentemente da configuração \"Habilitar interface web avançada\" ou do tamanho da tela.",
+  "layout.single": "Celular",
+  "media_gallery.sensitive": "Sensível",
+  "moved_to_warning": "Esta conta foi como movida para {moved_to_link} e, portanto, pode não aceitar novos seguidores.",
+  "navigation_bar.app_settings": "Configurações do aplicativo",
+  "navigation_bar.featured_users": "Usuários em destaque",
+  "navigation_bar.keyboard_shortcuts": "Atalhos de teclado",
+  "navigation_bar.misc": "Diversos",
+  "notification.markForDeletion": "Marcar para exclusão",
+  "notification_purge.btn_all": "Selecionar\ntudo",
+  "notification_purge.btn_apply": "Limpar\nselecionados",
+  "notification_purge.btn_invert": "Inverter\nseleção",
+  "notification_purge.btn_none": "Selecionar\nnenhum",
+  "notification_purge.start": "Entrar no modo de limpeza de notificação",
+  "notifications.marked_clear": "Limpar as notificações selecionadas",
+  "notifications.marked_clear_confirmation": "Tem certeza que deseja limpar todas as notificações selecionadas permanentemente?",
+  "onboarding.done": "Feito",
+  "onboarding.next": "Próximo",
+  "onboarding.page_five.public_timelines": "A linha do tempo local mostra publicações públicas de todos em {domain}. A linha do tempo federada mostra publicações públicas de todos que as pessoas seguem em {domain}. Estas são as linhas do tempo públicas, uma ótima maneira de descobrir novas pessoas.",
+  "onboarding.page_four.home": "A linha do tempo da casa mostra publicações de pessoas que você segue.",
+  "onboarding.page_four.notifications": "A coluna de notificações mostra quando alguém interage com você.",
+  "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.handle": "Você está em {domain}, então o seu identificador completo é {handle}",
+  "onboarding.page_one.welcome": "Bem-vindo ao {domain}!",
+  "onboarding.page_six.admin": "O administrador da sua instância é {admin}.",
+  "onboarding.page_six.almost_done": "Quase pronto...",
+  "onboarding.page_six.appetoot": "Bom Appetoot!",
+  "onboarding.page_six.apps_available": "Há {apps} disponíveis para iOS, Android e outras plataformas.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "diretrizes da comunidade",
+  "onboarding.page_six.read_guidelines": "Por favor, leia {domain} {guidelines}!",
+  "onboarding.page_six.various_app": "aplicativos móveis",
+  "onboarding.page_three.profile": "Edite seu perfil para alterar seu avatar, bio e nome de exibição. Lá você também encontrará outras preferências.",
+  "onboarding.page_three.search": "Use a barra de busca para encontrar pessoas e procure hashtags, tais como {illustration} e {introductions}. Para procurar uma pessoa que não esteja neste caso, use o identificador completo.",
+  "onboarding.page_two.compose": "Escreva as postagens a partir da coluna de composição. Você pode enviar imagens, alterar as configurações de privacidade e adicionar avisos de conteúdo com os ícones abaixo.",
+  "onboarding.skip": "Pular",
+  "settings.always_show_spoilers_field": "Sempre ativar o campo Aviso de Conteúdo",
+  "settings.auto_collapse": "Colapso automático",
+  "settings.auto_collapse_all": "Tudo",
+  "settings.auto_collapse_lengthy": "Toots longos",
+  "settings.auto_collapse_media": "Toots com mídia",
+  "settings.auto_collapse_height": "Altura (em pixels) para um toot ser considerado longo",
+  "settings.auto_collapse_notifications": "Notificações",
+  "settings.auto_collapse_reblogs": "Impulsos",
+  "settings.auto_collapse_replies": "Respostas",
+  "settings.close": "Fechar",
+  "settings.collapsed_statuses": "Toots recolhidos",
+  "settings.compose_box_opts": "Caixa de composição",
+  "settings.confirm_before_clearing_draft": "Mostrar diálogo de confirmação antes de sobrescrever a mensagem que está sendo composta",
+  "settings.confirm_boost_missing_media_description": "Mostrar diálogo antes de inpulsionar os toots sem descrições de mídia",
+  "settings.confirm_missing_media_description": "Mostrar diálogo antes de enviar toots sem descrições de mídia",
+  "settings.content_warnings": "Aviso de Conteúdo",
+  "settings.content_warnings.regexp": "Expressão regular",
+  "settings.content_warnings_filter": "Avisos de conteúdo para não revelar automaticamente:",
+  "settings.content_warnings_media_outside": "Exibir anexos de mídia fora avisos de conteúdo",
+  "settings.content_warnings_media_outside_hint": "Reproduzir o comportamento do Mastodonte, fazendo com que a alternância do Aviso de Conteúdo não afete os anexos de mídia",
+  "settings.content_warnings_shared_state": "Mostrar/ocultar o conteúdo de todas as cópias de uma só vez",
+  "settings.content_warnings_shared_state_hint": "Reproduzir o comportamento do Mastodonte fazendo com que o botão de Aviso de Conteúdo afete todas as cópias de um post de uma só vez. Isto evitará o colapso automático de qualquer cópia de um toon com Aviso de Conteúdo revelado",
+  "settings.content_warnings_unfold_opts": "Opções de auto-revelar",
+  "settings.deprecated_setting": "Essa configuração agora é controlada pelo {settings_page_link} do Mastodon",
+  "settings.enable_collapsed": "Habilitar toots recolhidos",
+  "settings.enable_collapsed_hint": "Posts recolhidos têm partes dos seus conteúdos ocultos para ocupar menos espaço na tela. Isto é diferente do recurso 'Aviso de Conteúdo'",
+  "settings.enable_content_warnings_auto_unfold": "Revelar automaticamente os avisos de conteúdo",
+  "settings.general": "Geral",
+  "settings.hicolor_privacy_icons": "Ícones de privacidade com cores de alto contraste",
+  "settings.hicolor_privacy_icons.hint": "Exibir ícones de privacidade em cores brilhantes e facilmente distinguíveis",
+  "settings.image_backgrounds": "Fundos de imagem",
+  "settings.image_backgrounds_media": "Pré-visualização da mídia de toots colapsados",
+  "settings.image_backgrounds_media_hint": "Se o post tiver algum anexo de mídia, use o primeiro em um plano de fundo",
+  "settings.image_backgrounds_users": "Dar a toots recolhidos uma imagem de fundo",
+  "settings.inline_preview_cards": "Cartões de pré-visualização em linha para links externos",
+  "settings.layout": "Layout:",
+  "settings.layout_opts": "Opções de layout",
+  "settings.media": "Mídia",
+  "settings.media_fullwidth": "Pré-visualização da mídia em largura total",
+  "settings.media_letterbox": "Caixa de mensagens",
+  "settings.media_letterbox_hint": "Escala para baixo para encher os recipientes de imagem em vez de esticá-los e cortá-los",
+  "settings.media_reveal_behind_cw": "Revelar mídia sensível por trás de um Aviso de Conteúdo por padrão",
+  "settings.notifications.favicon_badge": "Notificações não lidas como emblema do favicon",
+  "settings.notifications.favicon_badge.hint": "Adicionar um emblema para notificações não lidas ao favicon",
+  "settings.notifications.tab_badge": "Emblema de notificações não lidas",
+  "settings.notifications.tab_badge.hint": "Exibir um emblema para notificações não lidas nos ícones de coluna quando a coluna de notificações não estiver aberta",
+  "settings.notifications_opts": "Opções de notificações",
+  "settings.pop_in_left": "Esquerda",
+  "settings.pop_in_player": "Ativar player pop-in",
+  "settings.pop_in_position": "Posição do player:",
+  "settings.pop_in_right": "Direita",
+  "settings.preferences": "Preferências do usuário",
+  "settings.prepend_cw_re": "Preparar \"re: \" para avisos de conteúdo quando responder",
+  "settings.preselect_on_reply": "Nome de usuário pré-selecionado na resposta",
+  "settings.preselect_on_reply_hint": "Ao responder a uma conversa com vários participantes, pré-selecionar nomes de usuários após o primeiro",
+  "settings.rewrite_mentions": "Reescrever as menções nos status exibidos",
+  "settings.rewrite_mentions_acct": "Reescrever com nome de usuário e domínio (quando a conta for remota)",
+  "settings.rewrite_mentions_no": "Não reescrever menções",
+  "settings.rewrite_mentions_username": "Reescreva com nome de usuário",
+  "settings.shared_settings_link": "preferências do usuário",
+  "settings.show_action_bar": "Mostrar botões de ação em toots recolhidos",
+  "settings.show_content_type_choice": "Exibir opção do tipo de conteúdo ao autorar toots",
+  "settings.show_reply_counter": "Exibir uma estimativa da contagem de respostas",
+  "settings.side_arm": "Botão de toot secundário:",
+  "settings.side_arm.none": "Nenhum",
+  "settings.side_arm_reply_mode": "Ao responder a um toot, o botão secundário de toot deve:",
+  "settings.side_arm_reply_mode.copy": "Copiar configuração de privacidade do toot sendo respondido a",
+  "settings.side_arm_reply_mode.keep": "Mantenha sua privacidade definida",
+  "settings.side_arm_reply_mode.restrict": "Restringir configuração de privacidade ao toot sendo respondido a",
+  "settings.status_icons": "Ícones de toot",
+  "settings.status_icons_language": "Indicador de idioma",
+  "settings.status_icons_local_only": "Indicador somente local",
+  "settings.status_icons_media": "Indicadores de mídia e enquete",
+  "settings.status_icons_reply": "Indicador de resposta",
+  "settings.status_icons_visibility": "Indicador de privacidade",
+  "settings.swipe_to_change_columns": "Permitir deslizar para alterar colunas (apenas celular)",
+  "settings.tag_misleading_links": "Marcar links enganosos",
+  "settings.tag_misleading_links.hint": "Acrescentar uma indicação visual com o link hospedeiro alvo a cada link que não o mencione explicitamente",
+  "settings.wide_view": "Visualização ampla (apenas no Modo desktop)",
+  "settings.wide_view_hint": "Estica as colunas para preencher melhor o espaço disponível.",
+  "status.collapse": "Recolher",
+  "status.has_audio": "Possui um arquivo de áudio anexado",
+  "status.has_pictures": "Possui uma imagem anexada",
+  "status.has_preview_card": "Possui uma pré-visualização anexada",
+  "status.has_video": "Possui um vídeo anexado",
+  "status.in_reply_to": "Este toot é uma resposta",
+  "status.is_poll": "Este toot é uma enquete",
+  "status.local_only": "Visível apenas em sua instância",
+  "status.sensitive_toggle": "Clique para ver",
+  "status.uncollapse": "Revelar",
+  "web_app_crash.change_your_settings": "Altere suas {settings}",
+  "web_app_crash.content": "Você poderia tentar qualquer uma das seguintes opções:",
+  "web_app_crash.debug_info": "Informações de depuração",
+  "web_app_crash.disable_addons": "Desativar complementos do navegador ou ferramentas de tradução integradas",
+  "web_app_crash.issue_tracker": "rastreador de problemas",
+  "web_app_crash.reload": "Recarregar",
+  "web_app_crash.reload_page": "{reload} a página atual",
+  "web_app_crash.report_issue": "Relatar um erro no {issuetracker}",
+  "web_app_crash.settings": "configurações",
+  "web_app_crash.title": "Desculpe, mas algo deu errado com o aplicativo Mastodon."
+}
diff --git a/app/javascript/flavours/glitch/locales/pt-PT.json b/app/javascript/flavours/glitch/locales/pt-PT.json
new file mode 100644
index 000000000..fc3cdc621
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/pt-PT.json
@@ -0,0 +1,25 @@
+{
+  "about.fork_disclaimer": "O Glitch-soc é um software livre de código aberto, derivado (fork) do Mastodon.",
+  "account.add_account_note": "Juntar uma nota sobre @{name}",
+  "account.disclaimer_full": "As informações abaixo podem não refletir completamente o perfil do utilizador.",
+  "account.follows": "A seguir",
+  "account.joined": "Juntou-se em {date}",
+  "account.suspended_disclaimer_full": "Este utilizador foi suspenso por um elemento da moderação.",
+  "account.view_full_profile": "Ver o perfil completo",
+  "account_note.cancel": "Cancelar",
+  "account_note.edit": "Editar",
+  "account_note.glitch_placeholder": "Nenhum comentário dado",
+  "account_note.save": "Gravar",
+  "advanced_options.icon_title": "Opções avançadas",
+  "advanced_options.local-only.long": "Não publicar noutras instâncias",
+  "advanced_options.local-only.short": "Apenas local",
+  "advanced_options.local-only.tooltip": "Este post é apenas local",
+  "advanced_options.threaded_mode.long": "Abrir automaticamente uma resposta ao publicar",
+  "advanced_options.threaded_mode.short": "Modo de fio",
+  "advanced_options.threaded_mode.tooltip": "Modo de fio ativado",
+  "boost_modal.missing_description": "Este post contém alguns media sem descrição",
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ro.json b/app/javascript/flavours/glitch/locales/ro.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ro.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ru.json b/app/javascript/flavours/glitch/locales/ru.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ru.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sa.json b/app/javascript/flavours/glitch/locales/sa.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sa.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sc.json b/app/javascript/flavours/glitch/locales/sc.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sc.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sco.json b/app/javascript/flavours/glitch/locales/sco.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sco.json
@@ -0,0 +1 @@
+{}
diff --git a/app/javascript/flavours/glitch/locales/si.json b/app/javascript/flavours/glitch/locales/si.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/si.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sk.json b/app/javascript/flavours/glitch/locales/sk.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sk.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sl.json b/app/javascript/flavours/glitch/locales/sl.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sl.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sq.json b/app/javascript/flavours/glitch/locales/sq.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sq.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sr-Latn.json b/app/javascript/flavours/glitch/locales/sr-Latn.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sr-Latn.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sr.json b/app/javascript/flavours/glitch/locales/sr.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sr.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/sv.json b/app/javascript/flavours/glitch/locales/sv.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/sv.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/szl.json b/app/javascript/flavours/glitch/locales/szl.json
new file mode 100644
index 000000000..807ed8207
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/szl.json
@@ -0,0 +1,201 @@
+{
+  "about.fork_disclaimer": "Glitch-soc is free open source software forked from Mastodon.",
+  "account.add_account_note": "Add note for @{name}",
+  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.follows": "Follows",
+  "account.joined": "Joined {date}",
+  "account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
+  "account.view_full_profile": "View full profile",
+  "account_note.cancel": "Cancel",
+  "account_note.edit": "Edit",
+  "account_note.glitch_placeholder": "No comment provided",
+  "account_note.save": "Save",
+  "advanced_options.icon_title": "Advanced options",
+  "advanced_options.local-only.long": "Do not post to other instances",
+  "advanced_options.local-only.short": "Local-only",
+  "advanced_options.local-only.tooltip": "This post is local-only",
+  "advanced_options.threaded_mode.long": "Automatically opens a reply on posting",
+  "advanced_options.threaded_mode.short": "Threaded mode",
+  "advanced_options.threaded_mode.tooltip": "Threaded mode enabled",
+  "boost_modal.missing_description": "This toot contains some media without description",
+  "column.favourited_by": "Favourited by",
+  "column.heading": "Misc",
+  "column.reblogged_by": "Boosted by",
+  "column.subheading": "Miscellaneous options",
+  "column_header.profile": "Profile",
+  "column_subheading.lists": "Lists",
+  "column_subheading.navigation": "Navigation",
+  "community.column_settings.allow_local_only": "Show local-only toots",
+  "compose.attach": "Attach...",
+  "compose.attach.doodle": "Draw something",
+  "compose.attach.upload": "Upload a file",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Plain text",
+  "compose_form.poll.multiple_choices": "Allow multiple choices",
+  "compose_form.poll.single_choice": "Allow one choice",
+  "compose_form.spoiler": "Hide text behind warning",
+  "confirmation_modal.do_not_ask_again": "Do not ask for confirmation again",
+  "confirmations.deprecated_settings.confirm": "Use Mastodon preferences",
+  "confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
+  "confirmations.missing_media_description.confirm": "Send anyway",
+  "confirmations.missing_media_description.edit": "Edit media",
+  "confirmations.missing_media_description.message": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.",
+  "confirmations.unfilter.author": "Author",
+  "confirmations.unfilter.confirm": "Show",
+  "confirmations.unfilter.edit_filter": "Edit filter",
+  "confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
+  "content-type.change": "Content type",
+  "direct.group_by_conversations": "Group by conversation",
+  "endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
+  "favourite_modal.combo": "You can press {combo} to skip this next time",
+  "getting_started.onboarding": "Show me around",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_direct": "Show DMs",
+  "home.settings": "Column settings",
+  "keyboard_shortcuts.bookmark": "to bookmark",
+  "keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
+  "keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
+  "layout.auto": "Auto",
+  "layout.desktop": "Desktop",
+  "layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
+  "layout.hint.desktop": "Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.hint.single": "Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.single": "Mobile",
+  "media_gallery.sensitive": "Sensitive",
+  "moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
+  "navigation_bar.app_settings": "App settings",
+  "navigation_bar.featured_users": "Featured users",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.misc": "Misc",
+  "notification.markForDeletion": "Mark for deletion",
+  "notification_purge.btn_all": "Select\nall",
+  "notification_purge.btn_apply": "Clear\nselected",
+  "notification_purge.btn_invert": "Invert\nselection",
+  "notification_purge.btn_none": "Select\nnone",
+  "notification_purge.start": "Enter notification cleaning mode",
+  "notifications.marked_clear": "Clear selected notifications",
+  "notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "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.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "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.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to {domain}!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "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.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "settings.always_show_spoilers_field": "Always enable the Content Warning field",
+  "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.compose_box_opts": "Compose box",
+  "settings.confirm_before_clearing_draft": "Show confirmation dialog before overwriting the message being composed",
+  "settings.confirm_boost_missing_media_description": "Show confirmation dialog before boosting toots lacking media descriptions",
+  "settings.confirm_missing_media_description": "Show confirmation dialog before sending toots lacking media descriptions",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Regular expression",
+  "settings.content_warnings_filter": "Content warnings to not automatically unfold:",
+  "settings.content_warnings_media_outside": "Display media attachments outside content warnings",
+  "settings.content_warnings_media_outside_hint": "Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments",
+  "settings.content_warnings_shared_state": "Show/hide content of all copies at once",
+  "settings.content_warnings_shared_state_hint": "Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW",
+  "settings.content_warnings_unfold_opts": "Auto-unfolding options",
+  "settings.deprecated_setting": "This setting is now controlled from Mastodon's {settings_page_link}",
+  "settings.enable_collapsed": "Enable collapsed toots",
+  "settings.enable_collapsed_hint": "Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature",
+  "settings.enable_content_warnings_auto_unfold": "Automatically unfold content-warnings",
+  "settings.filters": "Filters",
+  "settings.general": "General",
+  "settings.hicolor_privacy_icons": "High color privacy icons",
+  "settings.hicolor_privacy_icons.hint": "Display privacy icons in bright and easily distinguishable colors",
+  "settings.image_backgrounds": "Image backgrounds",
+  "settings.image_backgrounds_media": "Preview collapsed toot media",
+  "settings.image_backgrounds_media_hint": "If the post has any media attachment, use the first one as a background",
+  "settings.image_backgrounds_users": "Give collapsed toots an image background",
+  "settings.inline_preview_cards": "Inline preview cards for external links",
+  "settings.layout": "Layout:",
+  "settings.layout_opts": "Layout options",
+  "settings.media": "Media",
+  "settings.media_fullwidth": "Full-width media previews",
+  "settings.media_letterbox": "Letterbox media",
+  "settings.media_letterbox_hint": "Scale down and letterbox media to fill the image containers instead of stretching and cropping them",
+  "settings.media_reveal_behind_cw": "Reveal sensitive media behind a CW by default",
+  "settings.notifications.favicon_badge": "Unread notifications favicon badge",
+  "settings.notifications.favicon_badge.hint": "Add a badge for unread notifications to the favicon",
+  "settings.notifications.tab_badge": "Unread notifications badge",
+  "settings.notifications.tab_badge.hint": "Display a badge for unread notifications in the column icons when the notifications column isn't open",
+  "settings.notifications_opts": "Notifications options",
+  "settings.pop_in_left": "Left",
+  "settings.pop_in_player": "Enable pop-in player",
+  "settings.pop_in_position": "Pop-in player position:",
+  "settings.pop_in_right": "Right",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "Prepend “re: ” to content warnings when replying",
+  "settings.preselect_on_reply": "Pre-select usernames on reply",
+  "settings.preselect_on_reply_hint": "When replying to a conversation with multiple participants, pre-select usernames past the first",
+  "settings.rewrite_mentions": "Rewrite mentions in displayed statuses",
+  "settings.rewrite_mentions_acct": "Rewrite with username and domain (when the account is remote)",
+  "settings.rewrite_mentions_no": "Do not rewrite mentions",
+  "settings.rewrite_mentions_username": "Rewrite with username",
+  "settings.shared_settings_link": "user preferences",
+  "settings.show_action_bar": "Show action buttons in collapsed toots",
+  "settings.show_content_type_choice": "Show content-type choice when authoring toots",
+  "settings.show_reply_counter": "Display an estimate of the reply count",
+  "settings.side_arm": "Secondary toot button:",
+  "settings.side_arm.none": "None",
+  "settings.side_arm_reply_mode": "When replying to a toot, the secondary toot button should:",
+  "settings.side_arm_reply_mode.copy": "Copy privacy setting of the toot being replied to",
+  "settings.side_arm_reply_mode.keep": "Keep its set privacy",
+  "settings.side_arm_reply_mode.restrict": "Restrict privacy setting to that of the toot being replied to",
+  "settings.status_icons": "Toot icons",
+  "settings.status_icons_language": "Language indicator",
+  "settings.status_icons_local_only": "Local-only indicator",
+  "settings.status_icons_media": "Media and poll indicators",
+  "settings.status_icons_reply": "Reply indicator",
+  "settings.status_icons_visibility": "Toot privacy indicator",
+  "settings.swipe_to_change_columns": "Allow swiping to change columns (Mobile only)",
+  "settings.tag_misleading_links": "Tag misleading links",
+  "settings.tag_misleading_links.hint": "Add a visual indication with the link target host to every link not mentioning it explicitly",
+  "settings.wide_view": "Wide view (Desktop mode only)",
+  "settings.wide_view_hint": "Stretches columns to better fill the available space.",
+  "status.collapse": "Collapse",
+  "status.has_audio": "Features attached audio files",
+  "status.has_pictures": "Features attached pictures",
+  "status.has_preview_card": "Features an attached preview card",
+  "status.has_video": "Features attached videos",
+  "status.in_reply_to": "This toot is a reply",
+  "status.is_poll": "This toot is a poll",
+  "status.local_only": "Only visible from your instance",
+  "status.sensitive_toggle": "Click to view",
+  "status.uncollapse": "Uncollapse",
+  "web_app_crash.change_your_settings": "Change your {settings}",
+  "web_app_crash.content": "You could try any of the following:",
+  "web_app_crash.debug_info": "Debug information",
+  "web_app_crash.disable_addons": "Disable browser add-ons or built-in translation tools",
+  "web_app_crash.issue_tracker": "issue tracker",
+  "web_app_crash.reload": "Reload",
+  "web_app_crash.reload_page": "{reload} the current page",
+  "web_app_crash.report_issue": "Report a bug in the {issuetracker}",
+  "web_app_crash.settings": "settings",
+  "web_app_crash.title": "We're sorry, but something went wrong with the Mastodon app."
+}
diff --git a/app/javascript/flavours/glitch/locales/ta.json b/app/javascript/flavours/glitch/locales/ta.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ta.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/tai.json b/app/javascript/flavours/glitch/locales/tai.json
new file mode 100644
index 000000000..807ed8207
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/tai.json
@@ -0,0 +1,201 @@
+{
+  "about.fork_disclaimer": "Glitch-soc is free open source software forked from Mastodon.",
+  "account.add_account_note": "Add note for @{name}",
+  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.follows": "Follows",
+  "account.joined": "Joined {date}",
+  "account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
+  "account.view_full_profile": "View full profile",
+  "account_note.cancel": "Cancel",
+  "account_note.edit": "Edit",
+  "account_note.glitch_placeholder": "No comment provided",
+  "account_note.save": "Save",
+  "advanced_options.icon_title": "Advanced options",
+  "advanced_options.local-only.long": "Do not post to other instances",
+  "advanced_options.local-only.short": "Local-only",
+  "advanced_options.local-only.tooltip": "This post is local-only",
+  "advanced_options.threaded_mode.long": "Automatically opens a reply on posting",
+  "advanced_options.threaded_mode.short": "Threaded mode",
+  "advanced_options.threaded_mode.tooltip": "Threaded mode enabled",
+  "boost_modal.missing_description": "This toot contains some media without description",
+  "column.favourited_by": "Favourited by",
+  "column.heading": "Misc",
+  "column.reblogged_by": "Boosted by",
+  "column.subheading": "Miscellaneous options",
+  "column_header.profile": "Profile",
+  "column_subheading.lists": "Lists",
+  "column_subheading.navigation": "Navigation",
+  "community.column_settings.allow_local_only": "Show local-only toots",
+  "compose.attach": "Attach...",
+  "compose.attach.doodle": "Draw something",
+  "compose.attach.upload": "Upload a file",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Plain text",
+  "compose_form.poll.multiple_choices": "Allow multiple choices",
+  "compose_form.poll.single_choice": "Allow one choice",
+  "compose_form.spoiler": "Hide text behind warning",
+  "confirmation_modal.do_not_ask_again": "Do not ask for confirmation again",
+  "confirmations.deprecated_settings.confirm": "Use Mastodon preferences",
+  "confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
+  "confirmations.missing_media_description.confirm": "Send anyway",
+  "confirmations.missing_media_description.edit": "Edit media",
+  "confirmations.missing_media_description.message": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.",
+  "confirmations.unfilter.author": "Author",
+  "confirmations.unfilter.confirm": "Show",
+  "confirmations.unfilter.edit_filter": "Edit filter",
+  "confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
+  "content-type.change": "Content type",
+  "direct.group_by_conversations": "Group by conversation",
+  "endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
+  "favourite_modal.combo": "You can press {combo} to skip this next time",
+  "getting_started.onboarding": "Show me around",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_direct": "Show DMs",
+  "home.settings": "Column settings",
+  "keyboard_shortcuts.bookmark": "to bookmark",
+  "keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
+  "keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
+  "layout.auto": "Auto",
+  "layout.desktop": "Desktop",
+  "layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
+  "layout.hint.desktop": "Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.hint.single": "Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.single": "Mobile",
+  "media_gallery.sensitive": "Sensitive",
+  "moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
+  "navigation_bar.app_settings": "App settings",
+  "navigation_bar.featured_users": "Featured users",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.misc": "Misc",
+  "notification.markForDeletion": "Mark for deletion",
+  "notification_purge.btn_all": "Select\nall",
+  "notification_purge.btn_apply": "Clear\nselected",
+  "notification_purge.btn_invert": "Invert\nselection",
+  "notification_purge.btn_none": "Select\nnone",
+  "notification_purge.start": "Enter notification cleaning mode",
+  "notifications.marked_clear": "Clear selected notifications",
+  "notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "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.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "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.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to {domain}!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "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.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "settings.always_show_spoilers_field": "Always enable the Content Warning field",
+  "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.compose_box_opts": "Compose box",
+  "settings.confirm_before_clearing_draft": "Show confirmation dialog before overwriting the message being composed",
+  "settings.confirm_boost_missing_media_description": "Show confirmation dialog before boosting toots lacking media descriptions",
+  "settings.confirm_missing_media_description": "Show confirmation dialog before sending toots lacking media descriptions",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Regular expression",
+  "settings.content_warnings_filter": "Content warnings to not automatically unfold:",
+  "settings.content_warnings_media_outside": "Display media attachments outside content warnings",
+  "settings.content_warnings_media_outside_hint": "Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments",
+  "settings.content_warnings_shared_state": "Show/hide content of all copies at once",
+  "settings.content_warnings_shared_state_hint": "Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW",
+  "settings.content_warnings_unfold_opts": "Auto-unfolding options",
+  "settings.deprecated_setting": "This setting is now controlled from Mastodon's {settings_page_link}",
+  "settings.enable_collapsed": "Enable collapsed toots",
+  "settings.enable_collapsed_hint": "Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature",
+  "settings.enable_content_warnings_auto_unfold": "Automatically unfold content-warnings",
+  "settings.filters": "Filters",
+  "settings.general": "General",
+  "settings.hicolor_privacy_icons": "High color privacy icons",
+  "settings.hicolor_privacy_icons.hint": "Display privacy icons in bright and easily distinguishable colors",
+  "settings.image_backgrounds": "Image backgrounds",
+  "settings.image_backgrounds_media": "Preview collapsed toot media",
+  "settings.image_backgrounds_media_hint": "If the post has any media attachment, use the first one as a background",
+  "settings.image_backgrounds_users": "Give collapsed toots an image background",
+  "settings.inline_preview_cards": "Inline preview cards for external links",
+  "settings.layout": "Layout:",
+  "settings.layout_opts": "Layout options",
+  "settings.media": "Media",
+  "settings.media_fullwidth": "Full-width media previews",
+  "settings.media_letterbox": "Letterbox media",
+  "settings.media_letterbox_hint": "Scale down and letterbox media to fill the image containers instead of stretching and cropping them",
+  "settings.media_reveal_behind_cw": "Reveal sensitive media behind a CW by default",
+  "settings.notifications.favicon_badge": "Unread notifications favicon badge",
+  "settings.notifications.favicon_badge.hint": "Add a badge for unread notifications to the favicon",
+  "settings.notifications.tab_badge": "Unread notifications badge",
+  "settings.notifications.tab_badge.hint": "Display a badge for unread notifications in the column icons when the notifications column isn't open",
+  "settings.notifications_opts": "Notifications options",
+  "settings.pop_in_left": "Left",
+  "settings.pop_in_player": "Enable pop-in player",
+  "settings.pop_in_position": "Pop-in player position:",
+  "settings.pop_in_right": "Right",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "Prepend “re: ” to content warnings when replying",
+  "settings.preselect_on_reply": "Pre-select usernames on reply",
+  "settings.preselect_on_reply_hint": "When replying to a conversation with multiple participants, pre-select usernames past the first",
+  "settings.rewrite_mentions": "Rewrite mentions in displayed statuses",
+  "settings.rewrite_mentions_acct": "Rewrite with username and domain (when the account is remote)",
+  "settings.rewrite_mentions_no": "Do not rewrite mentions",
+  "settings.rewrite_mentions_username": "Rewrite with username",
+  "settings.shared_settings_link": "user preferences",
+  "settings.show_action_bar": "Show action buttons in collapsed toots",
+  "settings.show_content_type_choice": "Show content-type choice when authoring toots",
+  "settings.show_reply_counter": "Display an estimate of the reply count",
+  "settings.side_arm": "Secondary toot button:",
+  "settings.side_arm.none": "None",
+  "settings.side_arm_reply_mode": "When replying to a toot, the secondary toot button should:",
+  "settings.side_arm_reply_mode.copy": "Copy privacy setting of the toot being replied to",
+  "settings.side_arm_reply_mode.keep": "Keep its set privacy",
+  "settings.side_arm_reply_mode.restrict": "Restrict privacy setting to that of the toot being replied to",
+  "settings.status_icons": "Toot icons",
+  "settings.status_icons_language": "Language indicator",
+  "settings.status_icons_local_only": "Local-only indicator",
+  "settings.status_icons_media": "Media and poll indicators",
+  "settings.status_icons_reply": "Reply indicator",
+  "settings.status_icons_visibility": "Toot privacy indicator",
+  "settings.swipe_to_change_columns": "Allow swiping to change columns (Mobile only)",
+  "settings.tag_misleading_links": "Tag misleading links",
+  "settings.tag_misleading_links.hint": "Add a visual indication with the link target host to every link not mentioning it explicitly",
+  "settings.wide_view": "Wide view (Desktop mode only)",
+  "settings.wide_view_hint": "Stretches columns to better fill the available space.",
+  "status.collapse": "Collapse",
+  "status.has_audio": "Features attached audio files",
+  "status.has_pictures": "Features attached pictures",
+  "status.has_preview_card": "Features an attached preview card",
+  "status.has_video": "Features attached videos",
+  "status.in_reply_to": "This toot is a reply",
+  "status.is_poll": "This toot is a poll",
+  "status.local_only": "Only visible from your instance",
+  "status.sensitive_toggle": "Click to view",
+  "status.uncollapse": "Uncollapse",
+  "web_app_crash.change_your_settings": "Change your {settings}",
+  "web_app_crash.content": "You could try any of the following:",
+  "web_app_crash.debug_info": "Debug information",
+  "web_app_crash.disable_addons": "Disable browser add-ons or built-in translation tools",
+  "web_app_crash.issue_tracker": "issue tracker",
+  "web_app_crash.reload": "Reload",
+  "web_app_crash.reload_page": "{reload} the current page",
+  "web_app_crash.report_issue": "Report a bug in the {issuetracker}",
+  "web_app_crash.settings": "settings",
+  "web_app_crash.title": "We're sorry, but something went wrong with the Mastodon app."
+}
diff --git a/app/javascript/flavours/glitch/locales/te.json b/app/javascript/flavours/glitch/locales/te.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/te.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/th.json b/app/javascript/flavours/glitch/locales/th.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/th.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/tr.json b/app/javascript/flavours/glitch/locales/tr.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/tr.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/tt.json b/app/javascript/flavours/glitch/locales/tt.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/tt.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/ug.json b/app/javascript/flavours/glitch/locales/ug.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ug.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/uk.json b/app/javascript/flavours/glitch/locales/uk.json
new file mode 100644
index 000000000..b21584659
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/uk.json
@@ -0,0 +1,48 @@
+{
+  "advanced_options.local-only.long": "Не дмухати це на інші сервери",
+  "advanced_options.local-only.short": "Лише локальне",
+  "advanced_options.local-only.tooltip": "Цей дмух лише локальний",
+  "compose.attach": "Вкласти...",
+  "compose.attach.doodle": "Помалювати",
+  "compose.attach.upload": "Завантажити сюди файл",
+  "favourite_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
+  "getting_started.onboarding": "Шо тут",
+  "home.column_settings.show_direct": "Показати прямі повідомлення",
+  "layout.auto": "Автоматичний",
+  "layout.desktop": "Настільний",
+  "media_gallery.sensitive": "Чутливі",
+  "navigation_bar.app_settings": "Налаштування програми",
+  "notification.markForDeletion": "Позначити для видалення",
+  "notification_purge.btn_all": "Вибрати\nвсе",
+  "notification_purge.btn_apply": "Очистити\nвибір",
+  "notification_purge.btn_invert": "Інвертувати\nвибір",
+  "notification_purge.btn_none": "Вибрати\nнічого",
+  "notifications.marked_clear": "Очистити вибрані сповіщення",
+  "notifications.marked_clear_confirmation": "Ви впевнені, що хочете незворотньо очистити всі вибрані сповіщення?",
+  "onboarding.page_one.federation": "{domain} є сервером of Mastodon. Mastodon — мережа незалежних серверів, які працюють разом великою соціяльною мережою. Сервери Mastodon також називають „інстансами“.",
+  "onboarding.page_one.welcome": "Ласкаво просимо до {domain}!",
+  "onboarding.page_six.github": "{domain} використовує Glitchsoc. Glitchsoc — дружній {fork} {Mastodon}, сумісний з будь-яким сервером 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.content_warnings": "Content warnings",
+  "settings.enable_collapsed": "Увімкути згорнутання дмухів",
+  "settings.general": "Основне",
+  "settings.image_backgrounds": "Картинки на тлі",
+  "settings.image_backgrounds_media": "Підглядати медіа зі схованих дмухів",
+  "settings.image_backgrounds_users": "Давати схованим дмухам тло-картинку",
+  "settings.media": "Медіа",
+  "settings.media_fullwidth": "Показувати медіа повною шириною",
+  "settings.media_letterbox": "Обрізати медіа",
+  "settings.preferences": "Користувацькі налаштування",
+  "settings.show_action_bar": "Показувати кнопки у згорнутих дмухах",
+  "settings.wide_view": "Широкий вид (тільки в режимі для комп'ютерів)",
+  "status.collapse": "Згорнути",
+  "status.uncollapse": "Розгорнути"
+}
diff --git a/app/javascript/flavours/glitch/locales/ur.json b/app/javascript/flavours/glitch/locales/ur.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/ur.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/vi.json b/app/javascript/flavours/glitch/locales/vi.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/vi.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/whitelist_af.json b/app/javascript/flavours/glitch/locales/whitelist_af.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_af.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ar.json b/app/javascript/flavours/glitch/locales/whitelist_ar.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ar.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ast.json b/app/javascript/flavours/glitch/locales/whitelist_ast.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ast.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_bg.json b/app/javascript/flavours/glitch/locales/whitelist_bg.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_bg.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_bn.json b/app/javascript/flavours/glitch/locales/whitelist_bn.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_bn.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_br.json b/app/javascript/flavours/glitch/locales/whitelist_br.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_br.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ca.json b/app/javascript/flavours/glitch/locales/whitelist_ca.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ca.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ckb.json b/app/javascript/flavours/glitch/locales/whitelist_ckb.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ckb.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_co.json b/app/javascript/flavours/glitch/locales/whitelist_co.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_co.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_cs.json b/app/javascript/flavours/glitch/locales/whitelist_cs.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_cs.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_cy.json b/app/javascript/flavours/glitch/locales/whitelist_cy.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_cy.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_da.json b/app/javascript/flavours/glitch/locales/whitelist_da.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_da.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_de.json b/app/javascript/flavours/glitch/locales/whitelist_de.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_de.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_el.json b/app/javascript/flavours/glitch/locales/whitelist_el.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_el.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_en.json b/app/javascript/flavours/glitch/locales/whitelist_en.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_en.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_eo.json b/app/javascript/flavours/glitch/locales/whitelist_eo.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_eo.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_es-AR.json b/app/javascript/flavours/glitch/locales/whitelist_es-AR.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_es-AR.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_es-MX.json b/app/javascript/flavours/glitch/locales/whitelist_es-MX.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_es-MX.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_es.json b/app/javascript/flavours/glitch/locales/whitelist_es.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_es.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_et.json b/app/javascript/flavours/glitch/locales/whitelist_et.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_et.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_eu.json b/app/javascript/flavours/glitch/locales/whitelist_eu.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_eu.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_fa.json b/app/javascript/flavours/glitch/locales/whitelist_fa.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_fa.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_fi.json b/app/javascript/flavours/glitch/locales/whitelist_fi.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_fi.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_fr.json b/app/javascript/flavours/glitch/locales/whitelist_fr.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_fr.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ga.json b/app/javascript/flavours/glitch/locales/whitelist_ga.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ga.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_gd.json b/app/javascript/flavours/glitch/locales/whitelist_gd.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_gd.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_gl.json b/app/javascript/flavours/glitch/locales/whitelist_gl.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_gl.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_he.json b/app/javascript/flavours/glitch/locales/whitelist_he.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_he.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_hi.json b/app/javascript/flavours/glitch/locales/whitelist_hi.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_hi.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_hr.json b/app/javascript/flavours/glitch/locales/whitelist_hr.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_hr.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_hu.json b/app/javascript/flavours/glitch/locales/whitelist_hu.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_hu.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_hy.json b/app/javascript/flavours/glitch/locales/whitelist_hy.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_hy.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_id.json b/app/javascript/flavours/glitch/locales/whitelist_id.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_id.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_io.json b/app/javascript/flavours/glitch/locales/whitelist_io.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_io.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_is.json b/app/javascript/flavours/glitch/locales/whitelist_is.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_is.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_it.json b/app/javascript/flavours/glitch/locales/whitelist_it.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_it.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ja.json b/app/javascript/flavours/glitch/locales/whitelist_ja.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ja.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ka.json b/app/javascript/flavours/glitch/locales/whitelist_ka.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ka.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_kab.json b/app/javascript/flavours/glitch/locales/whitelist_kab.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_kab.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_kk.json b/app/javascript/flavours/glitch/locales/whitelist_kk.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_kk.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_kn.json b/app/javascript/flavours/glitch/locales/whitelist_kn.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_kn.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ko.json b/app/javascript/flavours/glitch/locales/whitelist_ko.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ko.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ku.json b/app/javascript/flavours/glitch/locales/whitelist_ku.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ku.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_kw.json b/app/javascript/flavours/glitch/locales/whitelist_kw.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_kw.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_lt.json b/app/javascript/flavours/glitch/locales/whitelist_lt.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_lt.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_lv.json b/app/javascript/flavours/glitch/locales/whitelist_lv.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_lv.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_mk.json b/app/javascript/flavours/glitch/locales/whitelist_mk.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_mk.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ml.json b/app/javascript/flavours/glitch/locales/whitelist_ml.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ml.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_mr.json b/app/javascript/flavours/glitch/locales/whitelist_mr.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_mr.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ms.json b/app/javascript/flavours/glitch/locales/whitelist_ms.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ms.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_nl.json b/app/javascript/flavours/glitch/locales/whitelist_nl.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_nl.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_nn.json b/app/javascript/flavours/glitch/locales/whitelist_nn.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_nn.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_no.json b/app/javascript/flavours/glitch/locales/whitelist_no.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_no.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_oc.json b/app/javascript/flavours/glitch/locales/whitelist_oc.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_oc.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_pa.json b/app/javascript/flavours/glitch/locales/whitelist_pa.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_pa.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_pl.json b/app/javascript/flavours/glitch/locales/whitelist_pl.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_pl.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_pt-BR.json b/app/javascript/flavours/glitch/locales/whitelist_pt-BR.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_pt-BR.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_pt-PT.json b/app/javascript/flavours/glitch/locales/whitelist_pt-PT.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_pt-PT.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ro.json b/app/javascript/flavours/glitch/locales/whitelist_ro.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ro.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ru.json b/app/javascript/flavours/glitch/locales/whitelist_ru.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ru.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sa.json b/app/javascript/flavours/glitch/locales/whitelist_sa.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sa.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sc.json b/app/javascript/flavours/glitch/locales/whitelist_sc.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sc.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_si.json b/app/javascript/flavours/glitch/locales/whitelist_si.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_si.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sk.json b/app/javascript/flavours/glitch/locales/whitelist_sk.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sk.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sl.json b/app/javascript/flavours/glitch/locales/whitelist_sl.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sl.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sq.json b/app/javascript/flavours/glitch/locales/whitelist_sq.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sq.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sr-Latn.json b/app/javascript/flavours/glitch/locales/whitelist_sr-Latn.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sr-Latn.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sr.json b/app/javascript/flavours/glitch/locales/whitelist_sr.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sr.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_sv.json b/app/javascript/flavours/glitch/locales/whitelist_sv.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_sv.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_szl.json b/app/javascript/flavours/glitch/locales/whitelist_szl.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_szl.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ta.json b/app/javascript/flavours/glitch/locales/whitelist_ta.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ta.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_tai.json b/app/javascript/flavours/glitch/locales/whitelist_tai.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_tai.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_te.json b/app/javascript/flavours/glitch/locales/whitelist_te.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_te.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_th.json b/app/javascript/flavours/glitch/locales/whitelist_th.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_th.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_tr.json b/app/javascript/flavours/glitch/locales/whitelist_tr.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_tr.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_tt.json b/app/javascript/flavours/glitch/locales/whitelist_tt.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_tt.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ug.json b/app/javascript/flavours/glitch/locales/whitelist_ug.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ug.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_uk.json b/app/javascript/flavours/glitch/locales/whitelist_uk.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_uk.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_ur.json b/app/javascript/flavours/glitch/locales/whitelist_ur.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_ur.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_vi.json b/app/javascript/flavours/glitch/locales/whitelist_vi.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_vi.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_zgh.json b/app/javascript/flavours/glitch/locales/whitelist_zgh.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_zgh.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_zh-CN.json b/app/javascript/flavours/glitch/locales/whitelist_zh-CN.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_zh-CN.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_zh-HK.json b/app/javascript/flavours/glitch/locales/whitelist_zh-HK.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_zh-HK.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/whitelist_zh-TW.json b/app/javascript/flavours/glitch/locales/whitelist_zh-TW.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/whitelist_zh-TW.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/flavours/glitch/locales/zgh.json b/app/javascript/flavours/glitch/locales/zgh.json
new file mode 100644
index 000000000..807ed8207
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/zgh.json
@@ -0,0 +1,201 @@
+{
+  "about.fork_disclaimer": "Glitch-soc is free open source software forked from Mastodon.",
+  "account.add_account_note": "Add note for @{name}",
+  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.follows": "Follows",
+  "account.joined": "Joined {date}",
+  "account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
+  "account.view_full_profile": "View full profile",
+  "account_note.cancel": "Cancel",
+  "account_note.edit": "Edit",
+  "account_note.glitch_placeholder": "No comment provided",
+  "account_note.save": "Save",
+  "advanced_options.icon_title": "Advanced options",
+  "advanced_options.local-only.long": "Do not post to other instances",
+  "advanced_options.local-only.short": "Local-only",
+  "advanced_options.local-only.tooltip": "This post is local-only",
+  "advanced_options.threaded_mode.long": "Automatically opens a reply on posting",
+  "advanced_options.threaded_mode.short": "Threaded mode",
+  "advanced_options.threaded_mode.tooltip": "Threaded mode enabled",
+  "boost_modal.missing_description": "This toot contains some media without description",
+  "column.favourited_by": "Favourited by",
+  "column.heading": "Misc",
+  "column.reblogged_by": "Boosted by",
+  "column.subheading": "Miscellaneous options",
+  "column_header.profile": "Profile",
+  "column_subheading.lists": "Lists",
+  "column_subheading.navigation": "Navigation",
+  "community.column_settings.allow_local_only": "Show local-only toots",
+  "compose.attach": "Attach...",
+  "compose.attach.doodle": "Draw something",
+  "compose.attach.upload": "Upload a file",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "Plain text",
+  "compose_form.poll.multiple_choices": "Allow multiple choices",
+  "compose_form.poll.single_choice": "Allow one choice",
+  "compose_form.spoiler": "Hide text behind warning",
+  "confirmation_modal.do_not_ask_again": "Do not ask for confirmation again",
+  "confirmations.deprecated_settings.confirm": "Use Mastodon preferences",
+  "confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
+  "confirmations.missing_media_description.confirm": "Send anyway",
+  "confirmations.missing_media_description.edit": "Edit media",
+  "confirmations.missing_media_description.message": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.",
+  "confirmations.unfilter.author": "Author",
+  "confirmations.unfilter.confirm": "Show",
+  "confirmations.unfilter.edit_filter": "Edit filter",
+  "confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
+  "content-type.change": "Content type",
+  "direct.group_by_conversations": "Group by conversation",
+  "endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
+  "favourite_modal.combo": "You can press {combo} to skip this next time",
+  "getting_started.onboarding": "Show me around",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_direct": "Show DMs",
+  "home.settings": "Column settings",
+  "keyboard_shortcuts.bookmark": "to bookmark",
+  "keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
+  "keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
+  "layout.auto": "Auto",
+  "layout.desktop": "Desktop",
+  "layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
+  "layout.hint.desktop": "Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.hint.single": "Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.",
+  "layout.single": "Mobile",
+  "media_gallery.sensitive": "Sensitive",
+  "moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
+  "navigation_bar.app_settings": "App settings",
+  "navigation_bar.featured_users": "Featured users",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.misc": "Misc",
+  "notification.markForDeletion": "Mark for deletion",
+  "notification_purge.btn_all": "Select\nall",
+  "notification_purge.btn_apply": "Clear\nselected",
+  "notification_purge.btn_invert": "Invert\nselection",
+  "notification_purge.btn_none": "Select\nnone",
+  "notification_purge.start": "Enter notification cleaning mode",
+  "notifications.marked_clear": "Clear selected notifications",
+  "notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "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.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "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.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to {domain}!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "{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}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "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.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "settings.always_show_spoilers_field": "Always enable the Content Warning field",
+  "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.compose_box_opts": "Compose box",
+  "settings.confirm_before_clearing_draft": "Show confirmation dialog before overwriting the message being composed",
+  "settings.confirm_boost_missing_media_description": "Show confirmation dialog before boosting toots lacking media descriptions",
+  "settings.confirm_missing_media_description": "Show confirmation dialog before sending toots lacking media descriptions",
+  "settings.content_warnings": "Content warnings",
+  "settings.content_warnings.regexp": "Regular expression",
+  "settings.content_warnings_filter": "Content warnings to not automatically unfold:",
+  "settings.content_warnings_media_outside": "Display media attachments outside content warnings",
+  "settings.content_warnings_media_outside_hint": "Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments",
+  "settings.content_warnings_shared_state": "Show/hide content of all copies at once",
+  "settings.content_warnings_shared_state_hint": "Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW",
+  "settings.content_warnings_unfold_opts": "Auto-unfolding options",
+  "settings.deprecated_setting": "This setting is now controlled from Mastodon's {settings_page_link}",
+  "settings.enable_collapsed": "Enable collapsed toots",
+  "settings.enable_collapsed_hint": "Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature",
+  "settings.enable_content_warnings_auto_unfold": "Automatically unfold content-warnings",
+  "settings.filters": "Filters",
+  "settings.general": "General",
+  "settings.hicolor_privacy_icons": "High color privacy icons",
+  "settings.hicolor_privacy_icons.hint": "Display privacy icons in bright and easily distinguishable colors",
+  "settings.image_backgrounds": "Image backgrounds",
+  "settings.image_backgrounds_media": "Preview collapsed toot media",
+  "settings.image_backgrounds_media_hint": "If the post has any media attachment, use the first one as a background",
+  "settings.image_backgrounds_users": "Give collapsed toots an image background",
+  "settings.inline_preview_cards": "Inline preview cards for external links",
+  "settings.layout": "Layout:",
+  "settings.layout_opts": "Layout options",
+  "settings.media": "Media",
+  "settings.media_fullwidth": "Full-width media previews",
+  "settings.media_letterbox": "Letterbox media",
+  "settings.media_letterbox_hint": "Scale down and letterbox media to fill the image containers instead of stretching and cropping them",
+  "settings.media_reveal_behind_cw": "Reveal sensitive media behind a CW by default",
+  "settings.notifications.favicon_badge": "Unread notifications favicon badge",
+  "settings.notifications.favicon_badge.hint": "Add a badge for unread notifications to the favicon",
+  "settings.notifications.tab_badge": "Unread notifications badge",
+  "settings.notifications.tab_badge.hint": "Display a badge for unread notifications in the column icons when the notifications column isn't open",
+  "settings.notifications_opts": "Notifications options",
+  "settings.pop_in_left": "Left",
+  "settings.pop_in_player": "Enable pop-in player",
+  "settings.pop_in_position": "Pop-in player position:",
+  "settings.pop_in_right": "Right",
+  "settings.preferences": "Preferences",
+  "settings.prepend_cw_re": "Prepend “re: ” to content warnings when replying",
+  "settings.preselect_on_reply": "Pre-select usernames on reply",
+  "settings.preselect_on_reply_hint": "When replying to a conversation with multiple participants, pre-select usernames past the first",
+  "settings.rewrite_mentions": "Rewrite mentions in displayed statuses",
+  "settings.rewrite_mentions_acct": "Rewrite with username and domain (when the account is remote)",
+  "settings.rewrite_mentions_no": "Do not rewrite mentions",
+  "settings.rewrite_mentions_username": "Rewrite with username",
+  "settings.shared_settings_link": "user preferences",
+  "settings.show_action_bar": "Show action buttons in collapsed toots",
+  "settings.show_content_type_choice": "Show content-type choice when authoring toots",
+  "settings.show_reply_counter": "Display an estimate of the reply count",
+  "settings.side_arm": "Secondary toot button:",
+  "settings.side_arm.none": "None",
+  "settings.side_arm_reply_mode": "When replying to a toot, the secondary toot button should:",
+  "settings.side_arm_reply_mode.copy": "Copy privacy setting of the toot being replied to",
+  "settings.side_arm_reply_mode.keep": "Keep its set privacy",
+  "settings.side_arm_reply_mode.restrict": "Restrict privacy setting to that of the toot being replied to",
+  "settings.status_icons": "Toot icons",
+  "settings.status_icons_language": "Language indicator",
+  "settings.status_icons_local_only": "Local-only indicator",
+  "settings.status_icons_media": "Media and poll indicators",
+  "settings.status_icons_reply": "Reply indicator",
+  "settings.status_icons_visibility": "Toot privacy indicator",
+  "settings.swipe_to_change_columns": "Allow swiping to change columns (Mobile only)",
+  "settings.tag_misleading_links": "Tag misleading links",
+  "settings.tag_misleading_links.hint": "Add a visual indication with the link target host to every link not mentioning it explicitly",
+  "settings.wide_view": "Wide view (Desktop mode only)",
+  "settings.wide_view_hint": "Stretches columns to better fill the available space.",
+  "status.collapse": "Collapse",
+  "status.has_audio": "Features attached audio files",
+  "status.has_pictures": "Features attached pictures",
+  "status.has_preview_card": "Features an attached preview card",
+  "status.has_video": "Features attached videos",
+  "status.in_reply_to": "This toot is a reply",
+  "status.is_poll": "This toot is a poll",
+  "status.local_only": "Only visible from your instance",
+  "status.sensitive_toggle": "Click to view",
+  "status.uncollapse": "Uncollapse",
+  "web_app_crash.change_your_settings": "Change your {settings}",
+  "web_app_crash.content": "You could try any of the following:",
+  "web_app_crash.debug_info": "Debug information",
+  "web_app_crash.disable_addons": "Disable browser add-ons or built-in translation tools",
+  "web_app_crash.issue_tracker": "issue tracker",
+  "web_app_crash.reload": "Reload",
+  "web_app_crash.reload_page": "{reload} the current page",
+  "web_app_crash.report_issue": "Report a bug in the {issuetracker}",
+  "web_app_crash.settings": "settings",
+  "web_app_crash.title": "We're sorry, but something went wrong with the Mastodon app."
+}
diff --git a/app/javascript/flavours/glitch/locales/zh-CN.json b/app/javascript/flavours/glitch/locales/zh-CN.json
new file mode 100644
index 000000000..a8e604bef
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/zh-CN.json
@@ -0,0 +1,200 @@
+{
+  "about.fork_disclaimer": "Glitch-soc是从Mastodon派生的免费开源软件。",
+  "account.add_account_note": "为 @{name} 添加备注",
+  "account.disclaimer_full": "以下信息可能无法完整代表你的个人资料。",
+  "account.follows": "正在关注",
+  "account.joined": "在 {date} 加入",
+  "account.suspended_disclaimer_full": "该用户已被封禁。",
+  "account.view_full_profile": "查看完整资料",
+  "account_note.cancel": "取消",
+  "account_note.edit": "编辑",
+  "account_note.glitch_placeholder": "暂无备注",
+  "account_note.save": "保存",
+  "advanced_options.icon_title": "高级选项",
+  "advanced_options.local-only.long": "不要发布嘟文到其他实例",
+  "advanced_options.local-only.short": "本地模式",
+  "advanced_options.local-only.tooltip": "这条嘟文仅限于本实例",
+  "advanced_options.threaded_mode.long": "发嘟时自动打开回复",
+  "advanced_options.threaded_mode.short": "线程模式",
+  "advanced_options.threaded_mode.tooltip": "线程模式已启用",
+  "boost_modal.missing_description": "这条嘟文未包含媒体描述",
+  "column.favourited_by": "喜欢",
+  "column.heading": "标题",
+  "column.reblogged_by": "转嘟",
+  "column.subheading": "其他选项",
+  "column_header.profile": "个人资料",
+  "column_subheading.lists": "列表",
+  "column_subheading.navigation": "导航",
+  "community.column_settings.allow_local_only": "只显示本地模式嘟文",
+  "compose.attach": "附上...",
+  "compose.attach.doodle": "画点什么",
+  "compose.attach.upload": "上传文件",
+  "compose.content-type.html": "HTML",
+  "compose.content-type.markdown": "Markdown",
+  "compose.content-type.plain": "纯文本",
+  "compose_form.poll.multiple_choices": "允许多选",
+  "compose_form.poll.single_choice": "允许单选",
+  "compose_form.spoiler": "隐藏为内容警告",
+  "confirmation_modal.do_not_ask_again": "下次不显示确认窗口",
+  "confirmations.deprecated_settings.confirm": "使用 Mastodon 偏好设置",
+  "confirmations.deprecated_settings.message": "您正使用的glitch-soc的特定于此设备的 {app_settings} 已被Mastodon {preferences} 替换,并将被覆盖:",
+  "confirmations.missing_media_description.confirm": "确认",
+  "confirmations.missing_media_description.edit": "编辑",
+  "confirmations.missing_media_description.message": "你没有为一种或多种媒体撰写描述。请考虑为视障人士添加描述。",
+  "confirmations.unfilter.author": "作者",
+  "confirmations.unfilter.confirm": "查看",
+  "confirmations.unfilter.edit_filter": "编辑过滤器",
+  "confirmations.unfilter.filters": "应用 {count, plural, one {过滤器} other {过滤器}}",
+  "content-type.change": "内容类型 ",
+  "direct.group_by_conversations": "以对话分组",
+  "endorsed_accounts_editor.endorsed_accounts": "推荐用户",
+  "favourite_modal.combo": "下次你可以按 {combo} 跳过这个",
+  "getting_started.onboarding": "参观一下",
+  "home.column_settings.advanced": "高级",
+  "home.column_settings.filter_regex": "按正则表达式过滤",
+  "home.column_settings.show_direct": "显示私信",
+  "home.settings": "列表设置",
+  "keyboard_shortcuts.bookmark": "书签",
+  "keyboard_shortcuts.secondary_toot": "使用二级隐私设置发送嘟文",
+  "keyboard_shortcuts.toggle_collapse": "折叠或展开嘟文",
+  "layout.auto": "自动模式",
+  "layout.desktop": "桌面模式",
+  "layout.hint.auto": "根据“启用高级 Web 界面”设置和屏幕大小自动选择布局。",
+  "layout.hint.desktop": "“使用多列布局,无论“启用高级 Web 界面”设置和屏幕大小如何。",
+  "layout.hint.single": "使用单列布局,无论“启用高级 Web 界面”设置和屏幕大小如何。",
+  "layout.single": "移动模式",
+  "media_gallery.sensitive": "敏感内容",
+  "moved_to_warning": "此帐户已被标记为移至 {moved_to_link},并且似乎没有收到新关注者。",
+  "navigation_bar.app_settings": "应用选项",
+  "navigation_bar.featured_users": "推荐用户",
+  "navigation_bar.keyboard_shortcuts": "键盘快捷键",
+  "navigation_bar.misc": "杂项",
+  "notification.markForDeletion": "标记以删除",
+  "notification_purge.btn_all": "全选",
+  "notification_purge.btn_apply": "清除已选",
+  "notification_purge.btn_invert": "反向选择",
+  "notification_purge.btn_none": "取消全选",
+  "notification_purge.start": "进入通知清除模式",
+  "notifications.marked_clear": "清除选择的通知",
+  "notifications.marked_clear_confirmation": "你确定要永久清除所有选择的通知吗?",
+  "onboarding.done": "完成",
+  "onboarding.next": "下一个",
+  "onboarding.page_five.public_timelines": "本地时间线显示来自 {domain} 中所有人的公开嘟文。跨站时间线显示了 {domain} 用户关注的每个人的公开嘟文。这些被称为公共时间线,是发现新朋友的好方法。",
+  "onboarding.page_four.home": "你的主页时间线会显示你关注的人的嘟文。",
+  "onboarding.page_four.notifications": "通知栏显示某人与你互动的内容。",
+  "onboarding.page_one.federation": "{domain} 是 Mastodon 的一个“实例”。Mastodon 是一个由独立服务器组成的,通过不断联合形成的社交网络。我们称这些服务器为实例。",
+  "onboarding.page_one.handle": "你位于 {domain},因此你的完整用户名是 {handle} 。",
+  "onboarding.page_one.welcome": "欢迎来到 {domain}!",
+  "onboarding.page_six.admin": "实例的管理员是 {admin}。",
+  "onboarding.page_six.almost_done": "就快完成了...",
+  "onboarding.page_six.appetoot": "尽情享用吧!",
+  "onboarding.page_six.apps_available": "有适用于 iOS、Android 和其他平台的应用程序。",
+  "onboarding.page_six.github": "{domain} 在 Glitchsoc 上运行。Glitchsoc 是 {Mastodon} 的一个友好 {fork},与任何 Mastodon 实例或应用兼容。Glitchsoc 是完全免费和开源的。你可以在 {github} 上报告错误、请求功能或贡献代码。",
+  "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_two.compose": "在撰写框中撰写嘟文。你可以使用下方图标上传图像、更改隐私设置和添加内容警告。",
+  "onboarding.skip": "跳过",
+  "settings.always_show_spoilers_field": "始终显示内容警告框",
+  "settings.auto_collapse": "自动折叠",
+  "settings.auto_collapse_all": "所有",
+  "settings.auto_collapse_lengthy": "长嘟文",
+  "settings.auto_collapse_media": "带媒体文件的嘟文",
+  "settings.auto_collapse_height": "嘟文被视作长嘟文的临界高度(像素)",
+  "settings.auto_collapse_notifications": "通知",
+  "settings.auto_collapse_reblogs": "转嘟",
+  "settings.auto_collapse_replies": "回复",
+  "settings.close": "关闭",
+  "settings.collapsed_statuses": "折叠嘟文",
+  "settings.compose_box_opts": "撰写框",
+  "settings.confirm_before_clearing_draft": "在覆盖正在写入的嘟文之前显示确认对话框",
+  "settings.confirm_boost_missing_media_description": "在转嘟缺少媒体描述的嘟文之前显示确认对话框",
+  "settings.confirm_missing_media_description": "在发送缺少媒体描述的嘟文之前显示确认对话框",
+  "settings.content_warnings": "内容警告",
+  "settings.content_warnings.regexp": "正则表达式",
+  "settings.content_warnings_filter": "不会自动展开的内容警告:",
+  "settings.content_warnings_media_outside": "在内容警告外显示媒体附件",
+  "settings.content_warnings_media_outside_hint": "通过让内容警告开关不影响媒体附件来复制上游Mastodon行为",
+  "settings.content_warnings_shared_state": "一次显示/隐藏所有副本的内容",
+  "settings.content_warnings_shared_state_hint": "通过让内容警告按钮同时影响所有帖子的副本来重现上游Mastodon行为。这将防止任何展开内容警告的嘟文自动折叠。",
+  "settings.content_warnings_unfold_opts": "自动展开设置项",
+  "settings.deprecated_setting": "此设置现在被 Mastodon 的 {settings_page_link} 控制",
+  "settings.enable_collapsed": "启用折叠嘟文",
+  "settings.enable_collapsed_hint": "让折叠的帖子隐藏部分内容以占用较少的屏幕空间。这与“内容警告”功能不同。",
+  "settings.enable_content_warnings_auto_unfold": "自动展开内容警告",
+  "settings.general": "一般",
+  "settings.hicolor_privacy_icons": "彩色隐私图标 ",
+  "settings.hicolor_privacy_icons.hint": "以明亮且易于区分的颜色显示隐私图标",
+  "settings.image_backgrounds": "图片背景",
+  "settings.image_backgrounds_media": "预览折叠嘟文的媒体文件",
+  "settings.image_backgrounds_media_hint": "如果帖子有任何媒体附件,则使用第一个作为背景",
+  "settings.image_backgrounds_users": "为折叠嘟文附加图片背景",
+  "settings.inline_preview_cards": "外部链接的内嵌预览卡片",
+  "settings.layout": "布局:",
+  "settings.layout_opts": "布局选项",
+  "settings.media": "媒体",
+  "settings.media_fullwidth": "全宽媒体预览",
+  "settings.media_letterbox": "信箱媒体",
+  "settings.media_letterbox_hint": "缩小媒体以填充图像容器而不是拉伸和裁剪它们",
+  "settings.media_reveal_behind_cw": "默认显示内容警告后的敏感媒体",
+  "settings.notifications.favicon_badge": "未读通知网站图标",
+  "settings.notifications.favicon_badge.hint": "将未读通知添加到网站图标",
+  "settings.notifications.tab_badge": "未读通知图标",
+  "settings.notifications.tab_badge.hint": "当通知栏未打开时,显示未读通知图标",
+  "settings.notifications_opts": "通知选项",
+  "settings.pop_in_left": "左边",
+  "settings.pop_in_player": "启用悬浮播放器",
+  "settings.pop_in_position": "悬浮播放器位置:",
+  "settings.pop_in_right": "右边",
+  "settings.preferences": "用户选项",
+  "settings.prepend_cw_re": "回复时在内容警告前加上“re:”",
+  "settings.preselect_on_reply": "回复时预先选择用户名",
+  "settings.preselect_on_reply_hint": "回复与多个参与者的对话时,预先选择第一个用户名",
+  "settings.rewrite_mentions": "重写嘟文中的提及",
+  "settings.rewrite_mentions_acct": "重写为用户名和域名(当帐户为远程时)",
+  "settings.rewrite_mentions_no": "不要重写",
+  "settings.rewrite_mentions_username": "重写为用户名",
+  "settings.shared_settings_link": "用户偏好设置",
+  "settings.show_action_bar": "在折叠的嘟文中显示操作按钮",
+  "settings.show_content_type_choice": "允许你在撰写嘟文时选择格式类型",
+  "settings.show_reply_counter": "显示回复的大致数量",
+  "settings.side_arm": "辅助发嘟按钮:",
+  "settings.side_arm.none": "无",
+  "settings.side_arm_reply_mode": "当回复嘟文时:",
+  "settings.side_arm_reply_mode.copy": "复制被回复嘟文的隐私设置",
+  "settings.side_arm_reply_mode.keep": "保留辅助发嘟按钮以设置隐私",
+  "settings.side_arm_reply_mode.restrict": "将隐私设置限制为正在回复的那条嘟文",
+  "settings.status_icons": "嘟文图标",
+  "settings.status_icons_language": "语言指示器",
+  "settings.status_icons_local_only": "仅本地指示器",
+  "settings.status_icons_media": "媒体和投票指示器",
+  "settings.status_icons_reply": "回复指示器",
+  "settings.status_icons_visibility": "嘟文隐私状态指示器",
+  "settings.swipe_to_change_columns": "允许滑动以在列之间切换(仅限移动模式)",
+  "settings.tag_misleading_links": "标记误导性链接",
+  "settings.tag_misleading_links.hint": "将带有目标网页链接的视觉指示添加到每个未明确的链接",
+  "settings.wide_view": "宽视图(仅限于桌面模式)",
+  "settings.wide_view_hint": "拉伸列宽以更好地填充可用空间。",
+  "status.collapse": "折叠",
+  "status.has_audio": "附带音频文件",
+  "status.has_pictures": "附带图片文件",
+  "status.has_preview_card": "附带预览卡片",
+  "status.has_video": "附带视频文件",
+  "status.in_reply_to": "此嘟文是回复",
+  "status.is_poll": "此嘟文是投票",
+  "status.local_only": "此嘟文仅本实例可见",
+  "status.sensitive_toggle": "点击查看",
+  "status.uncollapse": "不折叠",
+  "web_app_crash.change_your_settings": "更改 {settings}",
+  "web_app_crash.content": "你可以尝试这些:",
+  "web_app_crash.debug_info": "调试信息",
+  "web_app_crash.disable_addons": "禁用浏览器插件或本地翻译工具",
+  "web_app_crash.issue_tracker": "问题追踪器",
+  "web_app_crash.reload": "刷新",
+  "web_app_crash.reload_page": "{reload} 此页面",
+  "web_app_crash.report_issue": "将错误报告给 {issuetracker}",
+  "web_app_crash.settings": "设置",
+  "web_app_crash.title": "抱歉,Mastodon 出了点问题。"
+}
diff --git a/app/javascript/flavours/glitch/locales/zh-HK.json b/app/javascript/flavours/glitch/locales/zh-HK.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/zh-HK.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/locales/zh-TW.json b/app/javascript/flavours/glitch/locales/zh-TW.json
new file mode 100644
index 000000000..4d243f94c
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/zh-TW.json
@@ -0,0 +1,6 @@
+{
+  "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_six.github": "{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}.",
+  "settings.content_warnings": "Content warnings",
+  "settings.preferences": "Preferences"
+}
diff --git a/app/javascript/flavours/glitch/main.jsx b/app/javascript/flavours/glitch/main.jsx
new file mode 100644
index 000000000..14a6effbb
--- /dev/null
+++ b/app/javascript/flavours/glitch/main.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
+import Mastodon, { store } from 'flavours/glitch/containers/mastodon';
+import { me } from 'flavours/glitch/initial_state';
+import ready from 'flavours/glitch/ready';
+
+const perf = require('flavours/glitch/performance');
+
+/**
+ * @returns {Promise<void>}
+ */
+function main() {
+  perf.start('main()');
+
+  return ready(async () => {
+    const mountNode = document.getElementById('mastodon');
+    const props = JSON.parse(mountNode.getAttribute('data-props'));
+
+    ReactDOM.render(<Mastodon {...props} />, mountNode);
+    store.dispatch(setupBrowserNotifications());
+
+    if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) {
+      const { Workbox } = await import('workbox-window');
+      const wb = new Workbox('/sw.js');
+      /** @type {ServiceWorkerRegistration} */
+      let registration;
+
+      try {
+        registration = await wb.register();
+      } catch (err) {
+        console.error(err);
+      }
+
+      if (registration) {
+        const registerPushNotifications = await import('flavours/glitch/actions/push_notifications');
+
+        store.dispatch(registerPushNotifications.register());
+      }
+    }
+
+    perf.stop('main()');
+  });
+}
+
+export default main;
diff --git a/app/javascript/flavours/glitch/middleware/errors.js b/app/javascript/flavours/glitch/middleware/errors.js
new file mode 100644
index 000000000..3639a5951
--- /dev/null
+++ b/app/javascript/flavours/glitch/middleware/errors.js
@@ -0,0 +1,17 @@
+import { showAlertForError } 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)) {
+        dispatch(showAlertForError(action.error, action.skipNotFound));
+      }
+    }
+
+    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..da8cc4c7d
--- /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..7f2388983
--- /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.currentTime = 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..f35b457e1
--- /dev/null
+++ b/app/javascript/flavours/glitch/names.yml
@@ -0,0 +1,40 @@
+en:
+  flavours:
+    glitch:
+      description: The default flavour for GlitchSoc instances.
+      name: Glitch Edition
+  skins:
+    glitch:
+      default: Default
+cs:
+  flavours:
+    glitch:
+      description: Výchozí rozhraní instancí GlitchSoc.
+      name: Glitch
+  skins:
+    glitch:
+      default: Výchozí
+pl:
+  flavours:
+    glitch:
+      description: Domyślny motyw instancji GlitchSoc.
+  skins:
+    glitch:
+      default: Domyślny
+es:
+  flavours:
+    glitch:
+      description: El diseño predeterminado para las instancias con GlitchSoc.
+      name: Glitchsoc
+  skins:
+    glitch:
+      default: Predeterminado
+
+ja:
+  flavours:
+    glitch:
+      description: GlitchSocインスタンスのデフォルトフレーバーです。
+      name: Glitch Edition
+  skins:
+    glitch:
+      default: デフォルト
diff --git a/app/javascript/flavours/glitch/packs/admin.jsx b/app/javascript/flavours/glitch/packs/admin.jsx
new file mode 100644
index 000000000..56cdfc30a
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/admin.jsx
@@ -0,0 +1,24 @@
+import 'packs/public-path';
+import ready from 'flavours/glitch/ready';
+
+ready(() => {
+  const React    = require('react');
+  const ReactDOM = require('react-dom');
+
+  [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
+    const componentName  = element.getAttribute('data-admin-component');
+    const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
+
+    import('flavours/glitch/containers/admin_component').then(({ default: AdminComponent }) => {
+      return import('flavours/glitch/components/admin/' + componentName).then(({ default: Component }) => {
+        ReactDOM.render((
+          <AdminComponent locale={locale}>
+            <Component {...componentProps} />
+          </AdminComponent>
+        ), element);
+      });
+    }).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..7dc34eba9
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/common.js
@@ -0,0 +1,9 @@
+import 'packs/public-path';
+import { start } from '@rails/ujs';
+
+start();
+
+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/error.js b/app/javascript/flavours/glitch/packs/error.js
new file mode 100644
index 000000000..f13e32149
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/error.js
@@ -0,0 +1,14 @@
+import 'packs/public-path';
+import ready from 'flavours/glitch/ready';
+
+ready(() => {
+  const image = document.querySelector('img');
+
+  image.addEventListener('mouseenter', () => {
+    image.src = '/oops.gif';
+  });
+
+  image.addEventListener('mouseleave', () => {
+    image.src = '/oops.png';
+  });
+});
diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js
new file mode 100644
index 000000000..ace9dc3c4
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/home.js
@@ -0,0 +1,10 @@
+import 'packs/public-path';
+import loadPolyfills from 'flavours/glitch/load_polyfills';
+
+loadPolyfills().then(async () => {
+  const { default: main } = await import('flavours/glitch/main');
+
+  return main();
+}).catch(e => {
+  console.error(e);
+});
diff --git a/app/javascript/flavours/glitch/packs/public.jsx b/app/javascript/flavours/glitch/packs/public.jsx
new file mode 100644
index 000000000..b256fdbd5
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/public.jsx
@@ -0,0 +1,224 @@
+import 'packs/public-path';
+import loadPolyfills from 'flavours/glitch/load_polyfills';
+import ready from 'flavours/glitch/ready';
+import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
+
+function main() {
+  const IntlMessageFormat = require('intl-messageformat').default;
+  const { timeAgoString } = require('flavours/glitch/components/relative_timestamp');
+  const { delegate } = require('@rails/ujs');
+  const emojify = require('flavours/glitch/features/emoji/emoji').default;
+  const { getLocale } = require('locales');
+  const { messages } = getLocale();
+  const React = require('react');
+  const ReactDOM = require('react-dom');
+  const { createBrowserHistory } = require('history');
+
+  const scrollToDetailedStatus = () => {
+    const history = createBrowserHistory();
+    const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
+    const location = history.location;
+
+    if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
+      detailedStatuses[0].scrollIntoView();
+      history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
+    }
+  };
+
+  const getEmojiAnimationHandler = (swapTo) => {
+    return ({ target }) => {
+      target.src = target.getAttribute(swapTo);
+    };
+  };
+
+  ready(() => {
+    const locale = document.documentElement.lang;
+
+    const dateTimeFormat = new Intl.DateTimeFormat(locale, {
+      year: 'numeric',
+      month: 'long',
+      day: 'numeric',
+      hour: 'numeric',
+      minute: 'numeric',
+    });
+
+    const dateFormat = new Intl.DateTimeFormat(locale, {
+      year: 'numeric',
+      month: 'short',
+      day: 'numeric',
+      timeFormat: false,
+    });
+
+    const timeFormat = new Intl.DateTimeFormat(locale, {
+      timeStyle: 'short',
+      hour12: false,
+    });
+
+    [].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;
+    });
+
+    const isToday = date => {
+      const today = new Date();
+
+      return date.getDate() === today.getDate() &&
+        date.getMonth() === today.getMonth() &&
+        date.getFullYear() === today.getFullYear();
+    };
+    const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
+
+    [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
+      const datetime = new Date(content.getAttribute('datetime'));
+
+      let formattedContent;
+
+      if (isToday(datetime)) {
+        const formattedTime = timeFormat.format(datetime);
+
+        formattedContent = todayFormat.format({ time: formattedTime });
+      } else {
+        formattedContent = dateFormat.format(datetime);
+      }
+
+      content.title = formattedContent;
+      content.textContent = formattedContent;
+    });
+
+    [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
+      const datetime = new Date(content.getAttribute('datetime'));
+      const now      = new Date();
+
+      content.title = dateTimeFormat.format(datetime);
+      content.textContent = timeAgoString({
+        formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values),
+        formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
+      }, datetime, now, now.getFullYear(), content.getAttribute('datetime').includes('T'));
+    });
+
+    const reactComponents = document.querySelectorAll('[data-component]');
+    if (reactComponents.length > 0) {
+      import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container')
+        .then(({ default: MediaContainer }) => {
+          [].forEach.call(reactComponents, (component) => {
+            [].forEach.call(component.children, (child) => {
+              component.removeChild(child);
+            });
+          });
+
+          const content = document.createElement('div');
+
+          ReactDOM.render(<MediaContainer locale={locale} components={reactComponents} />, content);
+          document.body.appendChild(content);
+          scrollToDetailedStatus();
+        })
+        .catch(error => {
+          console.error(error);
+          scrollToDetailedStatus();
+        });
+    } else {
+      scrollToDetailedStatus();
+    }
+
+    delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
+      const password = document.getElementById('registration_user_password');
+      const confirmation = document.getElementById('registration_user_password_confirmation');
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
+      } else {
+        confirmation.setCustomValidity('');
+      }
+    });
+
+    delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
+      const password = document.getElementById('user_password');
+      const confirmation = document.getElementById('user_password_confirmation');
+      if (!confirmation) return;
+
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
+      } else {
+        confirmation.setCustomValidity('');
+      }
+    });
+
+    delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
+    delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
+
+    delegate(document, '.status__content__spoiler-link', 'click', function() {
+      const statusEl = this.parentNode.parentNode;
+
+      if (statusEl.dataset.spoiler === 'expanded') {
+        statusEl.dataset.spoiler = 'folded';
+        this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format();
+      } else {
+        statusEl.dataset.spoiler = 'expanded';
+        this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format();
+      }
+
+      return false;
+    });
+
+    [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
+      const statusEl = spoilerLink.parentNode.parentNode;
+      const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more');
+      spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
+    });
+  });
+
+  const toggleSidebar = () => {
+    const sidebar = document.querySelector('.sidebar ul');
+    const toggleButton = document.querySelector('.sidebar__toggle__icon');
+
+    if (sidebar.classList.contains('visible')) {
+      document.body.style.overflow = null;
+      toggleButton.setAttribute('aria-expanded', false);
+    } else {
+      document.body.style.overflow = 'hidden';
+      toggleButton.setAttribute('aria-expanded', true);
+    }
+
+    toggleButton.classList.toggle('active');
+    sidebar.classList.toggle('visible');
+  };
+
+  delegate(document, '.sidebar__toggle__icon', 'click', () => {
+    toggleSidebar();
+  });
+
+  delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
+    if (e.key === ' ' || e.key === 'Enter') {
+      e.preventDefault();
+      toggleSidebar();
+    }
+  });
+
+  // Empty the honeypot fields in JS in case something like an extension
+  // automatically filled them.
+  delegate(document, '#registration_new_user,#new_user', 'submit', () => {
+    ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
+      const field = document.getElementById(id);
+      if (field) {
+        field.value = '';
+      }
+    });
+  });
+}
+
+loadPolyfills()
+  .then(main)
+  .then(loadKeyboardExtensions)
+  .catch(error => {
+    console.error(error);
+  });
diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js
new file mode 100644
index 000000000..31c88b2b5
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/settings.js
@@ -0,0 +1,43 @@
+import 'packs/public-path';
+import loadPolyfills from 'flavours/glitch/load_polyfills';
+import ready from 'flavours/glitch/ready';
+import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
+import 'cocoon-js-vanilla';
+
+function main() {
+  const { delegate } = require('@rails/ujs');
+
+  const toggleSidebar = () => {
+    const sidebar = document.querySelector('.sidebar ul');
+    const toggleButton = document.querySelector('.sidebar__toggle__icon');
+
+    if (sidebar.classList.contains('visible')) {
+      document.body.style.overflow = null;
+      toggleButton.setAttribute('aria-expanded', false);
+    } else {
+      document.body.style.overflow = 'hidden';
+      toggleButton.setAttribute('aria-expanded', true);
+    }
+
+    toggleButton.classList.toggle('active');
+    sidebar.classList.toggle('visible');
+  };
+
+  delegate(document, '.sidebar__toggle__icon', 'click', () => {
+    toggleSidebar();
+  });
+
+  delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
+    if (e.key === ' ' || e.key === 'Enter') {
+      e.preventDefault();
+      toggleSidebar();
+    }
+  });
+}
+
+loadPolyfills()
+  .then(main)
+  .then(loadKeyboardExtensions)
+  .catch(error => {
+    console.error(error);
+  });
diff --git a/app/javascript/flavours/glitch/packs/share.jsx b/app/javascript/flavours/glitch/packs/share.jsx
new file mode 100644
index 000000000..e5a79849a
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/share.jsx
@@ -0,0 +1,23 @@
+import 'packs/public-path';
+import loadPolyfills from 'flavours/glitch/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/ready').default;
+  ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+  console.error(error);
+});
diff --git a/app/javascript/flavours/glitch/performance.js b/app/javascript/flavours/glitch/performance.js
new file mode 100644
index 000000000..2b7e1bda8
--- /dev/null
+++ b/app/javascript/flavours/glitch/performance.js
@@ -0,0 +1,32 @@
+//
+// 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);
+  }
+  // eslint-disable-next-line import/no-extraneous-dependencies
+  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/permissions.js b/app/javascript/flavours/glitch/permissions.js
new file mode 100644
index 000000000..9ea149e5f
--- /dev/null
+++ b/app/javascript/flavours/glitch/permissions.js
@@ -0,0 +1,4 @@
+export const PERMISSION_INVITE_USERS      = 0x0000000000010000;
+export const PERMISSION_MANAGE_USERS      = 0x0000000000000400;
+export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020;
+export const PERMISSION_MANAGE_REPORTS    = 0x0000000000000010;
diff --git a/app/javascript/flavours/glitch/ready.js b/app/javascript/flavours/glitch/ready.js
new file mode 100644
index 000000000..e769cc756
--- /dev/null
+++ b/app/javascript/flavours/glitch/ready.js
@@ -0,0 +1,32 @@
+// @ts-check
+
+/**
+ * @param {(() => void) | (() => Promise<void>)} callback
+ * @returns {Promise<void>}
+ */
+export default function ready(callback) {
+  return new Promise((resolve, reject) => {
+    function loaded() {
+      let result;
+      try {
+        result = callback();
+      } catch (err) {
+        reject(err);
+
+        return;
+      }
+
+      if (typeof result?.then === 'function') {
+        result.then(resolve).catch(reject);
+      } else {
+        resolve();
+      }
+    }
+
+    if (['interactive', 'complete'].includes(document.readyState)) {
+      loaded();
+    } else {
+      document.addEventListener('DOMContentLoaded', loaded);
+    }
+  });
+}
diff --git a/app/javascript/flavours/glitch/reducers/account_notes.js b/app/javascript/flavours/glitch/reducers/account_notes.js
new file mode 100644
index 000000000..b1cf2e0aa
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/account_notes.js
@@ -0,0 +1,44 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import {
+  ACCOUNT_NOTE_INIT_EDIT,
+  ACCOUNT_NOTE_CANCEL,
+  ACCOUNT_NOTE_CHANGE_COMMENT,
+  ACCOUNT_NOTE_SUBMIT_REQUEST,
+  ACCOUNT_NOTE_SUBMIT_FAIL,
+  ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from '../actions/account_notes';
+
+const initialState = ImmutableMap({
+  edit: ImmutableMap({
+    isSubmitting: false,
+    account_id: null,
+    comment: null,
+  }),
+});
+
+export default function account_notes(state = initialState, action) {
+  switch (action.type) {
+  case ACCOUNT_NOTE_INIT_EDIT:
+    return state.withMutations((state) => {
+      state.setIn(['edit', 'isSubmitting'], false);
+      state.setIn(['edit', 'account_id'], action.account.get('id'));
+      state.setIn(['edit', 'comment'], action.comment);
+    });
+  case ACCOUNT_NOTE_CHANGE_COMMENT:
+    return state.setIn(['edit', 'comment'], action.comment);
+  case ACCOUNT_NOTE_SUBMIT_REQUEST:
+    return state.setIn(['edit', 'isSubmitting'], true);
+  case ACCOUNT_NOTE_SUBMIT_FAIL:
+    return state.setIn(['edit', 'isSubmitting'], false);
+  case ACCOUNT_NOTE_SUBMIT_SUCCESS:
+  case ACCOUNT_NOTE_CANCEL:
+    return state.withMutations((state) => {
+      state.setIn(['edit', 'isSubmitting'], false);
+      state.setIn(['edit', 'account_id'], null);
+      state.setIn(['edit', 'comment'], null);
+    });
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js
new file mode 100644
index 000000000..07f45f98b
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts.js
@@ -0,0 +1,38 @@
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'flavours/glitch/actions/importer';
+import { ACCOUNT_REVEAL } from 'flavours/glitch/actions/accounts';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const normalizeAccount = (state, account) => {
+  account = { ...account };
+
+  delete account.followers_count;
+  delete account.following_count;
+  delete account.statuses_count;
+
+  account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited;
+
+  return state.set(account.id, fromJS(account));
+};
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+export default function accounts(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_IMPORT:
+    return normalizeAccount(state, action.account);
+  case ACCOUNTS_IMPORT:
+    return normalizeAccounts(state, action.accounts);
+  case ACCOUNT_REVEAL:
+    return state.setIn([action.id, 'hidden'], false);
+  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..4e1256d1b
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts_counters.js
@@ -0,0 +1,38 @@
+import {
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+} from '../actions/accounts';
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+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 initialState = ImmutableMap();
+
+export default function accountsCounters(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_IMPORT:
+    return normalizeAccount(state, action.account);
+  case ACCOUNTS_IMPORT:
+    return normalizeAccounts(state, action.accounts);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    return action.alreadyFollowing ? state :
+      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/accounts_map.js b/app/javascript/flavours/glitch/reducers/accounts_map.js
new file mode 100644
index 000000000..8412ad4d0
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts_map.js
@@ -0,0 +1,20 @@
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts';
+import { Map as ImmutableMap } from 'immutable';
+
+export const normalizeForLookup = str => str.toLowerCase();
+
+const initialState = ImmutableMap();
+
+export default function accountsMap(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_LOOKUP_FAIL:
+    return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state;
+  case ACCOUNT_IMPORT:
+    return state.set(normalizeForLookup(action.account.acct), action.account.id);
+  case ACCOUNTS_IMPORT:
+    return state.withMutations(map => action.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
+  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..f0a696164
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/alerts.js
@@ -0,0 +1,26 @@
+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,
+      message_values: action.message_values,
+    }));
+  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/announcements.js b/app/javascript/flavours/glitch/reducers/announcements.js
new file mode 100644
index 000000000..b53f93a4a
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/announcements.js
@@ -0,0 +1,102 @@
+import {
+  ANNOUNCEMENTS_FETCH_REQUEST,
+  ANNOUNCEMENTS_FETCH_SUCCESS,
+  ANNOUNCEMENTS_FETCH_FAIL,
+  ANNOUNCEMENTS_UPDATE,
+  ANNOUNCEMENTS_REACTION_UPDATE,
+  ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+  ANNOUNCEMENTS_REACTION_ADD_FAIL,
+  ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+  ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+  ANNOUNCEMENTS_TOGGLE_SHOW,
+  ANNOUNCEMENTS_DELETE,
+  ANNOUNCEMENTS_DISMISS_SUCCESS,
+} from '../actions/announcements';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  show: false,
+});
+
+const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
+  if (announcement.get('id') === id) {
+    return announcement.update('reactions', reactions => {
+      const idx = reactions.findIndex(reaction => reaction.get('name') === name);
+
+      if (idx > -1) {
+        return reactions.update(idx, reaction => updater(reaction));
+      }
+
+      return reactions.push(updater(fromJS({ name, count: 0 })));
+    });
+  }
+
+  return announcement;
+}));
+
+const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count));
+
+const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1));
+
+const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
+
+const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at'));
+
+const updateAnnouncement = (state, announcement) => {
+  const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id'));
+
+  if (idx > -1) {
+    // Deep merge is used because announcements from the streaming API do not contain
+    // personalized data about which reactions have been selected by the given user,
+    // and that is information we want to preserve
+    return state.update('items', list => sortAnnouncements(list.update(idx, x => x.mergeDeep(announcement))));
+  }
+
+  return state.update('items', list => sortAnnouncements(list.unshift(announcement)));
+};
+
+export default function announcementsReducer(state = initialState, action) {
+  switch(action.type) {
+  case ANNOUNCEMENTS_TOGGLE_SHOW:
+    return state.withMutations(map => {
+      map.set('show', !map.get('show'));
+    });
+  case ANNOUNCEMENTS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case ANNOUNCEMENTS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      const items = fromJS(action.announcements);
+
+      map.set('items', items);
+      map.set('isLoading', false);
+    });
+  case ANNOUNCEMENTS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case ANNOUNCEMENTS_UPDATE:
+    return updateAnnouncement(state, fromJS(action.announcement));
+  case ANNOUNCEMENTS_REACTION_UPDATE:
+    return updateReactionCount(state, action.reaction);
+  case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
+  case ANNOUNCEMENTS_REACTION_REMOVE_FAIL:
+    return addReaction(state, action.id, action.name);
+  case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
+  case ANNOUNCEMENTS_REACTION_ADD_FAIL:
+    return removeReaction(state, action.id, action.name);
+  case ANNOUNCEMENTS_DISMISS_SUCCESS:
+    return updateAnnouncement(state, fromJS({ 'id': action.id, 'read': true }));
+  case ANNOUNCEMENTS_DELETE:
+    return state.update('items', list => {
+      const idx = list.findIndex(x => x.get('id') === action.id);
+
+      if (idx > -1) {
+        return list.delete(idx);
+      }
+
+      return list;
+    });
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/blocks.js b/app/javascript/flavours/glitch/reducers/blocks.js
new file mode 100644
index 000000000..1b6507163
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/blocks.js
@@ -0,0 +1,22 @@
+import Immutable from 'immutable';
+
+import {
+  BLOCKS_INIT_MODAL,
+} from '../actions/blocks';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    account_id: null,
+  }),
+});
+
+export default function mutes(state = initialState, action) {
+  switch (action.type) {
+  case BLOCKS_INIT_MODAL:
+    return state.withMutations((state) => {
+      state.setIn(['new', 'account_id'], action.account.get('id'));
+    });
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/boosts.js b/app/javascript/flavours/glitch/reducers/boosts.js
new file mode 100644
index 000000000..3541ca0c2
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/boosts.js
@@ -0,0 +1,25 @@
+import Immutable from 'immutable';
+
+import {
+  BOOSTS_INIT_MODAL,
+  BOOSTS_CHANGE_PRIVACY,
+} from 'flavours/glitch/actions/boosts';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    privacy: 'public',
+  }),
+});
+
+export default function mutes(state = initialState, action) {
+  switch (action.type) {
+  case BOOSTS_INIT_MODAL:
+    return state.withMutations((state) => {
+      state.setIn(['new', 'privacy'], action.privacy);
+    });
+  case BOOSTS_CHANGE_PRIVACY:
+    return state.setIn(['new', 'privacy'], action.privacy);
+  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..109e4c723
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -0,0 +1,654 @@
+import {
+  COMPOSE_MOUNT,
+  COMPOSE_UNMOUNT,
+  COMPOSE_CHANGE,
+  COMPOSE_CYCLE_ELEFRIEND,
+  COMPOSE_REPLY,
+  COMPOSE_REPLY_CANCEL,
+  COMPOSE_DIRECT,
+  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_UPLOAD_PROCESSING,
+  THUMBNAIL_UPLOAD_REQUEST,
+  THUMBNAIL_UPLOAD_SUCCESS,
+  THUMBNAIL_UPLOAD_FAIL,
+  THUMBNAIL_UPLOAD_PROGRESS,
+  COMPOSE_SUGGESTIONS_CLEAR,
+  COMPOSE_SUGGESTIONS_READY,
+  COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SUGGESTION_IGNORE,
+  COMPOSE_SUGGESTION_TAGS_UPDATE,
+  COMPOSE_TAG_HISTORY_UPDATE,
+  COMPOSE_ADVANCED_OPTIONS_CHANGE,
+  COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
+  COMPOSE_VISIBILITY_CHANGE,
+  COMPOSE_LANGUAGE_CHANGE,
+  COMPOSE_CONTENT_TYPE_CHANGE,
+  COMPOSE_EMOJI_INSERT,
+  COMPOSE_UPLOAD_CHANGE_REQUEST,
+  COMPOSE_UPLOAD_CHANGE_SUCCESS,
+  COMPOSE_UPLOAD_CHANGE_FAIL,
+  COMPOSE_DOODLE_SET,
+  COMPOSE_RESET,
+  COMPOSE_POLL_ADD,
+  COMPOSE_POLL_REMOVE,
+  COMPOSE_POLL_OPTION_ADD,
+  COMPOSE_POLL_OPTION_CHANGE,
+  COMPOSE_POLL_OPTION_REMOVE,
+  COMPOSE_POLL_SETTINGS_CHANGE,
+  INIT_MEDIA_EDIT_MODAL,
+  COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+  COMPOSE_CHANGE_MEDIA_FOCUS,
+  COMPOSE_SET_STATUS,
+} from 'flavours/glitch/actions/compose';
+import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { REDRAFT } from 'flavours/glitch/actions/statuses';
+import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
+import uuid from '../uuid';
+import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
+import { me, defaultContentType } from 'flavours/glitch/initial_state';
+import { overwrite } from 'flavours/glitch/utils/js_helpers';
+import { unescapeHTML } from 'flavours/glitch/utils/html';
+import { recoverHashtags } from 'flavours/glitch/utils/hashtag';
+
+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: 0,
+  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,
+  id: null,
+  content_type: defaultContentType || 'text/plain',
+  text: '',
+  focusDate: null,
+  caretPosition: null,
+  preselectDate: null,
+  in_reply_to: null,
+  is_submitting: false,
+  is_uploading: false,
+  is_changing_upload: false,
+  progress: 0,
+  isUploadingThumbnail: false,
+  thumbnailProgress: 0,
+  media_attachments: ImmutableList(),
+  pending_media_attachments: 0,
+  poll: null,
+  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,
+  default_language: 'en',
+  resetFileKey: Math.floor((Math.random() * 0x10000)),
+  idempotencyKey: null,
+  tagHistory: ImmutableList(),
+  media_modal: ImmutableMap({
+    id: null,
+    description: '',
+    focusX: 0,
+    focusY: 0,
+    dirty: false,
+  }),
+  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,
+  }),
+});
+
+const initialPoll = ImmutableMap({
+  options: ImmutableList(['', '']),
+  expires_in: 24 * 3600,
+  multiple: 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 apiStatusToTextHashtags (state, status) {
+  const text = unescapeHTML(status.content);
+  return ImmutableOrderedSet([]).union(recoverHashtags(status.tags, text).map(
+    (name) => `#${name} `,
+  )).join('');
+}
+
+function clearAll(state) {
+  return state.withMutations(map => {
+    map.set('id', null);
+    map.set('text', '');
+    if (defaultContentType) map.set('content_type', defaultContentType);
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
+    map.set('is_submitting', false);
+    map.set('is_changing_upload', 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', state.get('default_sensitive'));
+    map.set('language', state.get('default_language'));
+    map.update('media_attachments', list => list.clear());
+    map.set('poll', null);
+    map.set('idempotencyKey', uuid());
+  });
+}
+
+function continueThread (state, status) {
+  return state.withMutations(function (map) {
+    let text = apiStatusToTextMentions(state, status);
+    text = text + apiStatusToTextHashtags(state, status);
+    map.set('text', text);
+    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: status.local_only })),
+    );
+    map.set('privacy', status.visibility);
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('poll', null);
+    map.set('idempotencyKey', uuid());
+    map.set('focusDate', new Date());
+    map.set('caretPosition', null);
+    map.set('preselectDate', new Date());
+  });
+}
+
+function appendMedia(state, media, file) {
+  const prevSize = state.get('media_attachments').size;
+
+  return state.withMutations(map => {
+    if (media.get('type') === 'image') {
+      media = media.set('file', file);
+    }
+    map.update('media_attachments', list => list.push(media.set('unattached', true)));
+    map.set('is_uploading', false);
+    map.set('is_processing', false);
+    map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
+    map.set('idempotencyKey', uuid());
+    map.update('pending_media_attachments', n => n - 1);
+
+    if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
+      map.set('sensitive', true);
+    }
+  });
+}
+
+function removeMedia(state, 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.set('idempotencyKey', uuid());
+
+    if (prevSize === 1) {
+      map.set('sensitive', false);
+    }
+  });
+}
+
+const insertSuggestion = (state, position, token, completion, path) => {
+  return state.withMutations(map => {
+    map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.set('suggestions', ImmutableList());
+    if (path.length === 1 && path[0] === 'text') {
+      map.set('focusDate', new Date());
+      map.set('caretPosition', position + completion.length + 1);
+    }
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const ignoreSuggestion = (state, position, token, completion, path) => {
+  return state.withMutations(map => {
+    map.updateIn(path, oldText => `${oldText.slice(0, position + token.length)} ${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.set('suggestions', ImmutableList());
+    map.set('focusDate', new Date());
+    map.set('caretPosition', position + token.length + 1);
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const sortHashtagsByUse = (state, tags) => {
+  const personalHistory = state.get('tagHistory').map(tag => tag.toLowerCase());
+
+  const tagsWithLowercase = tags.map(t => ({ ...t, lowerName: t.name.toLowerCase() }));
+  const sorted = tagsWithLowercase.sort((a, b) => {
+    const usedA = personalHistory.includes(a.lowerName);
+    const usedB = personalHistory.includes(b.lowerName);
+
+    if (usedA === usedB) {
+      return 0;
+    } else if (usedA && !usedB) {
+      return -1;
+    } else {
+      return 1;
+    }
+  });
+  sorted.forEach(tag => delete tag.lowerName);
+  return sorted;
+};
+
+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('caretPosition', position + emoji.length + 1);
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const hydrate = (state, hydratedState) => {
+  state = clearAll(state.merge(hydratedState));
+
+  if (hydratedState.get('text')) {
+    state = state.set('text', hydratedState.get('text')).set('focusDate', new Date());
+  }
+
+  return state;
+};
+
+const domParser = new DOMParser();
+
+const expandMentions = status => {
+  const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement;
+
+  status.get('mentions').forEach(mention => {
+    fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`;
+  });
+
+  return fragment.innerHTML;
+};
+
+const expiresInFromExpiresAt = expires_at => {
+  if (!expires_at) return 24 * 3600;
+  const delta = (new Date(expires_at).getTime() - Date.now()) / 1000;
+  return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
+};
+
+const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
+  prefix = prefix.toLowerCase();
+  if (suggestions.length < 4) {
+    const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
+    return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
+  } else {
+    return suggestions;
+  }
+};
+
+const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {
+  if (accounts) {
+    return accounts.map(item => ({ id: item.id, type: 'account' }));
+  } else if (emojis) {
+    return emojis.map(item => ({ ...item, type: 'emoji' }));
+  } else {
+    return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory'));
+  }
+};
+
+const updateSuggestionTags = (state, token) => {
+  const prefix = token.slice(1);
+
+  const suggestions = state.get('suggestions').toJS();
+  return state.merge({
+    suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))),
+    suggestion_token: token,
+  });
+};
+
+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', state.get('mounted') + 1);
+  case COMPOSE_UNMOUNT:
+    return state.set('mounted', Math.max(state.get('mounted') - 1, 0));
+  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', !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_CONTENT_TYPE_CHANGE:
+    return state
+      .set('content_type', 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('id', null);
+      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: !!action.status.get('local_only') })),
+      );
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('preselectDate', new Date());
+      map.set('idempotencyKey', uuid());
+
+      map.update('media_attachments', list => list.filter(media => media.get('unattached')));
+
+      if (action.status.get('language') && !action.status.has('translation')) {
+        map.set('language', action.status.get('language'));
+      } else {
+        map.set('language', state.get('default_language'));
+      }
+
+      if (action.status.get('spoiler_text').length > 0) {
+        let spoiler_text = action.status.get('spoiler_text');
+        if (action.prependCWRe && !spoiler_text.match(/^re[: ]/i)) {
+          spoiler_text = 're: '.concat(spoiler_text);
+        }
+        map.set('spoiler', true);
+        map.set('spoiler_text', spoiler_text);
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+    });
+  case COMPOSE_REPLY_CANCEL:
+    state = state.setIn(['advanced_options', 'threaded_mode'], false);
+  case COMPOSE_RESET:
+    return state.withMutations(map => {
+      map.set('in_reply_to', null);
+      if (defaultContentType) map.set('content_type', defaultContentType);
+      map.set('text', '');
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+      map.set('privacy', state.get('default_privacy'));
+      map.set('id', null);
+      map.set('poll', null);
+      map.update(
+        'advanced_options',
+        map => map.mergeWith(overwrite, state.get('default_advanced_options')),
+      );
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_UPLOAD_CHANGE_REQUEST:
+    return state.set('is_changing_upload', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+    return state.set('is_submitting', false);
+  case COMPOSE_UPLOAD_CHANGE_FAIL:
+    return state.set('is_changing_upload', false);
+  case COMPOSE_UPLOAD_REQUEST:
+    return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1);
+  case COMPOSE_UPLOAD_PROCESSING:
+    return state.set('is_processing', true);
+  case COMPOSE_UPLOAD_SUCCESS:
+    return appendMedia(state, fromJS(action.media), action.file);
+  case COMPOSE_UPLOAD_FAIL:
+    return state.set('is_uploading', false).set('is_processing', false).update('pending_media_attachments', n => n - 1);
+  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 THUMBNAIL_UPLOAD_REQUEST:
+    return state.set('isUploadingThumbnail', true);
+  case THUMBNAIL_UPLOAD_PROGRESS:
+    return state.set('thumbnailProgress', Math.round((action.loaded / action.total) * 100));
+  case THUMBNAIL_UPLOAD_FAIL:
+    return state.set('isUploadingThumbnail', false);
+  case THUMBNAIL_UPLOAD_SUCCESS:
+    return state
+      .set('isUploadingThumbnail', false)
+      .update('media_attachments', list => list.map(item => {
+        if (item.get('id') === action.media.id) {
+          return fromJS(action.media);
+        }
+
+        return item;
+      }));
+  case INIT_MEDIA_EDIT_MODAL:
+    const media =  state.get('media_attachments').find(item => item.get('id') === action.id);
+    return state.set('media_modal', ImmutableMap({
+      id: action.id,
+      description: media.get('description') || '',
+      focusX: media.getIn(['meta', 'focus', 'x'], 0),
+      focusY: media.getIn(['meta', 'focus', 'y'], 0),
+      dirty: false,
+    }));
+  case COMPOSE_CHANGE_MEDIA_DESCRIPTION:
+    return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true);
+  case COMPOSE_CHANGE_MEDIA_FOCUS:
+    return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true);
+  case COMPOSE_MENTION:
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_DIRECT:
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('privacy', 'direct');
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.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(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
+  case COMPOSE_SUGGESTION_SELECT:
+    return insertSuggestion(state, action.position, action.token, action.completion, action.path);
+  case COMPOSE_SUGGESTION_IGNORE:
+    return ignoreSuggestion(state, action.position, action.token, action.completion, action.path);
+  case COMPOSE_SUGGESTION_TAGS_UPDATE:
+    return updateSuggestionTags(state, action.token);
+  case COMPOSE_TAG_HISTORY_UPDATE:
+    return state.set('tagHistory', fromJS(action.tags));
+  case TIMELINE_DELETE:
+    if (action.id === state.get('in_reply_to')) {
+      return state.set('in_reply_to', null);
+    } else if (action.id === state.get('id')) {
+      return state.set('id', null);
+    } else {
+      return state;
+    }
+  case COMPOSE_EMOJI_INSERT:
+    return insertEmoji(state, action.position, action.emoji);
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state
+      .set('is_changing_upload', false)
+      .setIn(['media_modal', 'dirty'], false)
+      .update('media_attachments', list => list.map(item => {
+        if (item.get('id') === action.media.id) {
+          return fromJS(action.media).set('unattached', !action.attached);
+        }
+
+        return item;
+      }));
+  case COMPOSE_DOODLE_SET:
+    return state.mergeIn(['doodle'], action.options);
+  case REDRAFT:
+    const do_not_federate = !!action.status.get('local_only');
+    let text = action.raw_text || unescapeHTML(expandMentions(action.status));
+    if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, '');
+    return state.withMutations(map => {
+      map.set('text', text);
+      map.set('content_type', action.content_type || 'text/plain');
+      map.set('in_reply_to', action.status.get('in_reply_to_id'));
+      map.set('privacy', action.status.get('visibility'));
+      map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true)));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+      map.set('sensitive', action.status.get('sensitive'));
+      map.set('language', action.status.get('language'));
+      map.update(
+        'advanced_options',
+        map => map.merge(new ImmutableMap({ do_not_federate })),
+      );
+      map.set('id', null);
+
+      if (action.status.get('spoiler_text').length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.status.get('spoiler_text'));
+
+        if (map.get('media_attachments').size >= 1) {
+          map.set('sensitive', true);
+        }
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+
+      if (action.status.get('poll')) {
+        map.set('poll', ImmutableMap({
+          options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
+          multiple: action.status.getIn(['poll', 'multiple']),
+          expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
+        }));
+      }
+    });
+  case COMPOSE_SET_STATUS:
+    return state.withMutations(map => {
+      map.set('id', action.status.get('id'));
+      map.set('text', action.text);
+      map.set('content_type', action.content_type || 'text/plain');
+      map.set('in_reply_to', action.status.get('in_reply_to_id'));
+      map.set('privacy', action.status.get('visibility'));
+      map.set('media_attachments', action.status.get('media_attachments'));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+      map.set('sensitive', action.status.get('sensitive'));
+      map.set('language', action.status.get('language'));
+
+      if (action.spoiler_text.length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.spoiler_text);
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+
+      if (action.status.get('poll')) {
+        map.set('poll', ImmutableMap({
+          options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
+          multiple: action.status.getIn(['poll', 'multiple']),
+          expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
+        }));
+      }
+    });
+  case COMPOSE_POLL_ADD:
+    return state.set('poll', initialPoll);
+  case COMPOSE_POLL_REMOVE:
+    return state.set('poll', null);
+  case COMPOSE_POLL_OPTION_ADD:
+    return state.updateIn(['poll', 'options'], options => options.push(action.title));
+  case COMPOSE_POLL_OPTION_CHANGE:
+    return state.setIn(['poll', 'options', action.index], action.title);
+  case COMPOSE_POLL_OPTION_REMOVE:
+    return state.updateIn(['poll', 'options'], options => options.delete(action.index));
+  case COMPOSE_POLL_SETTINGS_CHANGE:
+    return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
+  case COMPOSE_LANGUAGE_CHANGE:
+    return state.set('language', action.language);
+  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..aea77ae41
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/contexts.js
@@ -0,0 +1,105 @@
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import { CONTEXT_FETCH_SUCCESS } from 'flavours/glitch/actions/statuses';
+import { TIMELINE_DELETE, TIMELINE_UPDATE } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from '../compare_id';
+
+const initialState = ImmutableMap({
+  inReplyTos: ImmutableMap(),
+  replies: ImmutableMap(),
+});
+
+const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+      function addReply({ id, in_reply_to_id }) {
+        if (in_reply_to_id && !inReplyTos.has(id)) {
+
+          replies.update(in_reply_to_id, ImmutableList(), siblings => {
+            const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
+            return siblings.insert(index + 1, id);
+          });
+
+          inReplyTos.set(id, in_reply_to_id);
+        }
+      }
+
+      // We know in_reply_to_id of statuses but `id` itself.
+      // So we assume that the status of the id replies to last ancestors.
+
+      ancestors.forEach(addReply);
+
+      if (ancestors[0]) {
+        addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
+      }
+
+      descendants.forEach(addReply);
+    }));
+  }));
+});
+
+const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+      ids.forEach(id => {
+        const inReplyToIdOfId = inReplyTos.get(id);
+        const repliesOfId = replies.get(id);
+        const siblings = replies.get(inReplyToIdOfId);
+
+        if (siblings) {
+          replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
+        }
+
+
+        if (repliesOfId) {
+          repliesOfId.forEach(reply => inReplyTos.delete(reply));
+        }
+
+        inReplyTos.delete(id);
+        replies.delete(id);
+      });
+    }));
+  }));
+});
+
+const filterContexts = (state, relationship, statuses) => {
+  const ownedStatusIds = statuses.filter(status => status.get('account') === relationship.id)
+    .map(status => status.get('id'));
+
+  return deleteFromContexts(state, ownedStatusIds);
+};
+
+const updateContext = (state, status) => {
+  if (status.in_reply_to_id) {
+    return state.withMutations(mutable => {
+      const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList());
+
+      mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
+
+      if (!replies.includes(status.id)) {
+        mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id));
+      }
+    });
+  }
+
+  return state;
+};
+
+export default function replies(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterContexts(state, action.relationship, action.statuses);
+  case CONTEXT_FETCH_SUCCESS:
+    return normalizeContext(state, action.id, action.ancestors, action.descendants);
+  case TIMELINE_DELETE:
+    return deleteFromContexts(state, [action.id]);
+  case TIMELINE_UPDATE:
+    return updateContext(state, action.status);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js
new file mode 100644
index 000000000..48b70cc33
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/conversations.js
@@ -0,0 +1,116 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  CONVERSATIONS_MOUNT,
+  CONVERSATIONS_UNMOUNT,
+  CONVERSATIONS_FETCH_REQUEST,
+  CONVERSATIONS_FETCH_SUCCESS,
+  CONVERSATIONS_FETCH_FAIL,
+  CONVERSATIONS_UPDATE,
+  CONVERSATIONS_READ,
+  CONVERSATIONS_DELETE_SUCCESS,
+} from '../actions/conversations';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
+import compareId from '../compare_id';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  hasMore: true,
+  mounted: 0,
+});
+
+const conversationToMap = item => ImmutableMap({
+  id: item.id,
+  unread: item.unread,
+  accounts: ImmutableList(item.accounts.map(a => a.id)),
+  last_status: item.last_status ? item.last_status.id : null,
+});
+
+const updateConversation = (state, item) => state.update('items', list => {
+  const index   = list.findIndex(x => x.get('id') === item.id);
+  const newItem = conversationToMap(item);
+
+  if (index === -1) {
+    return list.unshift(newItem);
+  } else {
+    return list.set(index, newItem);
+  }
+});
+
+const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => {
+  let items = ImmutableList(conversations.map(conversationToMap));
+
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        list = list.map(oldItem => {
+          const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id'));
+
+          if (newItemIndex === -1) {
+            return oldItem;
+          }
+
+          const newItem = items.get(newItemIndex);
+          items = items.delete(newItemIndex);
+
+          return newItem;
+        });
+
+        list = list.concat(items);
+
+        return list.sortBy(x => x.get('last_status'), (a, b) => {
+          if(a === null || b === null) {
+            return -1;
+          }
+
+          return compareId(a, b) * -1;
+        });
+      });
+    }
+
+    if (!next && !isLoadingRecent) {
+      mutable.set('hasMore', false);
+    }
+
+    mutable.set('isLoading', false);
+  });
+};
+
+const filterConversations = (state, accountIds) => {
+  return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId))));
+};
+
+export default function conversations(state = initialState, action) {
+  switch (action.type) {
+  case CONVERSATIONS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case CONVERSATIONS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case CONVERSATIONS_FETCH_SUCCESS:
+    return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent);
+  case CONVERSATIONS_UPDATE:
+    return updateConversation(state, action.conversation);
+  case CONVERSATIONS_MOUNT:
+    return state.update('mounted', count => count + 1);
+  case CONVERSATIONS_UNMOUNT:
+    return state.update('mounted', count => count - 1);
+  case CONVERSATIONS_READ:
+    return state.update('items', list => list.map(item => {
+      if (item.get('id') === action.id) {
+        return item.set('unread', false);
+      }
+
+      return item;
+    }));
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterConversations(state, [action.relationship.id]);
+  case DOMAIN_BLOCK_SUCCESS:
+    return filterConversations(state, action.accounts);
+  case CONVERSATIONS_DELETE_SUCCESS:
+    return state.update('items', list => list.filterNot(item => item.get('id') === action.id));
+  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..7f71ab791
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/custom_emojis.js
@@ -0,0 +1,15 @@
+import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable';
+import { CUSTOM_EMOJIS_FETCH_SUCCESS } from 'flavours/glitch/actions/custom_emojis';
+import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light';
+import { buildCustomEmojis } from 'flavours/glitch/features/emoji/emoji';
+
+const initialState = ImmutableList([]);
+
+export default function custom_emojis(state = initialState, action) {
+  if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) {
+    state = ConvertToImmutable(action.custom_emojis);
+    emojiSearch('', { custom: buildCustomEmojis(state) });
+  }
+
+  return state;
+}
diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js
new file mode 100644
index 000000000..6bf8cee68
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/domain_lists.js
@@ -0,0 +1,25 @@
+import {
+  DOMAIN_BLOCKS_FETCH_SUCCESS,
+  DOMAIN_BLOCKS_EXPAND_SUCCESS,
+  DOMAIN_UNBLOCK_SUCCESS,
+} from '../actions/domain_blocks';
+import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
+
+const initialState = ImmutableMap({
+  blocks: ImmutableMap({
+    items: ImmutableOrderedSet(),
+  }),
+});
+
+export default function domainLists(state = initialState, action) {
+  switch(action.type) {
+  case DOMAIN_BLOCKS_FETCH_SUCCESS:
+    return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next);
+  case DOMAIN_BLOCKS_EXPAND_SUCCESS:
+    return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next);
+  case DOMAIN_UNBLOCK_SUCCESS:
+    return state.updateIn(['blocks', 'items'], set => set.delete(action.domain));
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/dropdown_menu.js b/app/javascript/flavours/glitch/reducers/dropdown_menu.js
new file mode 100644
index 000000000..51bf9375b
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/dropdown_menu.js
@@ -0,0 +1,18 @@
+import Immutable from 'immutable';
+import {
+  DROPDOWN_MENU_OPEN,
+  DROPDOWN_MENU_CLOSE,
+} from '../actions/dropdown_menu';
+
+const initialState = Immutable.Map({ openId: null, keyboard: false, scroll_key: null });
+
+export default function dropdownMenu(state = initialState, action) {
+  switch (action.type) {
+  case DROPDOWN_MENU_OPEN:
+    return state.merge({ openId: action.id, keyboard: action.keyboard, scroll_key: action.scroll_key });
+  case DROPDOWN_MENU_CLOSE:
+    return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state;
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/filters.js b/app/javascript/flavours/glitch/reducers/filters.js
new file mode 100644
index 000000000..e1f014046
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/filters.js
@@ -0,0 +1,44 @@
+import { FILTERS_IMPORT } from '../actions/importer';
+import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters';
+import { Map as ImmutableMap, is, fromJS } from 'immutable';
+
+const normalizeFilter = (state, filter) => {
+  const normalizedFilter = fromJS({
+    id: filter.id,
+    title: filter.title,
+    context: filter.context,
+    filter_action: filter.filter_action,
+    keywords: filter.keywords,
+    expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
+  });
+
+  if (is(state.get(filter.id), normalizedFilter)) {
+    return state;
+  } else {
+    // Do not overwrite keywords when receiving a partial filter
+    return state.update(filter.id, ImmutableMap(), (old) => (
+      old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter)
+    ));
+  }
+};
+
+const normalizeFilters = (state, filters) => {
+  filters.forEach(filter => {
+    state = normalizeFilter(state, filter);
+  });
+
+  return state;
+};
+
+export default function filters(state = ImmutableMap(), action) {
+  switch(action.type) {
+  case FILTERS_CREATE_SUCCESS:
+    return normalizeFilter(state, action.filter);
+  case FILTERS_FETCH_SUCCESS:
+    return normalizeFilters(ImmutableMap(), action.filters);
+  case FILTERS_IMPORT:
+    return normalizeFilters(state, action.filters);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/followed_tags.js b/app/javascript/flavours/glitch/reducers/followed_tags.js
new file mode 100644
index 000000000..84c744640
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/followed_tags.js
@@ -0,0 +1,42 @@
+import {
+  FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+  FOLLOWED_HASHTAGS_FETCH_FAIL,
+  FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+  FOLLOWED_HASHTAGS_EXPAND_FAIL,
+} from 'flavours/glitch/actions/tags';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  next: null,
+});
+
+export default function followed_tags(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWED_HASHTAGS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.followed_tags));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
+    return state.withMutations(map => {
+      map.update('items', set => set.concat(fromJS(action.followed_tags)));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_EXPAND_FAIL:
+    return state.set('isLoading', false);
+  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..660a2d1d7
--- /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/history.js b/app/javascript/flavours/glitch/reducers/history.js
new file mode 100644
index 000000000..04f5f2fd1
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/history.js
@@ -0,0 +1,28 @@
+import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'flavours/glitch/actions/history';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialHistory = ImmutableMap({
+  loading: false,
+  items: ImmutableList(),
+});
+
+const initialState = ImmutableMap();
+
+export default function history(state = initialState, action) {
+  switch(action.type) {
+  case HISTORY_FETCH_REQUEST:
+    return state.update(action.statusId, initialHistory, history => history.withMutations(map => {
+      map.set('loading', true);
+      map.set('items', ImmutableList());
+    }));
+  case HISTORY_FETCH_SUCCESS:
+    return state.update(action.statusId, initialHistory, history => history.withMutations(map => {
+      map.set('loading', false);
+      map.set('items', fromJS(action.history.map((x, i) => ({ ...x, account: x.account.id, original: i === 0 })).reverse()));
+    }));
+  case HISTORY_FETCH_FAIL:
+    return state.update(action.statusId, initialHistory, history => history.set('loading', false));
+  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..5b7bdbf69
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -0,0 +1,94 @@
+import { combineReducers } from 'redux-immutable';
+import dropdown_menu from './dropdown_menu';
+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 domain_lists from './domain_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 mutes from './mutes';
+import blocks from './blocks';
+import server from './server';
+import boosts from './boosts';
+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';
+import listAdder from './list_adder';
+import filters from './filters';
+import conversations from './conversations';
+import suggestions from './suggestions';
+import pinnedAccountsEditor from './pinned_accounts_editor';
+import polls from './polls';
+import trends from './trends';
+import announcements from './announcements';
+import markers from './markers';
+import account_notes from './account_notes';
+import picture_in_picture from './picture_in_picture';
+import accounts_map from './accounts_map';
+import history from './history';
+import tags from './tags';
+import followed_tags from './followed_tags';
+
+const reducers = {
+  announcements,
+  dropdown_menu,
+  timelines,
+  meta,
+  alerts,
+  loadingBar: loadingBarReducer,
+  modal,
+  user_lists,
+  domain_lists,
+  status_lists,
+  accounts,
+  accounts_counters,
+  accounts_map,
+  statuses,
+  relationships,
+  settings,
+  local_settings,
+  push_notifications,
+  mutes,
+  blocks,
+  server,
+  boosts,
+  contexts,
+  compose,
+  search,
+  media_attachments,
+  notifications,
+  height_cache,
+  custom_emojis,
+  lists,
+  listEditor,
+  listAdder,
+  filters,
+  conversations,
+  suggestions,
+  pinnedAccountsEditor,
+  polls,
+  trends,
+  markers,
+  account_notes,
+  picture_in_picture,
+  history,
+  tags,
+  followed_tags,
+};
+
+export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/list_adder.js b/app/javascript/flavours/glitch/reducers/list_adder.js
new file mode 100644
index 000000000..b144610a5
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/list_adder.js
@@ -0,0 +1,47 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  LIST_ADDER_RESET,
+  LIST_ADDER_SETUP,
+  LIST_ADDER_LISTS_FETCH_REQUEST,
+  LIST_ADDER_LISTS_FETCH_SUCCESS,
+  LIST_ADDER_LISTS_FETCH_FAIL,
+  LIST_EDITOR_ADD_SUCCESS,
+  LIST_EDITOR_REMOVE_SUCCESS,
+} from '../actions/lists';
+
+const initialState = ImmutableMap({
+  accountId: null,
+
+  lists: ImmutableMap({
+    items: ImmutableList(),
+    loaded: false,
+    isLoading: false,
+  }),
+});
+
+export default function listAdderReducer(state = initialState, action) {
+  switch(action.type) {
+  case LIST_ADDER_RESET:
+    return initialState;
+  case LIST_ADDER_SETUP:
+    return state.withMutations(map => {
+      map.set('accountId', action.account.get('id'));
+    });
+  case LIST_ADDER_LISTS_FETCH_REQUEST:
+    return state.setIn(['lists', 'isLoading'], true);
+  case LIST_ADDER_LISTS_FETCH_FAIL:
+    return state.setIn(['lists', 'isLoading'], false);
+  case LIST_ADDER_LISTS_FETCH_SUCCESS:
+    return state.update('lists', lists => lists.withMutations(map => {
+      map.set('isLoading', false);
+      map.set('loaded', true);
+      map.set('items', ImmutableList(action.lists.map(item => item.id)));
+    }));
+  case LIST_EDITOR_ADD_SUCCESS:
+    return state.updateIn(['lists', 'items'], list => list.unshift(action.listId));
+  case LIST_EDITOR_REMOVE_SUCCESS:
+    return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId));
+  default:
+    return state;
+  }
+}
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..6e020dbe6
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/list_editor.js
@@ -0,0 +1,96 @@
+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,
+  isChanged: 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.withMutations(map => {
+      map.set('title', action.value);
+      map.set('isChanged', true);
+    });
+  case LIST_CREATE_REQUEST:
+  case LIST_UPDATE_REQUEST:
+    return state.withMutations(map => {
+      map.set('isSubmitting', true);
+      map.set('isChanged', false);
+    });
+  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..ba3e2b3cb
--- /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..887e0e135
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -0,0 +1,81 @@
+//  Package imports.
+import { Map as ImmutableMap } from 'immutable';
+
+//  Our imports.
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { LOCAL_SETTING_CHANGE, LOCAL_SETTING_DELETE } from 'flavours/glitch/actions/local_settings';
+
+const initialState = ImmutableMap({
+  layout    : 'auto',
+  stretch   : true,
+  navbar_under : false,
+  side_arm  : 'none',
+  side_arm_reply_mode : 'keep',
+  show_reply_count : false,
+  always_show_spoilers_field: false,
+  confirm_missing_media_description: false,
+  confirm_boost_missing_media_description: false,
+  confirm_before_clearing_draft: true,
+  prepend_cw_re: true,
+  preselect_on_reply: true,
+  inline_preview_cards: true,
+  hicolor_privacy_icons: false,
+  show_content_type_choice: false,
+  tag_misleading_links: true,
+  rewrite_mentions: 'no',
+  content_warnings : ImmutableMap({
+    filter       : null,
+    media_outside: false,
+    shared_state : false,
+  }),
+  collapsed : ImmutableMap({
+    enabled     : true,
+    auto        : ImmutableMap({
+      all              : false,
+      notifications    : true,
+      lengthy          : true,
+      reblogs          : false,
+      replies          : false,
+      media            : false,
+      height           : 400,
+    }),
+    backgrounds : ImmutableMap({
+      user_backgrounds : false,
+      preview_images   : false,
+    }),
+    show_action_bar : true,
+  }),
+  media     : ImmutableMap({
+    letterbox        : true,
+    fullwidth        : true,
+    reveal_behind_cw : false,
+    pop_in_player    : true,
+    pop_in_position  : 'right',
+  }),
+  notifications : ImmutableMap({
+    favicon_badge : false,
+    tab_badge     : true,
+  }),
+  status_icons : ImmutableMap({
+    language:   true,
+    reply:      true,
+    local_only: true,
+    media:      true,
+    visibility: 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);
+  case LOCAL_SETTING_DELETE:
+    return state.deleteIn(action.key);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/markers.js b/app/javascript/flavours/glitch/reducers/markers.js
new file mode 100644
index 000000000..e3d1b1936
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/markers.js
@@ -0,0 +1,25 @@
+import {
+  MARKERS_SUBMIT_SUCCESS,
+} from '../actions/markers';
+
+const initialState = ImmutableMap({
+  home: '0',
+  notifications: '0',
+});
+
+import { Map as ImmutableMap } from 'immutable';
+
+export default function markers(state = initialState, action) {
+  switch(action.type) {
+  case MARKERS_SUBMIT_SUCCESS:
+    if (action.home) {
+      state = state.set('home', action.home);
+    }
+    if (action.notifications) {
+      state = state.set('notifications', action.notifications);
+    }
+    return state;
+  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..dfd8ea42d
--- /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..7a38a9090
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/meta.js
@@ -0,0 +1,24 @@
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { APP_LAYOUT_CHANGE } from 'flavours/glitch/actions/app';
+import { Map as ImmutableMap } from 'immutable';
+import { layoutFromWindow } from 'flavours/glitch/is_mobile';
+
+const initialState = ImmutableMap({
+  streaming_api_base_url: null,
+  access_token: null,
+  layout: layoutFromWindow(),
+  permissions: '0',
+});
+
+export default function meta(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('meta'))
+      .set('permissions', action.state.getIn(['role', 'permissions']))
+      .set('layout', layoutFromWindow(action.state.getIn(['local_settings', 'layout'])));
+  case APP_LAYOUT_CHANGE:
+    return state.set('layout', action.layout);
+  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..c48117181
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/modal.js
@@ -0,0 +1,39 @@
+import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
+import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from 'flavours/glitch/actions/compose';
+import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+  ignoreFocus: false,
+  stack: ImmutableStack(),
+});
+
+const popModal = (state, { modalType, ignoreFocus }) => {
+  if (modalType === undefined || modalType === state.getIn(['stack', 0, 'modalType'])) {
+    return state.set('ignoreFocus', !!ignoreFocus).update('stack', stack => stack.shift());
+  } else {
+    return state;
+  }
+};
+
+const pushModal = (state, modalType, modalProps) => {
+  return state.withMutations(map => {
+    map.set('ignoreFocus', false);
+    map.update('stack', stack => stack.unshift(ImmutableMap({ modalType, modalProps })));
+  });
+};
+
+export default function modal(state = initialState, action) {
+  switch(action.type) {
+  case MODAL_OPEN:
+    return pushModal(state, action.modalType, action.modalProps);
+  case MODAL_CLOSE:
+    return popModal(state, action);
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
+  case TIMELINE_DELETE:
+    return state.update('stack', stack => stack.filterNot((modal) => modal.get('modalProps').statusId === action.id));
+  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..d346d9a78
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -0,0 +1,31 @@
+import Immutable from 'immutable';
+
+import {
+  MUTES_INIT_MODAL,
+  MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+  MUTES_CHANGE_DURATION,
+} from 'flavours/glitch/actions/mutes';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    account: null,
+    notifications: true,
+    duration: 0,
+  }),
+});
+
+export default function mutes(state = initialState, action) {
+  switch (action.type) {
+  case MUTES_INIT_MODAL:
+    return state.withMutations((state) => {
+      state.setIn(['new', 'account'], action.account);
+      state.setIn(['new', 'notifications'], true);
+    });
+  case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
+    return state.updateIn(['new', 'notifications'], (old) => !old);
+  case MUTES_CHANGE_DURATION:
+    return state.setIn(['new', 'duration'], Number(action.duration));
+  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..d5b1568e9
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -0,0 +1,374 @@
+import {
+  NOTIFICATIONS_MOUNT,
+  NOTIFICATIONS_UNMOUNT,
+  NOTIFICATIONS_SET_VISIBILITY,
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_EXPAND_FAIL,
+  NOTIFICATIONS_FILTER_SET,
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP,
+  NOTIFICATIONS_LOAD_PENDING,
+  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,
+  NOTIFICATIONS_MARK_AS_READ,
+  NOTIFICATIONS_SET_BROWSER_SUPPORT,
+  NOTIFICATIONS_SET_BROWSER_PERMISSION,
+} from 'flavours/glitch/actions/notifications';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+  FOLLOW_REQUEST_REJECT_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  MARKERS_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/markers';
+import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
+import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
+import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from '../compare_id';
+
+const initialState = ImmutableMap({
+  pendingItems: ImmutableList(),
+  items: ImmutableList(),
+  hasMore: true,
+  top: false,
+  mounted: 0,
+  unread: 0,
+  lastReadId: '0',
+  readMarkerId: '0',
+  isLoading: 0,
+  cleaningMode: false,
+  isTabVisible: true,
+  browserSupport: false,
+  browserPermission: 'default',
+  // notification removal mark of new notifs loaded whilst cleaningMode is true.
+  markNewForDelete: false,
+});
+
+const notificationToMap = (notification, markForDelete) => ImmutableMap({
+  id: notification.id,
+  type: notification.type,
+  account: notification.account.id,
+  markedForDelete: markForDelete,
+  status: notification.status ? notification.status.id : null,
+  report: notification.report ? fromJS(notification.report) : null,
+});
+
+const normalizeNotification = (state, notification, usePendingItems) => {
+  const markNewForDelete = state.get('markNewForDelete');
+  const top = state.get('top');
+
+  // Under currently unknown conditions, the client may receive duplicates from the server
+  if (state.get('pendingItems').some((item) => item?.get('id') === notification.id) || state.get('items').some((item) => item?.get('id') === notification.id)) {
+    return state;
+  }
+
+  if (usePendingItems || !state.get('pendingItems').isEmpty()) {
+    return state.update('pendingItems', list => list.unshift(notificationToMap(notification, markNewForDelete))).update('unread', unread => unread + 1);
+  }
+
+  if (shouldCountUnreadNotifications(state)) {
+    state = state.update('unread', unread => unread + 1);
+  } else {
+    state = state.set('lastReadId', notification.id);
+  }
+
+  return state.update('items', list => {
+    if (top && list.size > 40) {
+      list = list.take(20);
+    }
+
+    return list.unshift(notificationToMap(notification, markNewForDelete));
+  });
+};
+
+const expandNormalizedNotifications = (state, notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) => {
+  // This method is pretty tricky because:
+  // - existing notifications might be out of order
+  // - the existing notifications may have gaps, most often explicitly noted with a `null` item
+  // - ideally, we don't want it to reorder existing items
+  // - `notifications` may include items that are already included
+  // - this function can be called either to fill in a gap, or load newer items
+
+  const markNewForDelete = state.get('markNewForDelete');
+  const lastReadId = state.get('lastReadId');
+  const newItems = ImmutableList(notifications.map((notification) => notificationToMap(notification, markNewForDelete)));
+
+  return state.withMutations(mutable => {
+    if (!newItems.isEmpty()) {
+      usePendingItems = isLoadingRecent && (usePendingItems || !mutable.get('pendingItems').isEmpty());
+
+      mutable.update(usePendingItems ? 'pendingItems' : 'items', oldItems => {
+        // If called to poll *new* notifications, we just need to add them on top without duplicates
+        if (isLoadingRecent) {
+          const idsToCheck = oldItems.map(item => item?.get('id')).toSet();
+          const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
+          return insertedItems.concat(oldItems);
+        }
+
+        // If called to expand more (presumably older than any known to the WebUI), we just have to
+        // add them to the bottom without duplicates
+        if (isLoadingMore) {
+          const idsToCheck = oldItems.map(item => item?.get('id')).toSet();
+          const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
+          return oldItems.concat(insertedItems);
+        }
+
+        // Now this gets tricky, as we don't necessarily know for sure where the gap to fill is,
+        // and some items in the timeline may not be properly ordered.
+
+        // However, we know that `newItems.last()` is the oldest item that was requested and that
+        // there is no “hole” between `newItems.last()` and `newItems.first()`.
+
+        // First, find the furthest (if properly sorted, oldest) item in the notifications that is
+        // newer than the oldest fetched one, as it's most likely that it delimits the gap.
+        // Start the gap *after* that item.
+        const lastIndex = oldItems.findLastIndex(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) >= 0) + 1;
+
+        // Then, try to find the furthest (if properly sorted, oldest) item in the notifications that
+        // is newer than the most recent fetched one, as it delimits a section comprised of only
+        // items older or within `newItems` (or that were deleted from the server, so should be removed
+        // anyway).
+        // Stop the gap *after* that item.
+        const firstIndex = oldItems.take(lastIndex).findLastIndex(item => item !== null && compareId(item.get('id'), newItems.first().get('id')) > 0) + 1;
+
+        // At this point:
+        // - no `oldItems` after `firstIndex` is newer than any of the `newItems`
+        // - all `oldItems` after `lastIndex` are older than every of the `newItems`
+        // - it is possible for items in the replaced slice to be older than every `newItems`
+        // - it is possible for items before `firstIndex` to be in the `newItems` range
+        // Therefore:
+        // - to avoid losing items, items from the replaced slice that are older than `newItems`
+        //   should be added in the back.
+        // - to avoid duplicates, `newItems` should be checked the first `firstIndex` items of
+        //   `oldItems`
+        const idsToCheck = oldItems.take(firstIndex).map(item => item?.get('id')).toSet();
+        const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
+        const olderItems = oldItems.slice(firstIndex, lastIndex).filter(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) < 0);
+
+        return oldItems.take(firstIndex).concat(
+          insertedItems,
+          olderItems,
+          oldItems.skip(lastIndex),
+        );
+      });
+    }
+
+    if (!next) {
+      mutable.set('hasMore', false);
+    }
+
+    if (shouldCountUnreadNotifications(state)) {
+      mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), lastReadId) > 0));
+    } else {
+      const mostRecent = newItems.find(item => item !== null);
+      if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {
+        mutable.set('lastReadId', mostRecent.get('id'));
+      }
+    }
+
+    mutable.update('isLoading', (nbLoading) => nbLoading - 1);
+  });
+};
+
+const filterNotifications = (state, accountIds, type) => {
+  const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type')));
+  return state.update('items', helper).update('pendingItems', helper);
+};
+
+const clearUnread = (state) => {
+  state = state.set('unread', state.get('pendingItems').size);
+  const lastNotification = state.get('items').find(item => item !== null);
+  return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
+};
+
+const updateTop = (state, top) => {
+  state = state.set('top', top);
+
+  if (!shouldCountUnreadNotifications(state)) {
+    state = clearUnread(state);
+  }
+
+  return state;
+};
+
+const deleteByStatus = (state, statusId) => {
+  const lastReadId = state.get('lastReadId');
+
+  if (shouldCountUnreadNotifications(state)) {
+    const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+    state = state.update('unread', unread => unread - deletedUnread.size);
+  }
+
+  const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
+  const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+  state = state.update('unread', unread => unread - deletedUnread.size);
+  return state.update('items', helper).update('pendingItems', helper);
+};
+
+const markForDelete = (state, notificationId, yes) => {
+  return state.update('items', list => list.map(item => {
+    if (item === null) {
+      return null;
+    } else 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 (item === null) {
+      return null;
+    } else 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 === null ? item : item.set('markedForDelete', false)));
+};
+
+const deleteMarkedNotifs = (state) => {
+  return state.update('items', list => list.filterNot(item => item === null ? item : item.get('markedForDelete')));
+};
+
+const updateMounted = (state) => {
+  state = state.update('mounted', count => count + 1);
+  if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
+    state = clearUnread(state);
+  }
+  return state;
+};
+
+const updateVisibility = (state, visibility) => {
+  state = state.set('isTabVisible', visibility);
+  if (!shouldCountUnreadNotifications(state)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
+    state = clearUnread(state);
+  }
+  return state;
+};
+
+const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
+  const isTabVisible   = state.get('isTabVisible');
+  const isOnTop        = state.get('top');
+  const isMounted      = state.get('mounted') > 0;
+  const lastReadId     = state.get('lastReadId');
+  const lastItem       = state.get('items').findLast(item => item !== null);
+  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
+
+  return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
+};
+
+const recountUnread = (state, last_read_id) => {
+  return state.withMutations(mutable => {
+    if (compareId(last_read_id, mutable.get('lastReadId')) > 0) {
+      mutable.set('lastReadId', last_read_id);
+    }
+
+    if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) {
+      mutable.set('readMarkerId', last_read_id);
+    }
+
+    if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) {
+      mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0));
+    }
+  });
+};
+
+export default function notifications(state = initialState, action) {
+  let st;
+
+  switch(action.type) {
+  case MARKERS_FETCH_SUCCESS:
+    return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state;
+  case NOTIFICATIONS_MOUNT:
+    return updateMounted(state);
+  case NOTIFICATIONS_UNMOUNT:
+    return state.update('mounted', count => count - 1);
+  case NOTIFICATIONS_SET_VISIBILITY:
+    return updateVisibility(state, action.visibility);
+  case NOTIFICATIONS_LOAD_PENDING:
+    return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
+  case NOTIFICATIONS_EXPAND_REQUEST:
+  case NOTIFICATIONS_DELETE_MARKED_REQUEST:
+    return state.update('isLoading', (nbLoading) => nbLoading + 1);
+  case NOTIFICATIONS_DELETE_MARKED_FAIL:
+  case NOTIFICATIONS_EXPAND_FAIL:
+    return state.update('isLoading', (nbLoading) => nbLoading - 1);
+  case NOTIFICATIONS_FILTER_SET:
+    return state.set('items', ImmutableList()).set('hasMore', true);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification, action.usePendingItems);
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterNotifications(state, [action.relationship.id]);
+  case ACCOUNT_MUTE_SUCCESS:
+    return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
+  case DOMAIN_BLOCK_SUCCESS:
+    return filterNotifications(state, action.accounts);
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return filterNotifications(state, [action.id], 'follow_request');
+  case NOTIFICATIONS_CLEAR:
+    return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
+  case TIMELINE_DELETE:
+    return deleteByStatus(state, action.id);
+  case TIMELINE_DISCONNECT:
+    return action.timeline === 'home' ?
+      state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
+      state;
+  case NOTIFICATIONS_SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case NOTIFICATIONS_SET_BROWSER_PERMISSION:
+    return state.set('browserPermission', action.value);
+
+  case NOTIFICATION_MARK_FOR_DELETE:
+    return markForDelete(state, action.id, action.yes);
+
+  case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
+    return deleteMarkedNotifs(state).update('isLoading', (nbLoading) => nbLoading - 1);
+
+  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);
+
+  case NOTIFICATIONS_MARK_AS_READ:
+    const lastNotification = state.get('items').find(item => item !== null);
+    return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
+
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/picture_in_picture.js b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
new file mode 100644
index 000000000..395c21245
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
@@ -0,0 +1,25 @@
+import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture';
+import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+
+const initialState = {
+  statusId: null,
+  accountId: null,
+  type: null,
+  src: null,
+  muted: false,
+  volume: 0,
+  currentTime: 0,
+};
+
+export default function pictureInPicture(state = initialState, action) {
+  switch(action.type) {
+  case PICTURE_IN_PICTURE_DEPLOY:
+    return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props };
+  case PICTURE_IN_PICTURE_REMOVE:
+    return { ...initialState };
+  case TIMELINE_DELETE:
+    return (state.statusId === action.id) ? { ...initialState } : state;
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js
new file mode 100644
index 000000000..144418d12
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js
@@ -0,0 +1,57 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  PINNED_ACCOUNTS_EDITOR_RESET,
+  PINNED_ACCOUNTS_FETCH_REQUEST,
+  PINNED_ACCOUNTS_FETCH_SUCCESS,
+  PINNED_ACCOUNTS_FETCH_FAIL,
+  PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
+  PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
+  PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
+  ACCOUNT_PIN_SUCCESS,
+  ACCOUNT_UNPIN_SUCCESS,
+} from '../actions/accounts';
+
+const initialState = ImmutableMap({
+  accounts: ImmutableMap({
+    items: ImmutableList(),
+    loaded: false,
+    isLoading: false,
+  }),
+
+  suggestions: ImmutableMap({
+    value: '',
+    items: ImmutableList(),
+  }),
+});
+
+export default function listEditorReducer(state = initialState, action) {
+  switch(action.type) {
+  case PINNED_ACCOUNTS_EDITOR_RESET:
+    return initialState;
+  case PINNED_ACCOUNTS_FETCH_REQUEST:
+    return state.setIn(['accounts', 'isLoading'], true);
+  case PINNED_ACCOUNTS_FETCH_FAIL:
+    return state.setIn(['accounts', 'isLoading'], false);
+  case PINNED_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 PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE:
+    return state.setIn(['suggestions', 'value'], action.value);
+  case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY:
+    return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
+  case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', suggestions => suggestions.withMutations(map => {
+      map.set('items', ImmutableList());
+      map.set('value', '');
+    }));
+  case ACCOUNT_PIN_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.unshift(action.relationship.id));
+  case ACCOUNT_UNPIN_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.relationship.id));
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/polls.js b/app/javascript/flavours/glitch/reducers/polls.js
new file mode 100644
index 000000000..595f340bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/polls.js
@@ -0,0 +1,15 @@
+import { POLLS_IMPORT } from 'flavours/glitch/actions/importer';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll))));
+
+const initialState = ImmutableMap();
+
+export default function polls(state = initialState, action) {
+  switch(action.type) {
+  case POLLS_IMPORT:
+    return importPolls(state, action.polls);
+  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..116c3732f
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/push_notifications.js
@@ -0,0 +1,53 @@
+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,
+    follow_request: false,
+    favourite: false,
+    reblog: false,
+    mention: false,
+    poll: 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..b53f0238c
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -0,0 +1,85 @@
+import {
+  NOTIFICATIONS_UPDATE,
+} from '../actions/notifications';
+import {
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_FOLLOW_REQUEST,
+  ACCOUNT_FOLLOW_FAIL,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_REQUEST,
+  ACCOUNT_UNFOLLOW_FAIL,
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
+  ACCOUNT_PIN_SUCCESS,
+  ACCOUNT_UNPIN_SUCCESS,
+  RELATIONSHIPS_FETCH_SUCCESS,
+  FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+  FOLLOW_REQUEST_REJECT_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  DOMAIN_BLOCK_SUCCESS,
+  DOMAIN_UNBLOCK_SUCCESS,
+} from 'flavours/glitch/actions/domain_blocks';
+import {
+  ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from 'flavours/glitch/actions/account_notes';
+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 setDomainBlocking = (state, accounts, blocking) => {
+  return state.withMutations(map => {
+    accounts.forEach(id => {
+      map.setIn([id, 'domain_blocking'], blocking);
+    });
+  });
+};
+
+const initialState = ImmutableMap();
+
+export default function relationships(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+    return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false);
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false);
+  case NOTIFICATIONS_UPDATE:
+    return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state;
+  case ACCOUNT_FOLLOW_REQUEST:
+    return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+  case ACCOUNT_FOLLOW_FAIL:
+    return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
+  case ACCOUNT_UNFOLLOW_REQUEST:
+    return state.setIn([action.id, 'following'], false);
+  case ACCOUNT_UNFOLLOW_FAIL:
+    return state.setIn([action.id, 'following'], true);
+  case ACCOUNT_FOLLOW_SUCCESS:
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_UNBLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+  case ACCOUNT_UNMUTE_SUCCESS:
+  case ACCOUNT_PIN_SUCCESS:
+  case ACCOUNT_UNPIN_SUCCESS:
+  case ACCOUNT_NOTE_SUBMIT_SUCCESS:
+    return normalizeRelationship(state, action.relationship);
+  case RELATIONSHIPS_FETCH_SUCCESS:
+    return normalizeRelationships(state, action.relationships);
+  case DOMAIN_BLOCK_SUCCESS:
+    return setDomainBlocking(state, action.accounts, true);
+  case DOMAIN_UNBLOCK_SUCCESS:
+    return setDomainBlocking(state, action.accounts, 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..bc0433d1f
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/search.js
@@ -0,0 +1,67 @@
+import {
+  SEARCH_CHANGE,
+  SEARCH_CLEAR,
+  SEARCH_FETCH_REQUEST,
+  SEARCH_FETCH_FAIL,
+  SEARCH_FETCH_SUCCESS,
+  SEARCH_SHOW,
+  SEARCH_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/search';
+import {
+  COMPOSE_MENTION,
+  COMPOSE_REPLY,
+  COMPOSE_DIRECT,
+} from 'flavours/glitch/actions/compose';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  value: '',
+  submitted: false,
+  hidden: false,
+  results: ImmutableMap(),
+  isLoading: false,
+  searchTerm: '',
+});
+
+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:
+  case COMPOSE_DIRECT:
+    return state.set('hidden', true);
+  case SEARCH_FETCH_REQUEST:
+    return state.withMutations(map => {
+      map.set('isLoading', true);
+      map.set('submitted', true);
+    });
+  case SEARCH_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case SEARCH_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('results', ImmutableMap({
+        accounts: ImmutableList(action.results.accounts.map(item => item.id)),
+        statuses: ImmutableList(action.results.statuses.map(item => item.id)),
+        hashtags: fromJS(action.results.hashtags),
+      }));
+
+      map.set('searchTerm', action.searchTerm);
+      map.set('isLoading', false);
+    });
+  case SEARCH_EXPAND_SUCCESS:
+    const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
+    return state.updateIn(['results', action.searchType], list => list.concat(results));
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/server.js b/app/javascript/flavours/glitch/reducers/server.js
new file mode 100644
index 000000000..af0cfc7a9
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/server.js
@@ -0,0 +1,62 @@
+import {
+  SERVER_FETCH_REQUEST,
+  SERVER_FETCH_SUCCESS,
+  SERVER_FETCH_FAIL,
+  SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST,
+  SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS,
+  SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL,
+  EXTENDED_DESCRIPTION_REQUEST,
+  EXTENDED_DESCRIPTION_SUCCESS,
+  EXTENDED_DESCRIPTION_FAIL,
+  SERVER_DOMAIN_BLOCKS_FETCH_REQUEST,
+  SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS,
+  SERVER_DOMAIN_BLOCKS_FETCH_FAIL,
+} from 'flavours/glitch/actions/server';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  server: ImmutableMap({
+    isLoading: true,
+  }),
+
+  extendedDescription: ImmutableMap({
+    isLoading: true,
+  }),
+
+  domainBlocks: ImmutableMap({
+    isLoading: true,
+    isAvailable: true,
+    items: ImmutableList(),
+  }),
+});
+
+export default function server(state = initialState, action) {
+  switch (action.type) {
+  case SERVER_FETCH_REQUEST:
+    return state.setIn(['server', 'isLoading'], true);
+  case SERVER_FETCH_SUCCESS:
+    return state.set('server', fromJS(action.server)).setIn(['server', 'isLoading'], false);
+  case SERVER_FETCH_FAIL:
+    return state.setIn(['server', 'isLoading'], false);
+  case SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST:
+    return state.setIn(['translationLanguages', 'isLoading'], true);
+  case SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS:
+    return state.setIn(['translationLanguages', 'items'], fromJS(action.translationLanguages)).setIn(['translationLanguages', 'isLoading'], false);
+  case SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL:
+    return state.setIn(['translationLanguages', 'isLoading'], false);
+  case EXTENDED_DESCRIPTION_REQUEST:
+    return state.setIn(['extendedDescription', 'isLoading'], true);
+  case EXTENDED_DESCRIPTION_SUCCESS:
+    return state.set('extendedDescription', fromJS(action.description)).setIn(['extendedDescription', 'isLoading'], false);
+  case EXTENDED_DESCRIPTION_FAIL:
+    return state.setIn(['extendedDescription', 'isLoading'], false);
+  case SERVER_DOMAIN_BLOCKS_FETCH_REQUEST:
+    return state.setIn(['domainBlocks', 'isLoading'], true);
+  case SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS:
+    return state.setIn(['domainBlocks', 'items'], fromJS(action.blocks)).setIn(['domainBlocks', 'isLoading'], false).setIn(['domainBlocks', 'isAvailable'], action.isAvailable);
+  case SERVER_DOMAIN_BLOCKS_FETCH_FAIL:
+    return state.setIn(['domainBlocks', 'isLoading'], false);
+  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..e69eee966
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -0,0 +1,179 @@
+import { SETTING_CHANGE, SETTING_SAVE } from 'flavours/glitch/actions/settings';
+import { NOTIFICATIONS_FILTER_SET } from 'flavours/glitch/actions/notifications';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from 'flavours/glitch/actions/columns';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { EMOJI_USE } from 'flavours/glitch/actions/emojis';
+import { LANGUAGE_USE } from 'flavours/glitch/actions/languages';
+import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import uuid from '../uuid';
+
+const initialState = ImmutableMap({
+  saved: true,
+
+  onboarded: false,
+  layout: 'auto',
+
+  skinTone: 1,
+
+  trends: ImmutableMap({
+    show: true,
+  }),
+
+  home: ImmutableMap({
+    shows: ImmutableMap({
+      reblog: true,
+      reply: true,
+      direct: true,
+    }),
+
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  notifications: ImmutableMap({
+    alerts: ImmutableMap({
+      follow: false,
+      follow_request: false,
+      favourite: false,
+      reblog: false,
+      mention: false,
+      poll: false,
+      status: false,
+      update: false,
+      'admin.sign_up': false,
+      'admin.report': false,
+    }),
+
+    quickFilter: ImmutableMap({
+      active: 'all',
+      show: true,
+      advanced: false,
+    }),
+
+    dismissPermissionBanner: false,
+    showUnread: true,
+
+    shows: ImmutableMap({
+      follow: true,
+      follow_request: false,
+      favourite: true,
+      reblog: true,
+      mention: true,
+      poll: true,
+      status: true,
+      update: true,
+      'admin.sign_up': true,
+      'admin.report': true,
+    }),
+
+    sounds: ImmutableMap({
+      follow: true,
+      follow_request: false,
+      favourite: true,
+      reblog: true,
+      mention: true,
+      poll: true,
+      status: true,
+      update: true,
+      'admin.sign_up': true,
+      'admin.report': true,
+    }),
+  }),
+
+  community: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  public: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  direct: ImmutableMap({
+    conversations: true,
+    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 changeColumnParams = (state, uuid, path, value) => {
+  const columns = state.get('columns');
+  const index   = columns.findIndex(item => item.get('uuid') === uuid);
+
+  const newColumns = columns.update(index, column => column.updateIn(['params', ...path], () => value));
+
+  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 updateFrequentLanguages = (state, language) => state.update('frequentlyUsedLanguages', ImmutableMap(), map => map.update(language, 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 NOTIFICATIONS_FILTER_SET:
+  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 COLUMN_PARAMS_CHANGE:
+    return changeColumnParams(state, action.uuid, action.path, action.value);
+  case EMOJI_USE:
+    return updateFrequentEmojis(state, action.emoji);
+  case LANGUAGE_USE:
+    return updateFrequentLanguages(state, action.language);
+  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..a279d3d34
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/status_lists.js
@@ -0,0 +1,148 @@
+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 {
+  BOOKMARKED_STATUSES_FETCH_REQUEST,
+  BOOKMARKED_STATUSES_FETCH_SUCCESS,
+  BOOKMARKED_STATUSES_FETCH_FAIL,
+  BOOKMARKED_STATUSES_EXPAND_REQUEST,
+  BOOKMARKED_STATUSES_EXPAND_SUCCESS,
+  BOOKMARKED_STATUSES_EXPAND_FAIL,
+} from 'flavours/glitch/actions/bookmarks';
+import {
+  PINNED_STATUSES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/pin_statuses';
+import {
+  TRENDS_STATUSES_FETCH_REQUEST,
+  TRENDS_STATUSES_FETCH_SUCCESS,
+  TRENDS_STATUSES_FETCH_FAIL,
+  TRENDS_STATUSES_EXPAND_REQUEST,
+  TRENDS_STATUSES_EXPAND_SUCCESS,
+  TRENDS_STATUSES_EXPAND_FAIL,
+} from 'flavours/glitch/actions/trends';
+import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
+import {
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  BOOKMARK_SUCCESS,
+  UNBOOKMARK_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+
+const initialState = ImmutableMap({
+  favourites: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableOrderedSet(),
+  }),
+  bookmarks: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableOrderedSet(),
+  }),
+  pins: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableOrderedSet(),
+  }),
+  trending: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableOrderedSet(),
+  }),
+});
+
+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', ImmutableOrderedSet(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').union(statuses.map(item => item.id)));
+  }));
+};
+
+const prependOneToList = (state, listType, status) => {
+  return state.updateIn([listType, 'items'], (list) => {
+    if (list.includes(status.get('id'))) {
+      return list;
+    } else {
+      return ImmutableOrderedSet([status.get('id')]).union(list);
+    }
+  });
+};
+
+const removeOneFromList = (state, listType, status) => {
+  return state.updateIn([listType, 'items'], (list) => list.delete(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 BOOKMARKED_STATUSES_FETCH_REQUEST:
+  case BOOKMARKED_STATUSES_EXPAND_REQUEST:
+    return state.setIn(['bookmarks', 'isLoading'], true);
+  case BOOKMARKED_STATUSES_FETCH_FAIL:
+  case BOOKMARKED_STATUSES_EXPAND_FAIL:
+    return state.setIn(['bookmarks', 'isLoading'], false);
+  case BOOKMARKED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'bookmarks', action.statuses, action.next);
+  case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'bookmarks', action.statuses, action.next);
+  case TRENDS_STATUSES_FETCH_REQUEST:
+  case TRENDS_STATUSES_EXPAND_REQUEST:
+    return state.setIn(['trending', 'isLoading'], true);
+  case TRENDS_STATUSES_FETCH_FAIL:
+  case TRENDS_STATUSES_EXPAND_FAIL:
+    return state.setIn(['trending', 'isLoading'], false);
+  case TRENDS_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'trending', action.statuses, action.next);
+  case TRENDS_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'trending', action.statuses, action.next);
+  case FAVOURITE_SUCCESS:
+    return prependOneToList(state, 'favourites', action.status);
+  case UNFAVOURITE_SUCCESS:
+    return removeOneFromList(state, 'favourites', action.status);
+  case BOOKMARK_SUCCESS:
+    return prependOneToList(state, 'bookmarks', action.status);
+  case UNBOOKMARK_SUCCESS:
+    return removeOneFromList(state, 'bookmarks', 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);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id));
+  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..ca220c54d
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -0,0 +1,97 @@
+import {
+  REBLOG_REQUEST,
+  REBLOG_FAIL,
+  FAVOURITE_REQUEST,
+  FAVOURITE_FAIL,
+  UNFAVOURITE_SUCCESS,
+  BOOKMARK_REQUEST,
+  BOOKMARK_FAIL,
+} from 'flavours/glitch/actions/interactions';
+import {
+  STATUS_MUTE_SUCCESS,
+  STATUS_UNMUTE_SUCCESS,
+  STATUS_REVEAL,
+  STATUS_HIDE,
+  STATUS_COLLAPSE,
+  STATUS_TRANSLATE_SUCCESS,
+  STATUS_TRANSLATE_UNDO,
+  STATUS_FETCH_REQUEST,
+  STATUS_FETCH_FAIL,
+} from 'flavours/glitch/actions/statuses';
+import {
+  TIMELINE_DELETE,
+} from 'flavours/glitch/actions/timelines';
+import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const importStatus = (state, status) => state.set(status.id, fromJS(status));
+
+const importStatuses = (state, statuses) =>
+  state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status)));
+
+const deleteStatus = (state, id, references) => {
+  references.forEach(ref => {
+    state = deleteStatus(state, ref, []);
+  });
+
+  return state.delete(id);
+};
+
+const initialState = ImmutableMap();
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_FETCH_REQUEST:
+    return state.setIn([action.id, 'isLoading'], true);
+  case STATUS_FETCH_FAIL:
+    return state.delete(action.id);
+  case STATUS_IMPORT:
+    return importStatus(state, action.status);
+  case STATUSES_IMPORT:
+    return importStatuses(state, action.statuses);
+  case FAVOURITE_REQUEST:
+    return state.setIn([action.status.get('id'), 'favourited'], true);
+  case UNFAVOURITE_SUCCESS:
+    return state.updateIn([action.status.get('id'), 'favourites_count'], x => Math.max(0, x - 1));
+  case FAVOURITE_FAIL:
+    return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
+  case BOOKMARK_REQUEST:
+    return state.setIn([action.status.get('id'), 'bookmarked'], true);
+  case BOOKMARK_FAIL:
+    return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
+  case REBLOG_REQUEST:
+    return state.setIn([action.status.get('id'), 'reblogged'], true);
+  case REBLOG_FAIL:
+    return state.get(action.status.get('id')) === undefined ? state : 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 STATUS_REVEAL:
+    return state.withMutations(map => {
+      action.ids.forEach(id => {
+        if (!(state.get(id) === undefined)) {
+          map.setIn([id, 'hidden'], false);
+        }
+      });
+    });
+  case STATUS_HIDE:
+    return state.withMutations(map => {
+      action.ids.forEach(id => {
+        if (!(state.get(id) === undefined)) {
+          map.setIn([id, 'hidden'], true);
+        }
+      });
+    });
+  case STATUS_COLLAPSE:
+    return state.setIn([action.id, 'collapsed'], action.isCollapsed);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  case STATUS_TRANSLATE_SUCCESS:
+    return state.setIn([action.id, 'translation'], fromJS(action.translation));
+  case STATUS_TRANSLATE_UNDO:
+    return state.deleteIn([action.id, 'translation']);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js
new file mode 100644
index 000000000..3c1ea3fa8
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/suggestions.js
@@ -0,0 +1,37 @@
+import {
+  SUGGESTIONS_FETCH_REQUEST,
+  SUGGESTIONS_FETCH_SUCCESS,
+  SUGGESTIONS_FETCH_FAIL,
+  SUGGESTIONS_DISMISS,
+} from '../actions/suggestions';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+});
+
+export default function suggestionsReducer(state = initialState, action) {
+  switch(action.type) {
+  case SUGGESTIONS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case SUGGESTIONS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
+      map.set('isLoading', false);
+    });
+  case SUGGESTIONS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case SUGGESTIONS_DISMISS:
+    return state.update('items', list => list.filterNot(x => x.account === action.id));
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return state.update('items', list => list.filterNot(x => x.account === action.relationship.id));
+  case DOMAIN_BLOCK_SUCCESS:
+    return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account)));
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/tags.js b/app/javascript/flavours/glitch/reducers/tags.js
new file mode 100644
index 000000000..b280bc4dd
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/tags.js
@@ -0,0 +1,25 @@
+import {
+  HASHTAG_FETCH_SUCCESS,
+  HASHTAG_FOLLOW_REQUEST,
+  HASHTAG_FOLLOW_FAIL,
+  HASHTAG_UNFOLLOW_REQUEST,
+  HASHTAG_UNFOLLOW_FAIL,
+} from 'flavours/glitch/actions/tags';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function tags(state = initialState, action) {
+  switch(action.type) {
+  case HASHTAG_FETCH_SUCCESS:
+    return state.set(action.name, fromJS(action.tag));
+  case HASHTAG_FOLLOW_REQUEST:
+  case HASHTAG_UNFOLLOW_FAIL:
+    return state.setIn([action.name, 'following'], true);
+  case HASHTAG_FOLLOW_FAIL:
+  case HASHTAG_UNFOLLOW_REQUEST:
+    return state.setIn([action.name, 'following'], false);
+  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..96a6ca961
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -0,0 +1,232 @@
+import {
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_CLEAR,
+  TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT,
+  TIMELINE_LOAD_PENDING,
+  TIMELINE_MARK_AS_PARTIAL,
+} 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, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
+import compareId from '../compare_id';
+
+const initialState = ImmutableMap();
+
+const initialTimeline = ImmutableMap({
+  unread: 0,
+  online: false,
+  top: true,
+  isLoading: false,
+  hasMore: true,
+  pendingItems: ImmutableList(),
+  items: ImmutableList(),
+});
+
+const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
+  // This method is pretty tricky because:
+  // - existing items in the timeline might be out of order
+  // - the existing timeline may have gaps, most often explicitly noted with a `null` item
+  // - ideally, we don't want it to reorder existing items of the timeline
+  // - `statuses` may include items that are already included in the timeline
+  // - this function can be called either to fill in a gap, or load newer items
+
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    mMap.set('isLoading', false);
+    mMap.set('isPartial', isPartial);
+
+    if (!next && !isLoadingRecent) mMap.set('hasMore', false);
+
+    if (timeline.endsWith(':pinned')) {
+      mMap.set('items', statuses.map(status => status.get('id')));
+    } else if (!statuses.isEmpty()) {
+      usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('pendingItems').isEmpty());
+
+      mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => {
+        const newIds = statuses.map(status => status.get('id'));
+
+        // Now this gets tricky, as we don't necessarily know for sure where the gap to fill is
+        // and some items in the timeline may not be properly ordered.
+
+        // However, we know that `newIds.last()` is the oldest item that was requested and that
+        // there is no “hole” between `newIds.last()` and `newIds.first()`.
+
+        // First, find the furthest (if properly sorted, oldest) item in the timeline that is
+        // newer than the oldest fetched one, as it's most likely that it delimits the gap.
+        // Start the gap *after* that item.
+        const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
+
+        // Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
+        // is newer than the most recent fetched one, as it delimits a section comprised of only
+        // items older or within `newIds` (or that were deleted from the server, so should be removed
+        // anyway).
+        // Stop the gap *after* that item.
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0) + 1;
+
+        let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
+          // It is possible, though unlikely, that the slice we are replacing contains items older
+          // than the elements we got from the API. Get them and add them back at the back of the
+          // slice.
+          const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => id !== null && compareId(id, newIds.last()) < 0);
+          insertedIds.union(olderIds);
+
+          // Make sure we aren't inserting duplicates
+          insertedIds.subtract(oldIds.take(firstIndex), oldIds.skip(lastIndex));
+        }).toList();
+
+        // Finally, insert a gap marker if the data is marked as partial by the server
+        if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== null)) {
+          insertedIds = insertedIds.unshift(null);
+        }
+
+        return oldIds.take(firstIndex).concat(
+          insertedIds,
+          oldIds.skip(lastIndex),
+        );
+      });
+    }
+  }));
+};
+
+const updateTimeline = (state, timeline, status, usePendingItems, filtered) => {
+  const top = state.getIn([timeline, 'top']);
+
+  if (usePendingItems || !state.getIn([timeline, 'pendingItems']).isEmpty()) {
+    if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) {
+      return state;
+    }
+
+    state = state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id'))));
+
+    if (!filtered) {
+      state = state.updateIn([timeline, 'unread'], unread => unread + 1);
+    }
+
+    return state;
+  }
+
+  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 && !filtered) mMap.set('unread', unread + 1);
+    if (top && ids.size > 40) newIds = newIds.take(20);
+    mMap.set('items', newIds.unshift(status.get('id')));
+  }));
+};
+
+const deleteStatus = (state, id, references, exclude_account = null) => {
+  state.keySeq().forEach(timeline => {
+    if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) {
+      const helper = list => list.filterNot(item => item === id);
+      state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper);
+    }
+  });
+
+  // Remove reblogs of deleted status
+  references.forEach(ref => {
+    state = deleteStatus(state, ref, [], exclude_account);
+  });
+
+  return state;
+};
+
+const clearTimeline = (state, timeline) => {
+  return state.set(timeline, initialTimeline);
+};
+
+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'));
+    state      = deleteStatus(state, status.get('id'), references, relationship.id);
+  });
+
+  return state;
+};
+
+const filterTimeline = (timeline, state, relationship, statuses) => {
+  const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id);
+  return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper);
+};
+
+const updateTop = (state, timeline, top) => {
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    if (top) mMap.set('unread', mMap.get('pendingItems').size);
+    mMap.set('top', top);
+  }));
+};
+
+const reconnectTimeline = (state, usePendingItems) => {
+  if (state.get('online')) {
+    return state;
+  }
+
+  return state.withMutations(mMap => {
+    mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items);
+    mMap.set('online', true);
+  });
+};
+
+export default function timelines(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_LOAD_PENDING:
+    return state.update(action.timeline, initialTimeline, map =>
+      map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0));
+  case TIMELINE_EXPAND_REQUEST:
+    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
+  case TIMELINE_EXPAND_FAIL:
+    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
+  case TIMELINE_EXPAND_SUCCESS:
+    return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems);
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems, action.filtered);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references, action.reblogOf);
+  case TIMELINE_CLEAR:
+    return clearTimeline(state, action.timeline);
+  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 => reconnectTimeline(map, action.usePendingItems));
+  case TIMELINE_DISCONNECT:
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items),
+    );
+  case TIMELINE_MARK_AS_PARTIAL:
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0),
+    );
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/trends.js b/app/javascript/flavours/glitch/reducers/trends.js
new file mode 100644
index 000000000..0b8e0882d
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/trends.js
@@ -0,0 +1,46 @@
+import {
+  TRENDS_TAGS_FETCH_REQUEST,
+  TRENDS_TAGS_FETCH_SUCCESS,
+  TRENDS_TAGS_FETCH_FAIL,
+  TRENDS_LINKS_FETCH_REQUEST,
+  TRENDS_LINKS_FETCH_SUCCESS,
+  TRENDS_LINKS_FETCH_FAIL,
+} from 'flavours/glitch/actions/trends';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  tags: ImmutableMap({
+    items: ImmutableList(),
+    isLoading: false,
+  }),
+
+  links: ImmutableMap({
+    items: ImmutableList(),
+    isLoading: false,
+  }),
+});
+
+export default function trendsReducer(state = initialState, action) {
+  switch(action.type) {
+  case TRENDS_TAGS_FETCH_REQUEST:
+    return state.setIn(['tags', 'isLoading'], true);
+  case TRENDS_TAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['tags', 'items'], fromJS(action.trends));
+      map.setIn(['tags', 'isLoading'], false);
+    });
+  case TRENDS_TAGS_FETCH_FAIL:
+    return state.setIn(['tags', 'isLoading'], false);
+  case TRENDS_LINKS_FETCH_REQUEST:
+    return state.setIn(['links', 'isLoading'], true);
+  case TRENDS_LINKS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['links', 'items'], fromJS(action.trends));
+      map.setIn(['links', 'isLoading'], false);
+    });
+  case TRENDS_LINKS_FETCH_FAIL:
+    return state.setIn(['links', 'isLoading'], 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..9e020fd91
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/user_lists.js
@@ -0,0 +1,190 @@
+import {
+  NOTIFICATIONS_UPDATE,
+} from '../actions/notifications';
+import {
+  FOLLOWERS_FETCH_REQUEST,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_FAIL,
+  FOLLOWERS_EXPAND_REQUEST,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWERS_EXPAND_FAIL,
+  FOLLOWING_FETCH_REQUEST,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_FETCH_FAIL,
+  FOLLOWING_EXPAND_REQUEST,
+  FOLLOWING_EXPAND_SUCCESS,
+  FOLLOWING_EXPAND_FAIL,
+  FOLLOW_REQUESTS_FETCH_REQUEST,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_FAIL,
+  FOLLOW_REQUESTS_EXPAND_REQUEST,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_FAIL,
+  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_REQUEST,
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_FETCH_FAIL,
+  BLOCKS_EXPAND_REQUEST,
+  BLOCKS_EXPAND_SUCCESS,
+  BLOCKS_EXPAND_FAIL,
+} from 'flavours/glitch/actions/blocks';
+import {
+  MUTES_FETCH_REQUEST,
+  MUTES_FETCH_SUCCESS,
+  MUTES_FETCH_FAIL,
+  MUTES_EXPAND_REQUEST,
+  MUTES_EXPAND_SUCCESS,
+  MUTES_EXPAND_FAIL,
+} from 'flavours/glitch/actions/mutes';
+import {
+  DIRECTORY_FETCH_REQUEST,
+  DIRECTORY_FETCH_SUCCESS,
+  DIRECTORY_FETCH_FAIL,
+  DIRECTORY_EXPAND_REQUEST,
+  DIRECTORY_EXPAND_SUCCESS,
+  DIRECTORY_EXPAND_FAIL,
+} from 'flavours/glitch/actions/directory';
+import {
+  FEATURED_TAGS_FETCH_REQUEST,
+  FEATURED_TAGS_FETCH_SUCCESS,
+  FEATURED_TAGS_FETCH_FAIL,
+} from 'flavours/glitch/actions/featured_tags';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialListState = ImmutableMap({
+  next: null,
+  isLoading: false,
+  items: ImmutableList(),
+});
+
+const initialState = ImmutableMap({
+  followers: initialListState,
+  following: initialListState,
+  reblogged_by: initialListState,
+  favourited_by: initialListState,
+  follow_requests: initialListState,
+  blocks: initialListState,
+  mutes: initialListState,
+  featured_tags: initialListState,
+});
+
+const normalizeList = (state, path, accounts, next) => {
+  return state.setIn(path, ImmutableMap({
+    next,
+    items: ImmutableList(accounts.map(item => item.id)),
+    isLoading: false,
+  }));
+};
+
+const appendToList = (state, path, accounts, next) => {
+  return state.updateIn(path, map => {
+    return map.set('next', next).set('isLoading', false).update('items', list => list.concat(accounts.map(item => item.id)));
+  });
+};
+
+const normalizeFollowRequest = (state, notification) => {
+  return state.updateIn(['follow_requests', 'items'], list => {
+    return list.filterNot(item => item === notification.account.id).unshift(notification.account.id);
+  });
+};
+
+const normalizeFeaturedTag = (featuredTags, accountId) => {
+  const normalizeFeaturedTag = { ...featuredTags, accountId: accountId };
+  return fromJS(normalizeFeaturedTag);
+};
+
+const normalizeFeaturedTags = (state, path, featuredTags, accountId) => {
+  return state.setIn(path, ImmutableMap({
+    items: ImmutableList(featuredTags.map(featuredTag => normalizeFeaturedTag(featuredTag, accountId)).sort((a, b) => b.get('statuses_count') - a.get('statuses_count'))),
+    isLoading: false,
+  }));
+};
+
+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 FOLLOWERS_FETCH_REQUEST:
+  case FOLLOWERS_EXPAND_REQUEST:
+    return state.setIn(['followers', action.id, 'isLoading'], true);
+  case FOLLOWERS_FETCH_FAIL:
+  case FOLLOWERS_EXPAND_FAIL:
+    return state.setIn(['followers', action.id, 'isLoading'], false);
+  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 FOLLOWING_FETCH_REQUEST:
+  case FOLLOWING_EXPAND_REQUEST:
+    return state.setIn(['following', action.id, 'isLoading'], true);
+  case FOLLOWING_FETCH_FAIL:
+  case FOLLOWING_EXPAND_FAIL:
+    return state.setIn(['following', action.id, 'isLoading'], false);
+  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 NOTIFICATIONS_UPDATE:
+    return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return normalizeList(state, ['follow_requests'], action.accounts, action.next);
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+    return appendToList(state, ['follow_requests'], action.accounts, action.next);
+  case FOLLOW_REQUESTS_FETCH_REQUEST:
+  case FOLLOW_REQUESTS_EXPAND_REQUEST:
+    return state.setIn(['follow_requests', 'isLoading'], true);
+  case FOLLOW_REQUESTS_FETCH_FAIL:
+  case FOLLOW_REQUESTS_EXPAND_FAIL:
+    return state.setIn(['follow_requests', 'isLoading'], false);
+  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 normalizeList(state, ['blocks'], action.accounts, action.next);
+  case BLOCKS_EXPAND_SUCCESS:
+    return appendToList(state, ['blocks'], action.accounts, action.next);
+  case BLOCKS_FETCH_REQUEST:
+  case BLOCKS_EXPAND_REQUEST:
+    return state.setIn(['blocks', 'isLoading'], true);
+  case BLOCKS_FETCH_FAIL:
+  case BLOCKS_EXPAND_FAIL:
+    return state.setIn(['blocks', 'isLoading'], false);
+  case MUTES_FETCH_SUCCESS:
+    return normalizeList(state, ['mutes'], action.accounts, action.next);
+  case MUTES_EXPAND_SUCCESS:
+    return appendToList(state, ['mutes'], action.accounts, action.next);
+  case MUTES_FETCH_REQUEST:
+  case MUTES_EXPAND_REQUEST:
+    return state.setIn(['mutes', 'isLoading'], true);
+  case MUTES_FETCH_FAIL:
+  case MUTES_EXPAND_FAIL:
+    return state.setIn(['mutes', 'isLoading'], false);
+  case DIRECTORY_FETCH_SUCCESS:
+    return normalizeList(state, ['directory'], action.accounts, action.next);
+  case DIRECTORY_EXPAND_SUCCESS:
+    return appendToList(state, ['directory'], action.accounts, action.next);
+  case DIRECTORY_FETCH_REQUEST:
+  case DIRECTORY_EXPAND_REQUEST:
+    return state.setIn(['directory', 'isLoading'], true);
+  case DIRECTORY_FETCH_FAIL:
+  case DIRECTORY_EXPAND_FAIL:
+    return state.setIn(['directory', 'isLoading'], false);
+  case FEATURED_TAGS_FETCH_SUCCESS:
+    return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id);
+  case FEATURED_TAGS_FETCH_REQUEST:
+    return state.setIn(['featured_tags', action.id, 'isLoading'], true);
+  case FEATURED_TAGS_FETCH_FAIL:
+    return state.setIn(['featured_tags', action.id, 'isLoading'], false);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/scroll.js b/app/javascript/flavours/glitch/scroll.js
new file mode 100644
index 000000000..84fe58269
--- /dev/null
+++ b/app/javascript/flavours/glitch/scroll.js
@@ -0,0 +1,32 @@
+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;
+  };
+};
+
+const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style;
+
+export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
new file mode 100644
index 000000000..83f8783d9
--- /dev/null
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -0,0 +1,139 @@
+import escapeTextContentForBrowser from 'escape-html';
+import { createSelector } from 'reselect';
+import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
+import { toServerSideType } from 'flavours/glitch/utils/filters';
+import { me } from 'flavours/glitch/initial_state';
+
+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);
+const getAccountMoved        = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]);
+
+export const makeGetAccount = () => {
+  return createSelector([getAccountBase, getAccountCounters, getAccountRelationship, getAccountMoved], (base, counters, relationship, moved) => {
+    if (base === null) {
+      return null;
+    }
+
+    return base.merge(counters).withMutations(map => {
+      map.set('relationship', relationship);
+      map.set('moved', moved);
+    });
+  });
+};
+
+const getFilters = (state, { contextType }) => {
+  if (!contextType) return null;
+
+  const serverSideType = toServerSideType(contextType);
+  const now = new Date();
+
+  return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
+};
+
+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'])]),
+      getFilters,
+    ],
+
+    (statusBase, statusReblog, accountBase, accountReblog, filters) => {
+      if (!statusBase || statusBase.get('isLoading')) {
+        return null;
+      }
+
+      let filtered = false;
+      if ((accountReblog || accountBase).get('id') !== me && filters) {
+        let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
+        if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
+          return null;
+        }
+        filterResults = filterResults.filter(result => filters.has(result.get('filter')));
+        if (!filterResults.isEmpty()) {
+          filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
+        }
+      }
+
+      if (statusReblog) {
+        statusReblog = statusReblog.set('account', accountReblog);
+        statusReblog = statusReblog.set('matched_filters', filtered);
+      } else {
+        statusReblog = null;
+      }
+
+      return statusBase.withMutations(map => {
+        map.set('reblog', statusReblog);
+        map.set('account', accountBase);
+        map.set('matched_filters', filtered);
+      });
+    },
+  );
+};
+
+export const makeGetPictureInPicture = () => {
+  return createSelector([
+    (state, { id }) => state.get('picture_in_picture').statusId === id,
+    (state) => state.getIn(['meta', 'layout']) !== 'mobile',
+  ], (inUse, available) => ImmutableMap({
+    inUse: inUse && available,
+    available,
+  }));
+};
+
+const getAlertsBase = state => state.get('alerts');
+
+export const getAlerts = createSelector([getAlertsBase], (base) => {
+  let arr = [];
+
+  base.forEach(item => {
+    arr.push({
+      message: item.get('message'),
+      message_values: item.get('message_values'),
+      title: item.get('title'),
+      key: item.get('key'),
+      dismissAfter: 5000,
+      barStyle: {
+        zIndex: 200,
+      },
+    });
+  });
+
+  return arr;
+});
+
+export const makeGetNotification = () => createSelector([
+  (_, base)             => base,
+  (state, _, accountId) => state.getIn(['accounts', accountId]),
+], (base, account) => base.set('account', account));
+
+export const makeGetReport = () => createSelector([
+  (_, base) => base,
+  (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
+], (base, targetAccount) => base.set('target_account', targetAccount));
+
+export const getAccountGallery = createSelector([
+  (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
+  state       => state.get('statuses'),
+  (state, id) => state.getIn(['accounts', id]),
+], (statusIds, statuses, account) => {
+  let medias = ImmutableList();
+
+  statusIds.forEach(statusId => {
+    const status = statuses.get(statusId);
+    medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status).set('account', account)));
+  });
+
+  return medias;
+});
+
+export const getAccountHidden = createSelector([
+  (state, id) => state.getIn(['accounts', id, 'hidden']),
+  (state, id) => state.getIn(['relationships', id, 'following']) || state.getIn(['relationships', id, 'requested']),
+  (state, id) => id === me,
+], (hidden, followingOrRequested, isSelf) => {
+  return hidden && !(isSelf || followingOrRequested);
+});
diff --git a/app/javascript/flavours/glitch/settings.js b/app/javascript/flavours/glitch/settings.js
new file mode 100644
index 000000000..46cfadfa3
--- /dev/null
+++ b/app/javascript/flavours/glitch/settings.js
@@ -0,0 +1,48 @@
+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');
+export const tagHistory = new Settings('mastodon_tag_history');
+export const bannerSettings = new Settings('mastodon_banner_settings');
diff --git a/app/javascript/flavours/glitch/store/configureStore.js b/app/javascript/flavours/glitch/store/configureStore.js
new file mode 100644
index 000000000..0e0d45c66
--- /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.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f));
+}
diff --git a/app/javascript/flavours/glitch/stream.js b/app/javascript/flavours/glitch/stream.js
new file mode 100644
index 000000000..c6d12cd6f
--- /dev/null
+++ b/app/javascript/flavours/glitch/stream.js
@@ -0,0 +1,265 @@
+// @ts-check
+
+import WebSocketClient from '@gamestdio/websocket';
+
+/**
+ * @type {WebSocketClient | undefined}
+ */
+let sharedConnection;
+
+/**
+ * @typedef Subscription
+ * @property {string} channelName
+ * @property {Object.<string, string>} params
+ * @property {function(): void} onConnect
+ * @property {function(StreamEvent): void} onReceive
+ * @property {function(): void} onDisconnect
+ */
+
+/**
+  * @typedef StreamEvent
+  * @property {string} event
+  * @property {object} payload
+  */
+
+/**
+ * @type {Array.<Subscription>}
+ */
+const subscriptions = [];
+
+/**
+ * @type {Object.<string, number>}
+ */
+const subscriptionCounters = {};
+
+/**
+ * @param {Subscription} subscription
+ */
+const addSubscription = subscription => {
+  subscriptions.push(subscription);
+};
+
+/**
+ * @param {Subscription} subscription
+ */
+const removeSubscription = subscription => {
+  const index = subscriptions.indexOf(subscription);
+
+  if (index !== -1) {
+    subscriptions.splice(index, 1);
+  }
+};
+
+/**
+ * @param {Subscription} subscription
+ */
+const subscribe = ({ channelName, params, onConnect }) => {
+  const key = channelNameWithInlineParams(channelName, params);
+
+  subscriptionCounters[key] = subscriptionCounters[key] || 0;
+
+  if (subscriptionCounters[key] === 0) {
+    sharedConnection.send(JSON.stringify({ type: 'subscribe', stream: channelName, ...params }));
+  }
+
+  subscriptionCounters[key] += 1;
+  onConnect();
+};
+
+/**
+ * @param {Subscription} subscription
+ */
+const unsubscribe = ({ channelName, params, onDisconnect }) => {
+  const key = channelNameWithInlineParams(channelName, params);
+
+  subscriptionCounters[key] = subscriptionCounters[key] || 1;
+
+  if (subscriptionCounters[key] === 1 && sharedConnection.readyState === WebSocketClient.OPEN) {
+    sharedConnection.send(JSON.stringify({ type: 'unsubscribe', stream: channelName, ...params }));
+  }
+
+  subscriptionCounters[key] -= 1;
+  onDisconnect();
+};
+
+const sharedCallbacks = {
+  connected () {
+    subscriptions.forEach(subscription => subscribe(subscription));
+  },
+
+  received (data) {
+    const { stream } = data;
+
+    subscriptions.filter(({ channelName, params }) => {
+      const streamChannelName = stream[0];
+
+      if (stream.length === 1) {
+        return channelName === streamChannelName;
+      }
+
+      const streamIdentifier = stream[1];
+
+      if (['hashtag', 'hashtag:local'].includes(channelName)) {
+        return channelName === streamChannelName && params.tag === streamIdentifier;
+      } else if (channelName === 'list') {
+        return channelName === streamChannelName && params.list === streamIdentifier;
+      }
+
+      return false;
+    }).forEach(subscription => {
+      subscription.onReceive(data);
+    });
+  },
+
+  disconnected () {
+    subscriptions.forEach(subscription => unsubscribe(subscription));
+  },
+
+  reconnected () {
+  },
+};
+
+/**
+ * @param {string} channelName
+ * @param {Object.<string, string>} params
+ * @return {string}
+ */
+const channelNameWithInlineParams = (channelName, params) => {
+  if (Object.keys(params).length === 0) {
+    return channelName;
+  }
+
+  return `${channelName}&${Object.keys(params).map(key => `${key}=${params[key]}`).join('&')}`;
+};
+
+/**
+ * @param {string} channelName
+ * @param {Object.<string, string>} params
+ * @param {function(Function, Function): { onConnect: (function(): void), onReceive: (function(StreamEvent): void), onDisconnect: (function(): void) }} callbacks
+ * @return {function(): void}
+ */
+export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => {
+  const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
+  const accessToken = getState().getIn(['meta', 'access_token']);
+  const { onConnect, onReceive, onDisconnect } = callbacks(dispatch, getState);
+
+  // If we cannot use a websockets connection, we must fall back
+  // to using individual connections for each channel
+  if (!streamingAPIBaseURL.startsWith('ws')) {
+    const connection = createConnection(streamingAPIBaseURL, accessToken, channelNameWithInlineParams(channelName, params), {
+      connected () {
+        onConnect();
+      },
+
+      received (data) {
+        onReceive(data);
+      },
+
+      disconnected () {
+        onDisconnect();
+      },
+
+      reconnected () {
+        onConnect();
+      },
+    });
+
+    return () => {
+      connection.close();
+    };
+  }
+
+  const subscription = {
+    channelName,
+    params,
+    onConnect,
+    onReceive,
+    onDisconnect,
+  };
+
+  addSubscription(subscription);
+
+  // If a connection is open, we can execute the subscription right now. Otherwise,
+  // because we have already registered it, it will be executed on connect
+
+  if (!sharedConnection) {
+    sharedConnection = /** @type {WebSocketClient} */ (createConnection(streamingAPIBaseURL, accessToken, '', sharedCallbacks));
+  } else if (sharedConnection.readyState === WebSocketClient.OPEN) {
+    subscribe(subscription);
+  }
+
+  return () => {
+    removeSubscription(subscription);
+    unsubscribe(subscription);
+  };
+};
+
+const KNOWN_EVENT_TYPES = [
+  'update',
+  'delete',
+  'notification',
+  'conversation',
+  'filters_changed',
+  'encrypted_message',
+  'announcement',
+  'announcement.delete',
+  'announcement.reaction',
+];
+
+/**
+ * @param {MessageEvent} e
+ * @param {function(StreamEvent): void} received
+ */
+const handleEventSourceMessage = (e, received) => {
+  received({
+    event: e.type,
+    payload: e.data,
+  });
+};
+
+/**
+ * @param {string} streamingAPIBaseURL
+ * @param {string} accessToken
+ * @param {string} channelName
+ * @param {{ connected: Function, received: function(StreamEvent): void, disconnected: Function, reconnected: Function }} callbacks
+ * @return {WebSocketClient | EventSource}
+ */
+const createConnection = (streamingAPIBaseURL, accessToken, channelName, { connected, received, disconnected, reconnected }) => {
+  const params = channelName.split('&');
+
+  channelName = params.shift();
+
+  if (streamingAPIBaseURL.startsWith('ws')) {
+    const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
+
+    ws.onopen      = connected;
+    ws.onmessage   = e => received(JSON.parse(e.data));
+    ws.onclose     = disconnected;
+    ws.onreconnect = reconnected;
+
+    return ws;
+  }
+
+  channelName = channelName.replace(/:/g, '/');
+
+  if (channelName.endsWith(':media')) {
+    channelName = channelName.replace('/media', '');
+    params.push('only_media=true');
+  }
+
+  params.push(`access_token=${accessToken}`);
+
+  const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`);
+
+  es.onopen = () => {
+    connected();
+  };
+
+  KNOWN_EVENT_TYPES.forEach(type => {
+    es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */ (e), received));
+  });
+
+  es.onerror = /** @type {function(): void} */ (disconnected);
+
+  return es;
+};
diff --git a/app/javascript/flavours/glitch/styles/_mixins.scss b/app/javascript/flavours/glitch/styles/_mixins.scss
new file mode 100644
index 000000000..b23c4dbb7
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/_mixins.scss
@@ -0,0 +1,98 @@
+@mixin avatar-radius() {
+  border-radius: $ui-avatar-border-size;
+  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: -14px;
+    margin-right: -14px;
+    width: inherit;
+    max-width: none;
+    height: 250px;
+    border-radius: 0;
+  }
+}
+
+@mixin search-input() {
+  outline: 0;
+  box-sizing: border-box;
+  width: 100%;
+  border: 0;
+  box-shadow: none;
+  font-family: inherit;
+  background: $ui-base-color;
+  color: $darker-text-color;
+  border-radius: 4px;
+  font-size: 14px;
+  margin: 0;
+}
+
+@mixin search-popout() {
+  background: $simple-background-color;
+  border-radius: 4px;
+  padding: 10px 14px;
+  padding-bottom: 14px;
+  margin-top: 10px;
+  color: $light-text-color;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+  h4 {
+    text-transform: uppercase;
+    color: $light-text-color;
+    font-size: 13px;
+    font-weight: 500;
+    margin-bottom: 10px;
+  }
+
+  li {
+    padding: 4px 0;
+  }
+
+  ul {
+    margin-bottom: 10px;
+  }
+
+  em {
+    font-weight: 500;
+    color: $inverted-text-color;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss
new file mode 100644
index 000000000..0183c43d5
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/about.scss
@@ -0,0 +1,56 @@
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+
+.container {
+  box-sizing: border-box;
+  max-width: $maximum-width;
+  margin: 0 auto;
+  position: relative;
+
+  @media screen and (max-width: $fluid-breakpoint) {
+    width: 100%;
+    padding: 0 10px;
+  }
+}
+
+.brand {
+  position: relative;
+  text-decoration: none;
+}
+
+.rules-list {
+  font-size: 15px;
+  line-height: 22px;
+  color: $primary-text-color;
+  counter-reset: list-counter;
+
+  li {
+    position: relative;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+    padding: 1em 1.75em;
+    padding-left: 3em;
+    font-weight: 500;
+    counter-increment: list-counter;
+
+    &::before {
+      content: counter(list-counter);
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      background: $highlight-text-color;
+      color: $ui-base-color;
+      border-radius: 50%;
+      width: 4ch;
+      height: 4ch;
+      font-weight: 500;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/accessibility.scss b/app/javascript/flavours/glitch/styles/accessibility.scss
new file mode 100644
index 000000000..fb2376abf
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/accessibility.scss
@@ -0,0 +1,53 @@
+$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange'
+  'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign'
+  'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on'
+  'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default;
+
+%emoji-color-inversion {
+  filter: invert(1);
+}
+
+.emojione {
+  @each $emoji in $emojis-requiring-inversion {
+    &[title=':#{$emoji}:'] {
+      @extend %emoji-color-inversion;
+    }
+  }
+}
+
+// Display a checkmark on active UI elements otherwise differing only by color
+.status__action-bar-button,
+.detailed-status__button .icon-button {
+  position: relative;
+
+  &.active::after {
+    position: absolute;
+    content: '\F00C';
+    font-size: 50%;
+    font-family: FontAwesome;
+    right: -0.55em;
+    top: -0.44em;
+  }
+}
+
+.hicolor-privacy-icons {
+  .status__visibility-icon.fa-globe,
+  .privacy-dropdown__option .fa-globe {
+    color: #1976d2;
+  }
+
+  .status__visibility-icon.fa-unlock,
+  .privacy-dropdown__option .fa-unlock {
+    color: #388e3c;
+  }
+
+  .status__visibility-icon.fa-lock,
+  .privacy-dropdown__option .fa-lock {
+    color: #ffa000;
+  }
+
+  .status__visibility-icon.fa-envelope,
+  .privacy-dropdown__option .fa-envelope {
+    color: #d32f2f;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
new file mode 100644
index 000000000..abe2e8616
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -0,0 +1,393 @@
+.card {
+  & > a {
+    display: block;
+    text-decoration: none;
+    color: inherit;
+    box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      box-shadow: none;
+    }
+
+    &:hover,
+    &:active,
+    &:focus {
+      .card__bar {
+        background: lighten($ui-base-color, 8%);
+      }
+    }
+  }
+
+  &__img {
+    height: 130px;
+    position: relative;
+    background: darken($ui-base-color, 12%);
+    border-radius: 4px 4px 0 0;
+
+    img {
+      display: block;
+      width: 100%;
+      height: 100%;
+      margin: 0;
+      object-fit: cover;
+      border-radius: 4px 4px 0 0;
+    }
+
+    @media screen and (max-width: 600px) {
+      height: 200px;
+    }
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      display: none;
+    }
+  }
+
+  &__bar {
+    position: relative;
+    padding: 15px;
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    background: lighten($ui-base-color, 4%);
+    border-radius: 0 0 4px 4px;
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      border-radius: 0;
+    }
+
+    .avatar {
+      flex: 0 0 auto;
+      width: 48px;
+      height: 48px;
+      @include avatar-size(48px);
+
+      padding-top: 2px;
+
+      img {
+        width: 100%;
+        height: 100%;
+        display: block;
+        margin: 0;
+        border-radius: 4px;
+        @include avatar-radius;
+
+        background: darken($ui-base-color, 8%);
+        object-fit: cover;
+      }
+    }
+
+    .display-name {
+      margin-left: 15px;
+      text-align: left;
+
+      i[data-hidden] {
+        display: none;
+      }
+
+      strong {
+        font-size: 15px;
+        color: $primary-text-color;
+        font-weight: 500;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+
+      span {
+        display: block;
+        font-size: 14px;
+        color: $darker-text-color;
+        font-weight: 400;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+    }
+  }
+}
+
+.pagination {
+  padding: 30px 0;
+  text-align: center;
+  overflow: hidden;
+
+  a,
+  .current,
+  .newer,
+  .older,
+  .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: $inverted-text-color;
+    cursor: default;
+    margin: 0 10px;
+  }
+
+  .gap {
+    cursor: default;
+  }
+
+  .older,
+  .newer {
+    text-transform: uppercase;
+    color: $secondary-text-color;
+  }
+
+  .older {
+    float: left;
+    padding-left: 0;
+
+    .fa {
+      display: inline-block;
+      margin-right: 5px;
+    }
+  }
+
+  .newer {
+    float: right;
+    padding-right: 0;
+
+    .fa {
+      display: inline-block;
+      margin-left: 5px;
+    }
+  }
+
+  .disabled {
+    cursor: default;
+    color: lighten($inverted-text-color, 10%);
+  }
+
+  @media screen and (max-width: 700px) {
+    padding: 30px 20px;
+
+    .page {
+      display: none;
+    }
+
+    .newer,
+    .older {
+      display: inline-block;
+    }
+  }
+}
+
+.nothing-here {
+  background: $ui-base-color;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  color: $light-text-color;
+  font-size: 14px;
+  font-weight: 500;
+  text-align: center;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: default;
+  border-radius: 4px;
+  padding: 20px;
+  min-height: 30vh;
+
+  &--under-tabs {
+    border-radius: 0 0 4px 4px;
+  }
+
+  &--flexible {
+    box-sizing: border-box;
+    min-height: 100%;
+  }
+}
+
+.account-role,
+.simple_form .recommended,
+.simple_form .not_recommended,
+.simple_form .glitch_only {
+  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: var(--user-role-background, rgba($ui-secondary-color, 0.1));
+  border: 1px solid var(--user-role-border, 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);
+  }
+}
+
+.simple_form .not_recommended {
+  color: lighten($error-red, 12%);
+  background-color: rgba(lighten($error-red, 12%), 0.1);
+  border-color: rgba(lighten($error-red, 12%), 0.5);
+}
+
+.simple_form .glitch_only {
+  color: lighten($warning-red, 12%);
+  background-color: rgba(lighten($warning-red, 12%), 0.1);
+  border-color: rgba(lighten($warning-red, 12%), 0.5);
+}
+
+.account__header__fields {
+  max-width: 100vw;
+  padding: 0;
+  margin: 15px -15px -15px;
+  border: 0 none;
+  border-top: 1px solid lighten($ui-base-color, 12%);
+  border-bottom: 1px solid lighten($ui-base-color, 12%);
+  font-size: 14px;
+  line-height: 20px;
+
+  dl {
+    display: flex;
+    border-bottom: 1px solid lighten($ui-base-color, 12%);
+  }
+
+  dt,
+  dd {
+    box-sizing: border-box;
+    padding: 14px;
+    text-align: center;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  dt {
+    font-weight: 500;
+    width: 120px;
+    flex: 0 0 auto;
+    color: $secondary-text-color;
+    background: rgba(darken($ui-base-color, 8%), 0.5);
+  }
+
+  dd {
+    flex: 1 1 auto;
+    color: $darker-text-color;
+  }
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+
+  .verified {
+    border: 1px solid rgba($valid-value-color, 0.5);
+    background: rgba($valid-value-color, 0.25);
+
+    a {
+      color: $valid-value-color;
+      font-weight: 500;
+    }
+
+    &__mark {
+      color: $valid-value-color;
+    }
+  }
+
+  dl:last-child {
+    border-bottom: 0;
+  }
+}
+
+.directory__tag .trends__item__current {
+  width: auto;
+}
+
+.pending-account {
+  &__header {
+    color: $darker-text-color;
+
+    a {
+      color: $ui-secondary-color;
+      text-decoration: none;
+
+      &:hover,
+      &:active,
+      &:focus {
+        text-decoration: underline;
+      }
+    }
+
+    strong {
+      color: $primary-text-color;
+      font-weight: 700;
+    }
+  }
+
+  &__body {
+    margin-top: 10px;
+  }
+}
+
+.batch-table__row--muted {
+  color: lighten($ui-base-color, 26%);
+}
+
+.batch-table__row--muted .pending-account__header,
+.batch-table__row--muted .accounts-table,
+.batch-table__row--muted .name-tag {
+  &,
+  a,
+  strong {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--muted .name-tag .avatar {
+  opacity: 0.5;
+}
+
+.batch-table__row--muted .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--attention {
+  color: $gold-star;
+}
+
+.batch-table__row--attention .pending-account__header,
+.batch-table__row--attention .accounts-table,
+.batch-table__row--attention .name-tag {
+  &,
+  a,
+  strong {
+    color: $gold-star;
+  }
+}
+
+.batch-table__row--attention .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: $gold-star;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
new file mode 100644
index 000000000..240c90735
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -0,0 +1,1870 @@
+@use 'sass:math';
+
+$no-columns-breakpoint: 600px;
+$sidebar-width: 240px;
+$content-width: 840px;
+
+.admin-wrapper {
+  display: flex;
+  justify-content: center;
+  width: 100%;
+  min-height: 100vh;
+
+  .sidebar-wrapper {
+    min-height: 100vh;
+    overflow: hidden;
+    pointer-events: none;
+    flex: 1 1 auto;
+
+    &__inner {
+      display: flex;
+      justify-content: flex-end;
+      background: $ui-base-color;
+      height: 100%;
+    }
+  }
+
+  .sidebar {
+    width: $sidebar-width;
+    padding: 0;
+    pointer-events: auto;
+
+    &__toggle {
+      display: none;
+      background: darken($ui-base-color, 4%);
+      border-bottom: 1px solid lighten($ui-base-color, 4%);
+      align-items: center;
+
+      &__logo {
+        flex: 1 1 auto;
+
+        a {
+          display: block;
+          padding: 15px;
+        }
+      }
+
+      &__icon {
+        display: block;
+        color: $darker-text-color;
+        text-decoration: none;
+        flex: 0 0 auto;
+        font-size: 18px;
+        padding: 10px;
+        margin: 5px 10px;
+        border-radius: 4px;
+
+        &:focus {
+          background: $ui-base-color;
+        }
+
+        .fa-times {
+          display: none;
+        }
+
+        &.active {
+          .fa-times {
+            display: block;
+          }
+
+          .fa-bars {
+            display: none;
+          }
+        }
+      }
+    }
+
+    .logo {
+      display: block;
+      margin: 40px auto;
+      width: 100px;
+      height: 100px;
+    }
+
+    .logo--wordmark {
+      display: inherit;
+      margin: inherit;
+      width: inherit;
+      height: 25px;
+    }
+
+    @media screen and (max-width: $no-columns-breakpoint) {
+      & > a:first-child {
+        display: none;
+      }
+    }
+
+    ul {
+      list-style: none;
+      border-radius: 4px 0 0 4px;
+      overflow: hidden;
+      margin-bottom: 20px;
+
+      @media screen and (max-width: $no-columns-breakpoint) {
+        margin-bottom: 0;
+      }
+
+      a {
+        display: block;
+        padding: 15px;
+        color: $darker-text-color;
+        text-decoration: none;
+        transition: all 200ms linear;
+        transition-property: color, background-color;
+        border-radius: 4px 0 0 4px;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+
+        i.fa {
+          margin-right: 5px;
+        }
+
+        &:hover {
+          color: $primary-text-color;
+          background-color: darken($ui-base-color, 5%);
+          transition: all 100ms linear;
+          transition-property: color, background-color;
+        }
+
+        &.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;
+        }
+      }
+
+      .simple-navigation-active-leaf a {
+        color: $primary-text-color;
+        background-color: darken($ui-highlight-color, 2%);
+        border-bottom: 0;
+        border-radius: 0;
+
+        &:hover {
+          background-color: $ui-highlight-color;
+        }
+      }
+    }
+
+    & > ul > .simple-navigation-active-leaf a {
+      border-radius: 4px 0 0 4px;
+    }
+  }
+
+  .content-wrapper {
+    box-sizing: border-box;
+    width: 100%;
+    max-width: $content-width;
+    flex: 1 1 auto;
+  }
+
+  @media screen and (max-width: $content-width + $sidebar-width) {
+    .sidebar-wrapper--empty {
+      display: none;
+    }
+
+    .sidebar-wrapper {
+      width: $sidebar-width;
+      flex: 0 0 auto;
+    }
+  }
+
+  @media screen and (max-width: $no-columns-breakpoint) {
+    .sidebar-wrapper {
+      width: 100%;
+    }
+  }
+
+  .content {
+    padding: 55px 15px 20px 25px;
+
+    @media screen and (max-width: $no-columns-breakpoint) {
+      max-width: none;
+      padding: 15px;
+      padding-top: 30px;
+    }
+
+    &__heading {
+      margin-bottom: 45px;
+
+      &__row {
+        display: flex;
+        flex-wrap: wrap;
+        align-items: center;
+        justify-content: space-between;
+        margin: -15px -15px 0 0;
+
+        & > * {
+          margin-top: 15px;
+          margin-right: 15px;
+        }
+      }
+
+      &__tabs {
+        margin-top: 30px;
+        width: 100%;
+
+        & > div {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 5px;
+        }
+
+        a {
+          font-size: 14px;
+          display: inline-flex;
+          align-items: center;
+          padding: 7px 10px;
+          border-radius: 4px;
+          color: $darker-text-color;
+          text-decoration: none;
+          font-weight: 500;
+          gap: 5px;
+          white-space: nowrap;
+
+          &:hover,
+          &:focus,
+          &:active {
+            background: lighten($ui-base-color, 4%);
+          }
+
+          &.selected {
+            font-weight: 700;
+            color: $primary-text-color;
+            background: $ui-highlight-color;
+
+            &:hover,
+            &:focus,
+            &:active {
+              background: lighten($ui-highlight-color, 4%);
+            }
+          }
+        }
+      }
+
+      &__actions {
+        display: inline-flex;
+        flex-flow: wrap;
+        gap: 5px;
+      }
+
+      h2 small {
+        font-size: 12px;
+        display: block;
+        font-weight: 500;
+        color: $darker-text-color;
+        line-height: 18px;
+      }
+
+      @media screen and (max-width: $no-columns-breakpoint) {
+        border-bottom: 0;
+        padding-bottom: 0;
+      }
+    }
+
+    h2 {
+      color: $secondary-text-color;
+      font-size: 24px;
+      line-height: 36px;
+      font-weight: 700;
+    }
+
+    h3 {
+      color: $secondary-text-color;
+      font-size: 20px;
+      line-height: 28px;
+      font-weight: 400;
+      margin-bottom: 30px;
+    }
+
+    h4 {
+      text-transform: uppercase;
+      font-size: 13px;
+      font-weight: 700;
+      color: $darker-text-color;
+      padding-bottom: 8px;
+      margin-bottom: 8px;
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+    }
+
+    h6 {
+      font-size: 16px;
+      color: $secondary-text-color;
+      line-height: 28px;
+      font-weight: 500;
+    }
+
+    .fields-group h6 {
+      color: $primary-text-color;
+      font-weight: 500;
+    }
+
+    .directory__tag > a,
+    .directory__tag > div {
+      box-shadow: none;
+    }
+
+    .directory__tag .table-action-link .fa {
+      color: inherit;
+    }
+
+    .directory__tag h4 {
+      font-size: 18px;
+      font-weight: 700;
+      color: $primary-text-color;
+      text-transform: none;
+      padding-bottom: 0;
+      margin-bottom: 0;
+      border-bottom: 0;
+    }
+
+    & > p {
+      font-size: 14px;
+      line-height: 21px;
+      color: $secondary-text-color;
+      margin-bottom: 20px;
+
+      strong {
+        color: $primary-text-color;
+        font-weight: 500;
+
+        @each $lang in $cjk-langs {
+          &:lang(#{$lang}) {
+            font-weight: 700;
+          }
+        }
+      }
+    }
+
+    hr {
+      width: 100%;
+      height: 0;
+      border: 0;
+      border-bottom: 1px solid rgba($ui-base-lighter-color, 0.6);
+      margin: 20px 0;
+
+      &.spacer {
+        height: 1px;
+        border: 0;
+      }
+    }
+  }
+
+  @media screen and (max-width: $no-columns-breakpoint) {
+    display: block;
+
+    .sidebar-wrapper {
+      min-height: 0;
+    }
+
+    .sidebar {
+      width: 100%;
+      padding: 0;
+      height: auto;
+
+      &__toggle {
+        display: flex;
+      }
+
+      & > ul {
+        display: none;
+
+        &.visible {
+          display: block;
+          position: fixed;
+          z-index: 10;
+          width: 100%;
+          height: calc(100% - 56px);
+          left: 0;
+          bottom: 0;
+          overflow-y: auto;
+          background: $ui-base-color;
+        }
+      }
+
+      ul a,
+      ul ul a {
+        border-radius: 0;
+        border-bottom: 1px solid lighten($ui-base-color, 4%);
+        transition: none;
+
+        &:hover {
+          transition: none;
+        }
+      }
+
+      ul ul {
+        border-radius: 0;
+      }
+
+      ul .simple-navigation-active-leaf a {
+        border-bottom-color: $ui-highlight-color;
+      }
+    }
+  }
+}
+
+hr.spacer {
+  width: 100%;
+  border: 0;
+  margin: 20px 0;
+  height: 1px;
+}
+
+body,
+.admin-wrapper .content {
+  .muted-hint {
+    color: $darker-text-color;
+
+    a {
+      color: $highlight-text-color;
+    }
+  }
+
+  .positive-hint,
+  .negative-hint,
+  .neutral-hint {
+    a {
+      color: inherit;
+      text-decoration: underline;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: none;
+      }
+    }
+  }
+
+  .positive-hint {
+    color: $valid-value-color;
+    font-weight: 500;
+  }
+
+  .negative-hint {
+    color: $error-value-color;
+    font-weight: 500;
+  }
+
+  .neutral-hint {
+    color: $dark-text-color;
+    font-weight: 500;
+  }
+
+  .warning-hint {
+    color: $gold-star;
+    font-weight: 500;
+  }
+}
+
+.filters {
+  display: flex;
+  flex-wrap: wrap;
+
+  .filter-subset {
+    flex: 0 0 auto;
+    margin: 0 40px 20px 0;
+
+    &:last-child {
+      margin-bottom: 30px;
+    }
+
+    ul {
+      margin-top: 5px;
+      list-style: none;
+
+      li {
+        display: inline-block;
+        margin-right: 5px;
+      }
+    }
+
+    & > div {
+      display: flex;
+      gap: 5px;
+    }
+
+    strong {
+      font-weight: 500;
+      text-transform: uppercase;
+      font-size: 12px;
+
+      @each $lang in $cjk-langs {
+        &:lang(#{$lang}) {
+          font-weight: 700;
+        }
+      }
+    }
+
+    &--with-select strong {
+      display: block;
+      margin-bottom: 10px;
+    }
+
+    a {
+      display: inline-block;
+      color: $darker-text-color;
+      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: $highlight-text-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: $secondary-text-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;
+  }
+}
+
+.simple_form.new_report_note,
+.simple_form.new_account_moderation_note {
+  max-width: 100%;
+}
+
+.simple_form {
+  .actions {
+    margin-top: 15px;
+  }
+
+  .button {
+    font-size: 15px;
+  }
+}
+
+.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;
+    }
+  }
+}
+
+.back-link {
+  margin-bottom: 10px;
+  font-size: 14px;
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.special-action-button,
+.back-link {
+  text-align: right;
+  flex: 1 1 auto;
+}
+
+.action-buttons {
+  display: flex;
+  overflow: hidden;
+  justify-content: space-between;
+}
+
+.spacer {
+  flex: 1 1 auto;
+}
+
+.log-entry {
+  display: block;
+  line-height: 20px;
+  padding: 15px;
+  padding-left: 15px * 2 + 40px;
+  background: $ui-base-color;
+  border-bottom: 1px solid darken($ui-base-color, 8%);
+  position: relative;
+  text-decoration: none;
+  color: $darker-text-color;
+  font-size: 14px;
+
+  &:first-child {
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+  }
+
+  &:last-child {
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+    border-bottom: 0;
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    background: lighten($ui-base-color, 4%);
+  }
+
+  &__avatar {
+    position: absolute;
+    left: 15px;
+    top: 15px;
+
+    .avatar {
+      border-radius: 4px;
+      width: 40px;
+      height: 40px;
+    }
+  }
+
+  &__title {
+    word-wrap: break-word;
+  }
+
+  &__timestamp {
+    color: $dark-text-color;
+  }
+
+  a,
+  .username,
+  .target {
+    color: $secondary-text-color;
+    text-decoration: none;
+    font-weight: 500;
+  }
+
+  a {
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+}
+
+a.name-tag,
+.name-tag,
+a.inline-name-tag,
+.inline-name-tag {
+  text-decoration: none;
+  color: $secondary-text-color;
+
+  .username {
+    font-weight: 500;
+  }
+
+  &.suspended {
+    .username {
+      text-decoration: line-through;
+      color: lighten($error-red, 12%);
+    }
+
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
+a.name-tag,
+.name-tag {
+  display: inline-flex;
+  align-items: center;
+  vertical-align: top;
+
+  .avatar {
+    display: block;
+    margin: 0;
+    margin-right: 5px;
+    border-radius: 50%;
+  }
+
+  &.suspended {
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
+.speech-bubble {
+  margin-bottom: 20px;
+  border-left: 4px solid $ui-highlight-color;
+
+  &.positive {
+    border-left-color: $success-green;
+  }
+
+  &.negative {
+    border-left-color: lighten($error-red, 12%);
+  }
+
+  &.warning {
+    border-left-color: $gold-star;
+  }
+
+  &__bubble {
+    padding: 16px;
+    padding-left: 14px;
+    font-size: 15px;
+    line-height: 20px;
+    border-radius: 4px 4px 4px 0;
+    position: relative;
+    font-weight: 500;
+
+    a {
+      color: $darker-text-color;
+    }
+  }
+
+  &__owner {
+    padding: 8px;
+    padding-left: 12px;
+  }
+
+  time {
+    color: $dark-text-color;
+  }
+}
+
+.report-card {
+  background: $ui-base-color;
+  border-radius: 4px;
+  margin-bottom: 20px;
+
+  &__profile {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 15px;
+
+    .account {
+      padding: 0;
+      border: 0;
+
+      &__avatar-wrapper {
+        margin-left: 0;
+      }
+    }
+
+    &__stats {
+      flex: 0 0 auto;
+      font-weight: 500;
+      color: $darker-text-color;
+      text-transform: uppercase;
+      text-align: right;
+
+      a {
+        color: inherit;
+        text-decoration: none;
+
+        &:focus,
+        &:hover,
+        &:active {
+          color: lighten($darker-text-color, 8%);
+        }
+      }
+
+      .red {
+        color: $error-value-color;
+      }
+    }
+  }
+
+  &__summary {
+    &__item {
+      display: flex;
+      justify-content: flex-start;
+      border-top: 1px solid darken($ui-base-color, 4%);
+
+      &:hover {
+        background: lighten($ui-base-color, 2%);
+      }
+
+      &__reported-by,
+      &__assigned {
+        padding: 15px;
+        flex: 0 0 auto;
+        box-sizing: border-box;
+        width: 150px;
+        color: $darker-text-color;
+
+        &,
+        .username {
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
+
+      &__content {
+        flex: 1 1 auto;
+        max-width: calc(100% - 300px);
+
+        &__icon {
+          color: $dark-text-color;
+          margin-right: 4px;
+          font-weight: 500;
+        }
+      }
+
+      &__content a {
+        display: block;
+        box-sizing: border-box;
+        width: 100%;
+        padding: 15px;
+        text-decoration: none;
+        color: $darker-text-color;
+      }
+    }
+  }
+}
+
+.one-line {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.ellipsized-ip {
+  display: inline-block;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  vertical-align: middle;
+}
+
+.admin-account-bio {
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 -5px;
+  margin-top: 20px;
+
+  > div {
+    box-sizing: border-box;
+    padding: 0 5px;
+    margin-bottom: 10px;
+    flex: 1 0 50%;
+    max-width: 100%;
+  }
+
+  .account__header__fields,
+  .account__header__content {
+    background: lighten($ui-base-color, 8%);
+    border-radius: 4px;
+    height: 100%;
+  }
+
+  .account__header__fields {
+    margin: 0;
+    border: 0;
+
+    a {
+      color: $highlight-text-color;
+    }
+
+    dl:first-child .verified {
+      border-radius: 0 4px 0 0;
+    }
+
+    .verified a {
+      color: $valid-value-color;
+    }
+  }
+
+  .account__header__content {
+    box-sizing: border-box;
+    padding: 20px;
+    color: $primary-text-color;
+  }
+}
+
+.center-text {
+  text-align: center;
+}
+
+.applications-list__item,
+.filters-list__item {
+  padding: 15px 0;
+  background: $ui-base-color;
+  border: 1px solid lighten($ui-base-color, 4%);
+  border-radius: 4px;
+  margin-top: 15px;
+}
+
+.user-role {
+  color: var(--user-role-accent);
+}
+
+.announcements-list,
+.filters-list {
+  border: 1px solid lighten($ui-base-color, 4%);
+  border-radius: 4px;
+
+  &__item {
+    padding: 15px 0;
+    background: $ui-base-color;
+    border-bottom: 1px solid lighten($ui-base-color, 4%);
+
+    &__title {
+      padding: 0 15px;
+      display: block;
+      font-weight: 500;
+      font-size: 18px;
+      line-height: 1.5;
+      color: $secondary-text-color;
+      text-decoration: none;
+      margin-bottom: 10px;
+
+      .account-role {
+        vertical-align: middle;
+      }
+    }
+
+    a.announcements-list__item__title {
+      &:hover,
+      &:focus,
+      &:active {
+        color: $primary-text-color;
+      }
+    }
+
+    &__meta {
+      padding: 0 15px;
+      color: $dark-text-color;
+
+      a {
+        color: inherit;
+        text-decoration: underline;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: none;
+        }
+      }
+    }
+
+    &__action-bar {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+    }
+
+    &__permissions {
+      margin-top: 10px;
+    }
+
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
+.filters-list__item {
+  &__title {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 0;
+  }
+
+  &__permissions {
+    margin-top: 0;
+    margin-bottom: 10px;
+  }
+
+  .expiration {
+    font-size: 13px;
+  }
+
+  &.expired {
+    .expiration {
+      color: lighten($error-red, 12%);
+    }
+
+    .permissions-list__item__icon {
+      color: $dark-text-color;
+    }
+  }
+}
+
+.dashboard__counters.admin-account-counters {
+  margin-top: 10px;
+}
+
+.account-badges {
+  margin: -2px 0;
+}
+
+.retention {
+  overflow: auto;
+
+  > h4 {
+    position: sticky;
+    left: 0;
+  }
+
+  &__table {
+    &__number {
+      color: $secondary-text-color;
+      padding: 10px;
+    }
+
+    &__date {
+      white-space: nowrap;
+      padding: 10px 0;
+      text-align: left;
+      min-width: 120px;
+
+      &.retention__table__average {
+        font-weight: 700;
+      }
+    }
+
+    &__size {
+      text-align: center;
+      padding: 10px;
+    }
+
+    &__label {
+      font-weight: 700;
+      color: $darker-text-color;
+    }
+
+    &__box {
+      box-sizing: border-box;
+      background: $ui-highlight-color;
+      padding: 10px;
+      font-weight: 500;
+      color: $primary-text-color;
+      width: 52px;
+      margin: 1px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba(
+            $ui-highlight-color,
+            1 * (math.div(max(1, $i), 10))
+          );
+        }
+      }
+    }
+  }
+}
+
+.sparkline {
+  display: block;
+  text-decoration: none;
+  background: lighten($ui-base-color, 4%);
+  border-radius: 4px;
+  padding: 0;
+  position: relative;
+  padding-bottom: 55px + 20px;
+  overflow: hidden;
+
+  &__value {
+    display: flex;
+    line-height: 33px;
+    align-items: flex-end;
+    padding: 20px;
+    padding-bottom: 10px;
+
+    &__total {
+      display: block;
+      margin-right: 10px;
+      font-weight: 500;
+      font-size: 28px;
+      color: $primary-text-color;
+    }
+
+    &__change {
+      display: block;
+      font-weight: 500;
+      font-size: 18px;
+      color: $darker-text-color;
+      margin-bottom: -3px;
+
+      &.positive {
+        color: $valid-value-color;
+      }
+
+      &.negative {
+        color: $error-value-color;
+      }
+    }
+  }
+
+  &__label {
+    padding: 0 20px;
+    padding-bottom: 10px;
+    text-transform: uppercase;
+    color: $darker-text-color;
+    font-weight: 500;
+  }
+
+  &__graph {
+    position: absolute;
+    bottom: 0;
+    width: 100%;
+
+    svg {
+      display: block;
+      margin: 0;
+    }
+
+    path:first-child {
+      fill: rgba($highlight-text-color, 0.25) !important;
+      fill-opacity: 1 !important;
+    }
+
+    path:last-child {
+      stroke: lighten($highlight-text-color, 6%) !important;
+      fill: none !important;
+    }
+  }
+}
+
+a.sparkline {
+  &:hover,
+  &:focus,
+  &:active {
+    background: lighten($ui-base-color, 6%);
+  }
+}
+
+.skeleton {
+  background-color: lighten($ui-base-color, 8%);
+  background-image: linear-gradient(
+    90deg,
+    lighten($ui-base-color, 8%),
+    lighten($ui-base-color, 12%),
+    lighten($ui-base-color, 8%)
+  );
+  background-size: 200px 100%;
+  background-repeat: no-repeat;
+  border-radius: 4px;
+  display: inline-block;
+  line-height: 1;
+  width: 100%;
+  animation: skeleton 1.2s ease-in-out infinite;
+}
+
+@keyframes skeleton {
+  0% {
+    background-position: -200px 0;
+  }
+
+  100% {
+    background-position: calc(200px + 100%) 0;
+  }
+}
+
+.dimension {
+  table {
+    width: 100%;
+  }
+
+  &__item {
+    border-bottom: 1px solid lighten($ui-base-color, 4%);
+
+    &__key {
+      font-weight: 500;
+      padding: 11px 10px;
+    }
+
+    &__value {
+      text-align: right;
+      color: $darker-text-color;
+      padding: 11px 10px;
+    }
+
+    &__indicator {
+      display: inline-block;
+      width: 8px;
+      height: 8px;
+      border-radius: 50%;
+      background: $ui-highlight-color;
+      margin-right: 10px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba(
+            $ui-highlight-color,
+            1 * (math.div(max(1, $i), 10))
+          );
+        }
+      }
+    }
+
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
+.report-reason-selector {
+  border-radius: 4px;
+  background: $ui-base-color;
+  margin-bottom: 20px;
+
+  &__category {
+    cursor: pointer;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__label {
+      padding: 15px;
+    }
+
+    &__rules {
+      margin-left: 30px;
+    }
+  }
+
+  &__rule {
+    cursor: pointer;
+    padding: 15px;
+  }
+}
+
+.report-header {
+  display: grid;
+  grid-gap: 15px;
+  grid-template-columns: minmax(0, 1fr) 300px;
+
+  &__details {
+    &__item {
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+      padding: 15px 0;
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__header {
+        font-weight: 600;
+        padding: 4px 0;
+      }
+    }
+
+    &--horizontal {
+      display: grid;
+      grid-auto-columns: minmax(0, 1fr);
+      grid-auto-flow: column;
+
+      .report-header__details__item {
+        border-bottom: 0;
+      }
+    }
+  }
+
+  @media screen and (max-width: 930px) {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+
+.account-card {
+  background: $ui-base-color;
+  border-radius: 4px;
+
+  &__permalink {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  &__header {
+    padding: 4px;
+    border-radius: 4px;
+    height: 128px;
+
+    img {
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      background: darken($ui-base-color, 8%);
+    }
+  }
+
+  &__title {
+    margin-top: -(15px + 8px);
+    display: flex;
+    align-items: flex-end;
+
+    &__avatar {
+      padding: 14px;
+
+      img,
+      .account__avatar {
+        display: block;
+        margin: 0;
+        width: 56px;
+        height: 56px;
+        background-color: darken($ui-base-color, 8%);
+        border-radius: 8px;
+        border: 1px solid $ui-base-color;
+      }
+    }
+
+    .display-name {
+      color: $darker-text-color;
+      padding-bottom: 15px;
+      font-size: 15px;
+      line-height: 20px;
+
+      bdi {
+        display: block;
+        color: $primary-text-color;
+        font-weight: 700;
+      }
+    }
+  }
+
+  &__bio {
+    padding: 0 15px;
+    margin: 8px 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    word-wrap: break-word;
+    max-height: 21px * 2;
+    position: relative;
+    font-size: 15px;
+    line-height: 21px;
+
+    &::after {
+      display: block;
+      content: '';
+      width: 50px;
+      height: 21px;
+      position: absolute;
+      bottom: 0;
+      right: 15px;
+      background: linear-gradient(to left, $ui-base-color, transparent);
+      pointer-events: none;
+    }
+
+    a {
+      color: $secondary-text-color;
+      text-decoration: none;
+      unicode-bidi: isolate;
+
+      &:hover {
+        text-decoration: underline;
+      }
+
+      &.mention {
+        &:hover {
+          text-decoration: none;
+
+          span {
+            text-decoration: underline;
+          }
+        }
+      }
+    }
+  }
+
+  &__actions {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    &__button {
+      flex-shrink: 1;
+      padding: 0 15px;
+      overflow: hidden;
+
+      .button {
+        min-width: 0;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        max-width: 100%;
+      }
+    }
+  }
+
+  &__counters {
+    flex: 1 1 auto;
+    display: grid;
+    grid-auto-columns: minmax(0, 1fr);
+    grid-auto-flow: column;
+    max-width: 340px;
+    min-width: 65px * 3;
+
+    &__item {
+      padding: 15px 0;
+      text-align: center;
+      color: $primary-text-color;
+      font-weight: 600;
+      font-size: 15px;
+      line-height: 21px;
+
+      small {
+        display: block;
+        color: $darker-text-color;
+        font-weight: 400;
+        font-size: 13px;
+        line-height: 18px;
+      }
+    }
+  }
+}
+
+.report-notes {
+  margin-bottom: 20px;
+
+  &__item {
+    background: $ui-base-color;
+    position: relative;
+    padding: 15px;
+    padding-left: 15px * 2 + 40px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:first-child {
+      border-top-left-radius: 4px;
+      border-top-right-radius: 4px;
+    }
+
+    &:last-child {
+      border-bottom-left-radius: 4px;
+      border-bottom-right-radius: 4px;
+      border-bottom: 0;
+    }
+
+    &:hover {
+      background-color: lighten($ui-base-color, 4%);
+    }
+
+    &__avatar {
+      position: absolute;
+      left: 15px;
+      top: 15px;
+      border-radius: 4px;
+      width: 40px;
+      height: 40px;
+    }
+
+    &__header {
+      color: $darker-text-color;
+      font-size: 15px;
+      line-height: 20px;
+      margin-bottom: 4px;
+
+      .username {
+        color: $primary-text-color;
+        font-weight: 500;
+        margin-right: 5px;
+
+        a {
+          color: inherit;
+          text-decoration: none;
+
+          &:hover,
+          &:focus,
+          &:active {
+            text-decoration: underline;
+          }
+        }
+      }
+
+      time {
+        margin-left: 5px;
+        vertical-align: baseline;
+      }
+    }
+
+    &__content {
+      font-size: 15px;
+      line-height: 20px;
+      word-wrap: break-word;
+      font-weight: 400;
+      color: $primary-text-color;
+
+      p {
+        margin-bottom: 20px;
+        white-space: pre-wrap;
+        unicode-bidi: plaintext;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+
+      a {
+        color: $highlight-text-color;
+        text-decoration: none;
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
+    }
+
+    &__actions {
+      position: absolute;
+      top: 15px;
+      right: 15px;
+      text-align: right;
+    }
+  }
+}
+
+.report-actions {
+  border: 1px solid darken($ui-base-color, 8%);
+
+  &__item {
+    display: flex;
+    align-items: center;
+    line-height: 18px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__button {
+      box-sizing: border-box;
+      flex: 0 0 auto;
+      width: 200px;
+      padding: 15px;
+      padding-right: 0;
+
+      .button {
+        display: block;
+        width: 100%;
+      }
+    }
+
+    &__description {
+      padding: 15px;
+      font-size: 14px;
+      color: $dark-text-color;
+    }
+  }
+
+  @media screen and (max-width: 800px) {
+    border: 0;
+
+    &__item {
+      flex-direction: column;
+      border: 0;
+
+      &__button {
+        width: 100%;
+        padding: 15px 0;
+      }
+
+      &__description {
+        padding: 0;
+        padding-bottom: 15px;
+      }
+    }
+  }
+}
+
+.section-skip-link {
+  float: right;
+
+  a {
+    color: $ui-highlight-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+}
+
+.strike-card {
+  padding: 15px;
+  border-radius: 4px;
+  background: $ui-base-color;
+  font-size: 15px;
+  line-height: 20px;
+  word-wrap: break-word;
+  font-weight: 400;
+  color: $primary-text-color;
+  box-sizing: border-box;
+  min-height: 100%;
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+
+  p {
+    margin-bottom: 20px;
+    unicode-bidi: plaintext;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    strong {
+      font-weight: 700;
+    }
+  }
+
+  &__rules {
+    list-style: disc;
+    padding-left: 15px;
+    margin-bottom: 20px;
+    color: $darker-text-color;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    &__text {
+      color: $primary-text-color;
+    }
+  }
+
+  &__statuses-list {
+    border-radius: 4px;
+    border: 1px solid darken($ui-base-color, 8%);
+    font-size: 13px;
+    line-height: 18px;
+    overflow: hidden;
+
+    &__item {
+      padding: 16px;
+      background: lighten($ui-base-color, 2%);
+      border-bottom: 1px solid darken($ui-base-color, 8%);
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__meta {
+        color: $darker-text-color;
+      }
+
+      a {
+        color: inherit;
+        text-decoration: none;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+}
+
+.availability-indicator {
+  display: flex;
+  align-items: center;
+  margin-bottom: 30px;
+  font-size: 14px;
+  line-height: 21px;
+
+  &__hint {
+    padding: 0 15px;
+  }
+
+  &__graphic {
+    display: flex;
+    margin: 0 -2px;
+
+    &__item {
+      display: block;
+      flex: 0 0 auto;
+      width: 4px;
+      height: 21px;
+      background: lighten($ui-base-color, 8%);
+      margin: 0 2px;
+      border-radius: 2px;
+
+      &.positive {
+        background: $valid-value-color;
+      }
+
+      &.negative {
+        background: $error-value-color;
+      }
+    }
+  }
+}
+
+.history {
+  counter-reset: step 0;
+  font-size: 15px;
+  line-height: 22px;
+
+  li {
+    counter-increment: step 1;
+    padding-left: 2.5rem;
+    padding-bottom: 8px;
+    position: relative;
+    margin-bottom: 8px;
+
+    &::before {
+      position: absolute;
+      content: counter(step);
+      font-size: 0.625rem;
+      font-weight: 500;
+      left: 0;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      width: calc(1.375rem + 1px);
+      height: calc(1.375rem + 1px);
+      background: $ui-base-color;
+      border: 1px solid $highlight-text-color;
+      color: $highlight-text-color;
+      border-radius: 8px;
+    }
+
+    &::after {
+      position: absolute;
+      content: '';
+      width: 1px;
+      background: $highlight-text-color;
+      bottom: 0;
+      top: calc(1.875rem + 1px);
+      left: 0.6875rem;
+    }
+
+    &:last-child {
+      margin-bottom: 0;
+
+      &::after {
+        display: none;
+      }
+    }
+  }
+
+  &__entry {
+    h5 {
+      font-weight: 500;
+      color: $primary-text-color;
+      line-height: 25px;
+      margin-bottom: 16px;
+    }
+
+    .status {
+      border: 1px solid lighten($ui-base-color, 4%);
+      background: $ui-base-color;
+      border-radius: 4px;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/basics.scss b/app/javascript/flavours/glitch/styles/basics.scss
new file mode 100644
index 000000000..84977eb39
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/basics.scss
@@ -0,0 +1,283 @@
+@function hex-color($color) {
+  @if type-of($color) == 'color' {
+    $color: str-slice(ie-hex-str($color), 4);
+  }
+
+  @return '%23' + unquote($color);
+}
+
+body {
+  font-family: $font-sans-serif, sans-serif;
+  background: darken($ui-base-color, 7%);
+  font-size: 13px;
+  line-height: 18px;
+  font-weight: 400;
+  color: $primary-text-color;
+  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
+    // $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',
+      $font-sans-serif, sans-serif;
+  }
+
+  &.app-body {
+    padding: 0;
+
+    &.layout-single-column {
+      height: auto;
+      min-height: 100vh;
+      overflow-y: scroll;
+    }
+
+    &.layout-multiple-columns {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+    }
+
+    &.with-modals--active {
+      overflow-y: hidden;
+    }
+  }
+
+  &.lighter {
+    background: $ui-base-color;
+  }
+
+  &.with-modals {
+    overflow-x: hidden;
+    overflow-y: scroll;
+
+    &--active {
+      overflow-y: hidden;
+    }
+  }
+
+  &.player {
+    padding: 0;
+    margin: 0;
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+
+    & > div {
+      height: 100%;
+    }
+
+    .video-player video {
+      width: 100%;
+      height: 100%;
+      max-height: 100vh;
+    }
+
+    .media-gallery {
+      margin-top: 0;
+      height: 100% !important;
+      border-radius: 0;
+    }
+
+    .media-gallery__item {
+      border-radius: 0;
+    }
+  }
+
+  &.embed {
+    background: lighten($ui-base-color, 4%);
+    margin: 0;
+    padding-bottom: 0;
+
+    .container {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+  }
+
+  &.admin {
+    background: darken($ui-base-color, 4%);
+    padding: 0;
+  }
+
+  &.error {
+    position: absolute;
+    text-align: center;
+    color: $darker-text-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;
+
+      &__illustration {
+        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,
+  & > noscript {
+    display: flex;
+    width: 100%;
+    align-items: center;
+    justify-content: center;
+    outline: 0 !important;
+  }
+
+  & > noscript {
+    height: 100vh;
+  }
+}
+
+.layout-single-column .app-holder {
+  &,
+  & > div {
+    min-height: 100vh;
+  }
+}
+
+.layout-multiple-columns .app-holder {
+  &,
+  & > div {
+    height: 100%;
+  }
+}
+
+.app-holder noscript {
+  flex-direction: column;
+  font-size: 16px;
+  font-weight: 400;
+  line-height: 1.7;
+  color: lighten($error-red, 4%);
+  text-align: center;
+
+  & > div {
+    max-width: 500px;
+  }
+
+  p {
+    margin-bottom: 0.85em;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: $highlight-text-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: none;
+    }
+  }
+
+  &__footer {
+    color: $dark-text-color;
+    font-size: 13px;
+
+    a {
+      color: $dark-text-color;
+    }
+  }
+
+  button {
+    display: inline;
+    border: 0;
+    background: transparent;
+    color: $dark-text-color;
+    font: inherit;
+    padding: 0;
+    margin: 0;
+    line-height: inherit;
+    cursor: pointer;
+    outline: 0;
+    transition: color 300ms linear;
+    text-decoration: underline;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: none;
+    }
+
+    &.copied {
+      color: $valid-value-color;
+      transition: none;
+    }
+  }
+}
+
+.logo-resources {
+  // Not using display: none because of https://bugs.chromium.org/p/chromium/issues/detail?id=258029
+  visibility: hidden;
+  user-select: none;
+  pointer-events: none;
+  width: 0;
+  height: 0;
+  overflow: hidden;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: -1000;
+}
+
+// NoScript adds a __ns__pop2top class to the full ancestry of blocked elements,
+// to set the z-index to a high value, which messes with modals and dropdowns.
+// Blocked elements can in theory only be media and frames/embeds, so they
+// should only appear in statuses, under divs and articles.
+body,
+div,
+article {
+  .__ns__pop2top {
+    z-index: unset !important;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/branding.scss b/app/javascript/flavours/glitch/styles/branding.scss
new file mode 100644
index 000000000..d1bddc68b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/branding.scss
@@ -0,0 +1,3 @@
+.logo {
+  color: $primary-text-color;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/about.scss b/app/javascript/flavours/glitch/styles/components/about.scss
new file mode 100644
index 000000000..6664a5756
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/about.scss
@@ -0,0 +1,291 @@
+.image {
+  position: relative;
+  overflow: hidden;
+
+  &__preview {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  &.loaded &__preview {
+    display: none;
+  }
+
+  img {
+    display: block;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    border: 0;
+    background: transparent;
+    opacity: 0;
+  }
+
+  &.loaded img {
+    opacity: 1;
+  }
+}
+
+.link-footer {
+  flex: 0 0 auto;
+  padding: 10px;
+  padding-top: 20px;
+  z-index: 1;
+  font-size: 13px;
+
+  p {
+    color: $dark-text-color;
+    margin-bottom: 20px;
+
+    strong {
+      font-weight: 500;
+    }
+
+    a {
+      color: $dark-text-color;
+      text-decoration: underline;
+
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: none;
+      }
+    }
+  }
+}
+
+.about {
+  padding: 20px;
+
+  @media screen and (min-width: $no-gap-breakpoint) {
+    border-radius: 4px;
+  }
+
+  &__footer {
+    color: $dark-text-color;
+    text-align: center;
+    font-size: 15px;
+    line-height: 22px;
+    margin-top: 20px;
+  }
+
+  &__header {
+    margin-bottom: 30px;
+
+    &__hero {
+      width: 100%;
+      height: auto;
+      aspect-ratio: 1.9;
+      background: lighten($ui-base-color, 4%);
+      border-radius: 8px;
+      margin-bottom: 30px;
+    }
+
+    h1,
+    p {
+      text-align: center;
+    }
+
+    h1 {
+      font-size: 24px;
+      line-height: 1.5;
+      font-weight: 700;
+      margin-bottom: 10px;
+    }
+
+    p {
+      font-size: 16px;
+      line-height: 24px;
+      font-weight: 400;
+      color: $darker-text-color;
+    }
+  }
+
+  &__meta {
+    background: lighten($ui-base-color, 4%);
+    border-radius: 4px;
+    display: flex;
+    margin-bottom: 30px;
+    font-size: 15px;
+
+    &__column {
+      box-sizing: border-box;
+      width: 50%;
+      padding: 20px;
+    }
+
+    &__divider {
+      width: 0;
+      border: 0;
+      border-style: solid;
+      border-color: lighten($ui-base-color, 8%);
+      border-left-width: 1px;
+      min-height: calc(100% - 60px);
+      flex: 0 0 auto;
+    }
+
+    h4 {
+      font-size: 15px;
+      text-transform: uppercase;
+      color: $darker-text-color;
+      font-weight: 500;
+      margin-bottom: 20px;
+    }
+
+    @media screen and (max-width: 600px) {
+      display: block;
+
+      h4 {
+        text-align: center;
+      }
+
+      &__column {
+        width: 100%;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+      }
+
+      &__divider {
+        min-height: 0;
+        width: 100%;
+        border-left-width: 0;
+        border-top-width: 1px;
+      }
+    }
+
+    .layout-multiple-columns & {
+      display: block;
+
+      h4 {
+        text-align: center;
+      }
+
+      &__column {
+        width: 100%;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+      }
+
+      &__divider {
+        min-height: 0;
+        width: 100%;
+        border-left-width: 0;
+        border-top-width: 1px;
+      }
+    }
+  }
+
+  &__mail {
+    color: $primary-text-color;
+    text-decoration: none;
+    font-weight: 500;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+
+  .link-footer {
+    padding: 0;
+    margin-top: 60px;
+    text-align: center;
+    font-size: 15px;
+    line-height: 22px;
+
+    @media screen and (min-width: $no-gap-breakpoint) {
+      display: none;
+    }
+  }
+
+  .account {
+    padding: 0;
+    border: 0;
+  }
+
+  .account__avatar-wrapper {
+    margin-left: 0;
+  }
+
+  .account__relationship {
+    display: none;
+  }
+
+  &__section {
+    margin-bottom: 10px;
+
+    &__title {
+      font-size: 17px;
+      font-weight: 600;
+      line-height: 22px;
+      padding: 20px;
+      border-radius: 4px;
+      background: lighten($ui-base-color, 4%);
+      color: $highlight-text-color;
+      cursor: pointer;
+    }
+
+    &.active &__title {
+      border-radius: 4px 4px 0 0;
+    }
+
+    &__body {
+      border: 1px solid lighten($ui-base-color, 4%);
+      border-top: 0;
+      padding: 20px;
+      font-size: 15px;
+      line-height: 22px;
+    }
+  }
+
+  &__domain-blocks {
+    margin-top: 30px;
+    background: darken($ui-base-color, 4%);
+    border: 1px solid lighten($ui-base-color, 4%);
+    border-radius: 4px;
+
+    &__domain {
+      border-bottom: 1px solid lighten($ui-base-color, 4%);
+      padding: 10px;
+      font-size: 15px;
+      color: $darker-text-color;
+
+      &:nth-child(2n) {
+        background: darken($ui-base-color, 2%);
+      }
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__header {
+        display: flex;
+        gap: 10px;
+        justify-content: space-between;
+        font-weight: 500;
+        margin-bottom: 4px;
+      }
+
+      h6 {
+        color: $secondary-text-color;
+        font-size: inherit;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+
+      p {
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+    }
+  }
+}
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..b95cffbb4
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -0,0 +1,805 @@
+.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: $darker-text-color;
+    overflow: hidden;
+    text-decoration: none;
+    font-size: 14px;
+
+    &--with-note {
+      strong {
+        display: inline;
+      }
+    }
+  }
+
+  &__note {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    color: $ui-secondary-color;
+  }
+
+  &.small {
+    border: 0;
+    padding: 0;
+
+    & > .account__avatar-wrapper {
+      margin: 0 8px 0 0;
+    }
+
+    & > .display-name {
+      height: 24px;
+      line-height: 24px;
+    }
+  }
+}
+
+.follow-recommendations-account {
+  .icon-button {
+    color: $ui-primary-color;
+
+    &.active {
+      color: $valid-value-color;
+    }
+  }
+}
+
+.account__wrapper {
+  display: flex;
+}
+
+.account__avatar-wrapper {
+  float: left;
+  margin-left: 12px;
+  margin-right: 12px;
+}
+
+.account__avatar {
+  @include avatar-radius;
+
+  display: block;
+  position: relative;
+  cursor: pointer;
+  width: 36px;
+  height: 36px;
+  background-size: 36px 36px;
+
+  &-inline {
+    display: inline-block;
+    vertical-align: middle;
+    margin-right: 5px;
+  }
+
+  &-composite {
+    @include avatar-radius;
+
+    overflow: hidden;
+    position: relative;
+
+    & div {
+      @include avatar-radius;
+
+      float: left;
+      position: relative;
+      box-sizing: border-box;
+    }
+
+    &__label {
+      display: block;
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      color: $primary-text-color;
+      text-shadow: 1px 1px 2px $base-shadow-color;
+      font-weight: 700;
+      font-size: 15px;
+    }
+  }
+}
+
+.account__avatar-overlay {
+  @include avatar-size(48px);
+
+  position: relative;
+
+  &-base {
+    @include avatar-radius;
+    @include avatar-size(36px);
+
+    img {
+      @include avatar-radius;
+
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  &-overlay {
+    @include avatar-radius;
+    @include avatar-size(24px);
+
+    position: absolute;
+    bottom: 0;
+    right: 0;
+    z-index: 1;
+
+    img {
+      @include avatar-radius;
+
+      width: 100%;
+      height: 100%;
+    }
+  }
+}
+
+.account__relationship {
+  height: 18px;
+  padding: 10px;
+  white-space: nowrap;
+}
+
+.account__header__wrapper {
+  flex: 0 0 auto;
+  background: lighten($ui-base-color, 4%);
+}
+
+.account__disclaimer {
+  padding: 10px;
+  color: $dark-text-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__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-links {
+  display: flex;
+  flex: 1 1 auto;
+  line-height: 18px;
+  text-align: center;
+}
+
+.account__action-bar__tab {
+  text-decoration: none;
+  overflow: hidden;
+  flex: 0 1 100%;
+  border-left: 1px solid lighten($ui-base-color, 8%);
+  padding: 10px 0;
+  border-bottom: 4px solid transparent;
+
+  &:first-child {
+    border-left: 0;
+  }
+
+  &.active {
+    border-bottom: 4px solid $ui-highlight-color;
+  }
+
+  & > span {
+    display: block;
+    text-transform: uppercase;
+    font-size: 11px;
+    color: $darker-text-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: $highlight-text-color;
+  }
+}
+
+.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__report {
+  padding: 8px 10px;
+  padding-left: 68px;
+  position: relative;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  min-height: 54px;
+
+  &__details {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    color: $darker-text-color;
+    font-size: 15px;
+    line-height: 22px;
+
+    strong {
+      font-weight: 500;
+    }
+  }
+
+  &__avatar {
+    position: absolute;
+    left: 10px;
+    top: 10px;
+  }
+}
+
+.notification__message {
+  margin-left: 42px;
+  padding: 8px 0 0 26px;
+  cursor: default;
+  color: $darker-text-color;
+  font-size: 15px;
+  position: relative;
+
+  .fa {
+    color: $highlight-text-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;
+}
+
+.relationship-tag {
+  color: $primary-text-color;
+  margin-bottom: 4px;
+  display: block;
+  background-color: $base-overlay-background;
+  text-transform: uppercase;
+  font-size: 11px;
+  font-weight: 500;
+  padding: 4px;
+  border-radius: 4px;
+  opacity: 0.7;
+
+  &:hover {
+    opacity: 1;
+  }
+}
+
+.account-gallery__container {
+  display: flex;
+  flex-wrap: wrap;
+  padding: 4px 2px;
+}
+
+.account-gallery__item {
+  border: 0;
+  box-sizing: border-box;
+  display: block;
+  position: relative;
+  border-radius: 4px;
+  overflow: hidden;
+  margin: 2px;
+
+  &__icons {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    font-size: 24px;
+  }
+}
+
+.notification__filter-bar,
+.account__section-headline {
+  background: darken($ui-base-color, 4%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  cursor: default;
+  display: flex;
+  flex-shrink: 0;
+
+  button {
+    background: darken($ui-base-color, 4%);
+    border: 0;
+    margin: 0;
+  }
+
+  button,
+  a {
+    display: block;
+    flex: 1 1 auto;
+    color: $darker-text-color;
+    padding: 15px 0;
+    font-size: 14px;
+    font-weight: 500;
+    text-align: center;
+    text-decoration: none;
+    position: relative;
+
+    &.active {
+      color: $secondary-text-color;
+
+      &::before,
+      &::after {
+        display: block;
+        content: '';
+        position: absolute;
+        bottom: 0;
+        left: 50%;
+        width: 0;
+        height: 0;
+        transform: translateX(-50%);
+        border-style: solid;
+        border-width: 0 10px 10px;
+        border-color: transparent transparent lighten($ui-base-color, 8%);
+      }
+
+      &::after {
+        bottom: -1px;
+        border-color: transparent transparent $ui-base-color;
+      }
+    }
+  }
+
+  &.directory__section-headline {
+    background: darken($ui-base-color, 2%);
+    border-bottom-color: transparent;
+
+    a,
+    button {
+      &.active {
+        &::before {
+          display: none;
+        }
+
+        &::after {
+          border-color: transparent transparent darken($ui-base-color, 7%);
+        }
+      }
+    }
+  }
+}
+
+.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: $dark-text-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;
+  }
+}
+
+.account__header__content {
+  color: $darker-text-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 {
+  overflow: hidden;
+
+  &.inactive {
+    opacity: 0.5;
+
+    .account__header__image,
+    .account__avatar {
+      filter: grayscale(100%);
+    }
+  }
+
+  &__info {
+    position: absolute;
+    top: 10px;
+    left: 10px;
+  }
+
+  &__image {
+    overflow: hidden;
+    height: 145px;
+    position: relative;
+    background: darken($ui-base-color, 4%);
+
+    img {
+      object-fit: cover;
+      display: block;
+      width: 100%;
+      height: 100%;
+      margin: 0;
+    }
+  }
+
+  &__bar {
+    position: relative;
+    background: lighten($ui-base-color, 4%);
+    padding: 5px;
+    border-bottom: 1px solid lighten($ui-base-color, 12%);
+
+    .avatar {
+      display: block;
+      flex: 0 0 auto;
+      width: 94px;
+
+      .account__avatar {
+        background: darken($ui-base-color, 8%);
+        border: 2px solid lighten($ui-base-color, 4%);
+      }
+    }
+  }
+
+  &__tabs {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    padding: 7px 10px;
+    margin-top: -81px;
+    height: 130px;
+    overflow: hidden;
+    margin-left: -2px; // aligns the pfp with content below
+
+    .account-role {
+      margin: 0 2px;
+      padding: 4px 0;
+      box-sizing: border-box;
+      min-width: 90px;
+      text-align: center;
+    }
+
+    &__buttons {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding-top: 55px;
+      overflow: hidden;
+
+      .button {
+        flex-shrink: 1;
+        white-space: nowrap;
+
+        @media screen and (max-width: $no-gap-breakpoint) {
+          min-width: 0;
+        }
+      }
+
+      .icon-button {
+        border: 1px solid lighten($ui-base-color, 12%);
+        border-radius: 4px;
+        box-sizing: content-box;
+        padding: 2px;
+      }
+    }
+
+    &__name {
+      padding: 5px 10px;
+
+      .account-role {
+        vertical-align: top;
+      }
+
+      .emojione {
+        width: 22px;
+        height: 22px;
+      }
+
+      h1 {
+        font-size: 16px;
+        line-height: 24px;
+        color: $primary-text-color;
+        font-weight: 500;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+
+        small {
+          display: block;
+          font-size: 14px;
+          color: $darker-text-color;
+          font-weight: 400;
+          overflow: hidden;
+          text-overflow: ellipsis;
+
+          span {
+            user-select: all;
+          }
+        }
+      }
+    }
+
+    .spacer {
+      flex: 1 1 auto;
+    }
+  }
+
+  &__bio {
+    overflow: hidden;
+    margin: 0 -5px;
+
+    .account__header__content {
+      padding: 20px 15px;
+      padding-bottom: 5px;
+      color: $primary-text-color;
+    }
+
+    .account__header__joined {
+      font-size: 14px;
+      padding: 5px 15px;
+      color: $darker-text-color;
+
+      .columns-area--mobile & {
+        padding-left: 20px;
+        padding-right: 20px;
+      }
+    }
+
+    .account__header__fields {
+      margin: 0;
+      border-top: 1px solid lighten($ui-base-color, 12%);
+
+      a {
+        color: lighten($ui-highlight-color, 8%);
+      }
+
+      dl:first-child .verified {
+        border-radius: 0 4px 0 0;
+      }
+
+      .verified a {
+        color: $valid-value-color;
+      }
+    }
+  }
+
+  &__extra {
+    margin-top: 4px;
+
+    &__links {
+      font-size: 14px;
+      color: $darker-text-color;
+      padding: 10px 0;
+
+      a {
+        display: inline-block;
+        color: $darker-text-color;
+        text-decoration: none;
+        padding: 5px 10px;
+        font-weight: 500;
+
+        strong {
+          font-weight: 700;
+          color: $primary-text-color;
+        }
+      }
+    }
+  }
+
+  &__account-note {
+    margin: 0 -5px;
+    padding: 10px 15px;
+    display: flex;
+    flex-direction: column;
+    font-size: 14px;
+    font-weight: 400;
+    border-top: 1px solid lighten($ui-base-color, 12%);
+    border-bottom: 1px solid lighten($ui-base-color, 12%);
+
+    &__header {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      margin-bottom: 5px;
+      color: $darker-text-color;
+    }
+
+    &__content {
+      white-space: pre-wrap;
+      padding: 10px 0;
+    }
+
+    &__buttons {
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-end;
+      flex: 1 0;
+
+      .icon-button {
+        font-size: 14px;
+        padding: 2px 6px;
+        color: $darker-text-color;
+
+        &:hover,
+        &:active,
+        &:focus {
+          color: lighten($darker-text-color, 7%);
+          background-color: rgba($darker-text-color, 0.15);
+        }
+
+        &:focus {
+          background-color: rgba($darker-text-color, 0.3);
+        }
+
+        &[disabled] {
+          color: darken($darker-text-color, 13%);
+          background-color: transparent;
+          cursor: default;
+        }
+      }
+
+      .flex-spacer {
+        flex: 0 0 5px;
+        background: transparent;
+      }
+    }
+
+    strong {
+      font-size: 12px;
+      font-weight: 500;
+      text-transform: uppercase;
+    }
+
+    textarea {
+      display: block;
+      box-sizing: border-box;
+      width: calc(100% + 20px);
+      color: $secondary-text-color;
+      background: $ui-base-color;
+      padding: 10px;
+      margin: 0 -10px;
+      font-family: inherit;
+      font-size: 14px;
+      resize: none;
+      border: 0;
+      outline: 0;
+      border-radius: 4px;
+
+      &::placeholder {
+        color: $dark-text-color;
+        opacity: 1;
+      }
+    }
+  }
+}
+
+.moved-account-banner,
+.follow-request-banner {
+  padding: 20px;
+  background: lighten($ui-base-color, 4%);
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+
+  &__message {
+    color: $darker-text-color;
+    padding: 8px 0;
+    padding-top: 0;
+    padding-bottom: 4px;
+    font-size: 14px;
+    font-weight: 500;
+    text-align: center;
+    margin-bottom: 16px;
+  }
+
+  &__action {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 15px;
+    width: 100%;
+  }
+
+  .detailed-status__display-name {
+    margin-bottom: 0;
+  }
+}
+
+.follow-request-banner .button {
+  width: 100%;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/announcements.scss b/app/javascript/flavours/glitch/styles/components/announcements.scss
new file mode 100644
index 000000000..feaff81f5
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/announcements.scss
@@ -0,0 +1,233 @@
+.announcements__item__content {
+  word-wrap: break-word;
+  overflow-y: auto;
+
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -3px 0 0;
+  }
+
+  p {
+    margin-bottom: 10px;
+    white-space: pre-wrap;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: $secondary-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+
+    &.mention {
+      &:hover {
+        text-decoration: none;
+
+        span {
+          text-decoration: underline;
+        }
+      }
+    }
+
+    &.unhandled-link {
+      color: $highlight-text-color;
+    }
+  }
+}
+
+.announcements {
+  background: lighten($ui-base-color, 8%);
+  font-size: 13px;
+  display: flex;
+  align-items: flex-end;
+
+  &__mastodon {
+    width: 124px;
+    flex: 0 0 auto;
+
+    @media screen and (max-width: 124px + 300px) {
+      display: none;
+    }
+  }
+
+  &__container {
+    width: calc(100% - 124px);
+    flex: 0 0 auto;
+    position: relative;
+
+    @media screen and (max-width: 124px + 300px) {
+      width: 100%;
+    }
+  }
+
+  &__item {
+    box-sizing: border-box;
+    width: 100%;
+    padding: 15px;
+    position: relative;
+    font-size: 15px;
+    line-height: 20px;
+    word-wrap: break-word;
+    font-weight: 400;
+    max-height: 50vh;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+
+    &__range {
+      display: block;
+      font-weight: 500;
+      margin-bottom: 10px;
+      padding-right: 18px;
+    }
+
+    &__unread {
+      position: absolute;
+      top: 19px;
+      right: 19px;
+      display: block;
+      background: $highlight-text-color;
+      border-radius: 50%;
+      width: 0.625rem;
+      height: 0.625rem;
+    }
+  }
+
+  &__pagination {
+    padding: 15px;
+    color: $darker-text-color;
+    position: absolute;
+    bottom: 3px;
+    right: 0;
+  }
+}
+
+.layout-multiple-columns .announcements__mastodon {
+  display: none;
+}
+
+.layout-multiple-columns .announcements__container {
+  width: 100%;
+}
+
+.reactions-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  margin-top: 15px;
+  margin-left: -2px;
+  width: calc(100% - (90px - 33px));
+
+  &__item {
+    flex-shrink: 0;
+    background: lighten($ui-base-color, 12%);
+    border: 0;
+    border-radius: 3px;
+    margin: 2px;
+    cursor: pointer;
+    user-select: none;
+    padding: 0 6px;
+    display: flex;
+    align-items: center;
+    transition: all 100ms ease-in;
+    transition-property: background-color, color;
+
+    &__emoji {
+      display: block;
+      margin: 3px 0;
+      width: 16px;
+      height: 16px;
+
+      img {
+        display: block;
+        margin: 0;
+        width: 100%;
+        height: 100%;
+        min-width: auto;
+        min-height: auto;
+        vertical-align: bottom;
+        object-fit: contain;
+      }
+    }
+
+    &__count {
+      display: block;
+      min-width: 9px;
+      font-size: 13px;
+      font-weight: 500;
+      text-align: center;
+      margin-left: 6px;
+      color: $darker-text-color;
+    }
+
+    &:hover,
+    &:focus,
+    &:active {
+      background: lighten($ui-base-color, 16%);
+      transition: all 200ms ease-out;
+      transition-property: background-color, color;
+
+      &__count {
+        color: lighten($darker-text-color, 4%);
+      }
+    }
+
+    &.active {
+      transition: all 100ms ease-in;
+      transition-property: background-color, color;
+      background-color: mix(
+        lighten($ui-base-color, 12%),
+        $ui-highlight-color,
+        80%
+      );
+
+      .reactions-bar__item__count {
+        color: lighten($highlight-text-color, 8%);
+      }
+    }
+  }
+
+  .emoji-picker-dropdown {
+    margin: 2px;
+  }
+
+  &:hover .emoji-button {
+    opacity: 0.85;
+  }
+
+  .emoji-button {
+    color: $darker-text-color;
+    margin: 0;
+    font-size: 16px;
+    width: auto;
+    flex-shrink: 0;
+    padding: 0 6px;
+    height: 22px;
+    display: flex;
+    align-items: center;
+    opacity: 0.5;
+    transition: all 100ms ease-in;
+    transition-property: background-color, color;
+
+    &:hover,
+    &:active,
+    &:focus {
+      opacity: 1;
+      color: lighten($darker-text-color, 4%);
+      transition: all 200ms ease-out;
+      transition-property: background-color, color;
+    }
+  }
+
+  &--empty {
+    .emoji-button {
+      padding: 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..2969958e2
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/boost.scss
@@ -0,0 +1,44 @@
+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($action-button-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($highlight-text-color)}' stroke-width='0'/></svg>");
+  }
+
+  &:hover 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(lighten($action-button-color, 7%))}' 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($highlight-text-color)}' stroke-width='0'/></svg>");
+  }
+
+  &.reblogPrivate {
+    i.fa-retweet {
+      background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color($action-button-color)}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>");
+    }
+
+    &:hover i.fa-retweet {
+      background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color(lighten($action-button-color, 7%))}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>");
+    }
+  }
+
+  &.disabled {
+    i.fa-retweet,
+    &:hover i.fa-retweet {
+      background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 18.972656 1.2011719 C 18.829825 1.1881782 18.685932 1.2302188 18.572266 1.3300781 L 15.990234 3.5996094 C 15.58109 3.6070661 15.297269 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 12.664062 6.5195312 L 6.5761719 11.867188 C 6.5674697 11.818249 6.5507813 11.773891 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 13.045739 3.5690668 13.895038 3.6503906 14.4375 L 2.6152344 15.347656 C 2.3879011 15.547375 2.3754917 15.901081 2.5859375 16.140625 L 3.1464844 16.78125 C 3.3569308 17.020794 3.7101667 17.053234 3.9375 16.853516 L 19.892578 2.8359375 C 20.119911 2.6362188 20.134275 2.282513 19.923828 2.0429688 L 19.361328 1.4023438 C 19.256105 1.282572 19.115488 1.2141655 18.972656 1.2011719 z M 18.410156 6.7753906 L 15.419922 9.4042969 L 15.419922 9.9394531 L 14.810547 9.9394531 L 13.148438 11.400391 L 16.539062 15.640625 C 16.719062 15.890625 17.140313 15.890625 17.320312 15.640625 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 18.400391 9.9394531 L 18.400391 7.2402344 C 18.400391 7.0470074 18.407711 6.9489682 18.410156 6.7753906 z M 11.966797 12.439453 L 8.6679688 15.339844 L 14.919922 15.339844 L 12.619141 12.5 C 12.589141 12.48 12.590313 12.459453 12.570312 12.439453 L 11.966797 12.439453 z' fill='#{hex-color(darken($action-button-color, 13%))}' stroke-width='0'/></svg>");
+    }
+  }
+
+  .media-modal__overlay .picture-in-picture__footer & {
+    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($white)}' 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($highlight-text-color)}' stroke-width='0'/></svg>");
+    }
+
+    &.reblogPrivate {
+      i.fa-retweet {
+        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color($white)}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>");
+      }
+    }
+
+    &.disabled {
+      i.fa-retweet {
+        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 18.972656 1.2011719 C 18.829825 1.1881782 18.685932 1.2302188 18.572266 1.3300781 L 15.990234 3.5996094 C 15.58109 3.6070661 15.297269 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 12.664062 6.5195312 L 6.5761719 11.867188 C 6.5674697 11.818249 6.5507813 11.773891 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 13.045739 3.5690668 13.895038 3.6503906 14.4375 L 2.6152344 15.347656 C 2.3879011 15.547375 2.3754917 15.901081 2.5859375 16.140625 L 3.1464844 16.78125 C 3.3569308 17.020794 3.7101667 17.053234 3.9375 16.853516 L 19.892578 2.8359375 C 20.119911 2.6362188 20.134275 2.282513 19.923828 2.0429688 L 19.361328 1.4023438 C 19.256105 1.282572 19.115488 1.2141655 18.972656 1.2011719 z M 18.410156 6.7753906 L 15.419922 9.4042969 L 15.419922 9.9394531 L 14.810547 9.9394531 L 13.148438 11.400391 L 16.539062 15.640625 C 16.719062 15.890625 17.140313 15.890625 17.320312 15.640625 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 18.400391 9.9394531 L 18.400391 7.2402344 C 18.400391 7.0470074 18.407711 6.9489682 18.410156 6.7753906 z M 11.966797 12.439453 L 8.6679688 15.339844 L 14.919922 15.339844 L 12.619141 12.5 C 12.589141 12.48 12.590313 12.459453 12.570312 12.439453 L 11.966797 12.439453 z' fill='#{hex-color($white)}' 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..fd4bb95b5
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -0,0 +1,996 @@
+.column__wrapper {
+  display: flex;
+  flex: 1 1 auto;
+  position: relative;
+}
+
+.columns-area {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: row;
+  justify-content: flex-start;
+  overflow-x: auto;
+  position: relative;
+
+  &__panels {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    min-height: 100vh;
+
+    &__pane {
+      height: 100%;
+      overflow: hidden;
+      pointer-events: none;
+      display: flex;
+      justify-content: flex-end;
+      min-width: 285px;
+
+      &--start {
+        justify-content: flex-start;
+      }
+
+      &__inner {
+        position: fixed;
+        width: 285px;
+        pointer-events: auto;
+        height: 100%;
+      }
+    }
+
+    &__main {
+      box-sizing: border-box;
+      width: 100%;
+      flex: 0 0 auto;
+      display: flex;
+      flex-direction: column;
+
+      @media screen and (min-width: $no-gap-breakpoint) {
+        padding: 0 10px;
+        max-width: 600px;
+      }
+    }
+  }
+}
+
+$ui-header-height: 55px;
+
+.ui__header {
+  display: none;
+  box-sizing: border-box;
+  height: $ui-header-height;
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  justify-content: space-between;
+  align-items: center;
+  overflow: hidden;
+
+  &__logo {
+    display: inline-flex;
+    padding: 15px;
+
+    .logo {
+      height: $ui-header-height - 30px;
+      width: auto;
+    }
+  }
+
+  &__links {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 0 10px;
+    overflow: hidden;
+
+    .button {
+      flex: 0 0 auto;
+    }
+
+    .button-tertiary {
+      flex-shrink: 1;
+    }
+  }
+}
+
+.tabs-bar__wrapper {
+  background: darken($ui-base-color, 8%);
+  position: sticky;
+  top: $ui-header-height;
+  z-index: 2;
+  padding-top: 0;
+
+  @media screen and (min-width: $no-gap-breakpoint) {
+    padding-top: 10px;
+    top: 0;
+  }
+
+  .tabs-bar {
+    margin-bottom: 0;
+
+    @media screen and (min-width: $no-gap-breakpoint) {
+      margin-bottom: 10px;
+    }
+  }
+}
+
+.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%;
+}
+
+.column {
+  overflow: hidden;
+}
+
+.column-back-button {
+  box-sizing: border-box;
+  width: 100%;
+  background: lighten($ui-base-color, 4%);
+  border-radius: 4px 4px 0 0;
+  color: $highlight-text-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: $highlight-text-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;
+  overflow: hidden;
+  white-space: nowrap;
+
+  &:hover,
+  &:focus,
+  &:active {
+    background: lighten($ui-base-color, 11%);
+  }
+
+  &:focus {
+    outline: 0;
+  }
+
+  &--transparent {
+    background: transparent;
+    color: $ui-secondary-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      background: transparent;
+      color: $primary-text-color;
+    }
+
+    &.active {
+      color: $highlight-text-color;
+    }
+  }
+
+  &--logo {
+    background: transparent;
+    padding: 10px;
+
+    &:hover,
+    &:focus,
+    &:active {
+      background: transparent;
+    }
+  }
+}
+
+.column-link__icon {
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.column-subheading {
+  background: $ui-base-color;
+  color: $dark-text-color;
+  padding: 8px 20px;
+  font-size: 12px;
+  font-weight: 500;
+  text-transform: uppercase;
+  cursor: default;
+}
+
+.column-header__wrapper {
+  position: relative;
+  flex: 0 0 auto;
+  z-index: 1;
+
+  &.active {
+    box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);
+
+    &::before {
+      display: block;
+      content: '';
+      position: absolute;
+      bottom: -13px;
+      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%
+      );
+    }
+  }
+
+  .announcements {
+    z-index: 1;
+    position: relative;
+  }
+}
+
+.column-header {
+  display: flex;
+  font-size: 16px;
+  background: lighten($ui-base-color, 4%);
+  border-radius: 4px 4px 0 0;
+  flex: 0 0 auto;
+  cursor: pointer;
+  position: relative;
+  z-index: 2;
+  outline: 0;
+  overflow: hidden;
+
+  & > button {
+    margin: 0;
+    border: 0;
+    padding: 15px;
+    color: inherit;
+    background: transparent;
+    font: inherit;
+    text-align: left;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+    flex: 1;
+  }
+
+  & > .column-header__back-button {
+    color: $highlight-text-color;
+  }
+
+  &.active {
+    .column-header__icon {
+      color: $highlight-text-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 .columns-area:not(.columns-area--mobile) & {
+    flex: auto;
+    min-width: 330px;
+    max-width: 400px;
+  }
+
+  > .scrollable {
+    background: $ui-base-color;
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+  }
+}
+
+.column-header__buttons {
+  height: 48px;
+  display: flex;
+  margin-left: 0;
+}
+
+.column-header__links {
+  margin-bottom: 14px;
+}
+
+.column-header__links .text-btn {
+  margin-right: 10px;
+}
+
+.column-header__button {
+  background: lighten($ui-base-color, 4%);
+  border: 0;
+  color: $darker-text-color;
+  cursor: pointer;
+  font-size: 16px;
+  padding: 0 15px;
+
+  &:hover {
+    color: lighten($darker-text-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%);
+  }
+
+  &:disabled {
+    color: $dark-text-color;
+    cursor: default;
+  }
+}
+
+.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 5px;
+    font-size: 14px;
+  }
+
+  b {
+    font-weight: bold;
+  }
+}
+
+.layout-single-column .column-header__notif-cleaning-buttons {
+  @media screen and (min-width: $no-gap-breakpoint) {
+    b,
+    i {
+      margin-right: 5px;
+    }
+
+    br {
+      display: none;
+    }
+
+    button {
+      padding: 15px 5px;
+    }
+  }
+}
+
+// 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: $darker-text-color;
+  transition: max-height 150ms ease-in-out, opacity 300ms linear;
+  opacity: 1;
+  z-index: 1;
+  position: relative;
+
+  &.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,
+  &:focus {
+    color: $darker-text-color;
+    text-decoration: underline;
+  }
+}
+
+.column-header__collapsible__extra + .column-header__setting-btn {
+  padding-top: 5px;
+}
+
+.column-header__permission-btn {
+  display: inline;
+  font-weight: inherit;
+  text-decoration: underline;
+}
+
+.column-header__setting-arrows {
+  float: right;
+
+  .column-header__setting-btn {
+    padding: 5px;
+
+    &:first-child {
+      padding-right: 7px;
+    }
+
+    &:last-child {
+      padding-left: 7px;
+      margin-left: 5px;
+    }
+  }
+}
+
+.column-header__title {
+  display: inline-block;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+  flex: 1;
+}
+
+.column-header__issue-btn {
+  color: $warning-red;
+
+  &:hover {
+    color: $error-red;
+    text-decoration: underline;
+  }
+}
+
+.column-header__icon {
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.column-settings__pillbar {
+  display: flex;
+  overflow: hidden;
+  background-color: transparent;
+  border: 0;
+  border-radius: 4px;
+  margin-bottom: 10px;
+  align-items: stretch;
+  gap: 2px;
+}
+
+.pillbar-button {
+  border: 0;
+  color: #fafafa;
+  padding: 2px;
+  margin: 0;
+  font-size: inherit;
+  flex: auto;
+  background-color: $ui-base-color;
+  transition: all 0.2s ease;
+  transition-property: background-color, box-shadow;
+
+  &[disabled] {
+    cursor: not-allowed;
+    opacity: 0.5;
+  }
+
+  &:not([disabled]) {
+    &:hover,
+    &:focus {
+      background-color: darken($ui-base-color, 10%);
+    }
+
+    &.active {
+      background-color: darken($ui-highlight-color, 2%);
+
+      &:hover,
+      &:focus {
+        background-color: $ui-highlight-color;
+      }
+    }
+  }
+}
+
+.limited-account-hint {
+  p {
+    color: $secondary-text-color;
+    font-size: 15px;
+    font-weight: 500;
+    margin-bottom: 20px;
+  }
+}
+
+.empty-column-indicator,
+.follow_requests-unlocked_explanation {
+  color: $dark-text-color;
+  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;
+  }
+
+  & > span {
+    max-width: 500px;
+  }
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.follow_requests-unlocked_explanation {
+  background: darken($ui-base-color, 4%);
+  contain: initial;
+}
+
+.error-column {
+  padding: 20px;
+  background: $ui-base-color;
+  border-radius: 4px;
+  display: flex;
+  flex: 1 1 auto;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  cursor: default;
+
+  &__image {
+    width: 70%;
+    max-width: 350px;
+    margin-top: -50px;
+  }
+
+  &__message {
+    text-align: center;
+    color: $darker-text-color;
+    font-size: 15px;
+    line-height: 22px;
+
+    h1 {
+      font-size: 28px;
+      line-height: 33px;
+      font-weight: 700;
+      margin-bottom: 15px;
+      color: $primary-text-color;
+    }
+
+    p {
+      max-width: 48ch;
+    }
+
+    &__actions {
+      margin-top: 30px;
+      display: flex;
+      gap: 10px;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+}
+
+// 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: $no-gap-breakpoint) {
+    @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: $no-gap-breakpoint) {
+    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: 0 5px;
+  }
+}
+
+.column-settings__outer {
+  background: lighten($ui-base-color, 8%);
+  padding: 15px;
+}
+
+.column-settings__section {
+  color: $darker-text-color;
+  cursor: default;
+  display: block;
+  font-weight: 500;
+  margin-bottom: 10px;
+}
+
+.column-settings__row--with-margin {
+  margin-bottom: 15px;
+}
+
+.column-settings__hashtags {
+  .column-settings__row {
+    margin-bottom: 15px;
+  }
+
+  .column-select {
+    &__control {
+      @include search-input;
+
+      &::placeholder {
+        color: lighten($darker-text-color, 4%);
+      }
+
+      &::-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;
+      }
+    }
+
+    &__placeholder {
+      color: $dark-text-color;
+      padding-left: 2px;
+      font-size: 12px;
+    }
+
+    &__value-container {
+      padding-left: 6px;
+    }
+
+    &__multi-value {
+      background: lighten($ui-base-color, 8%);
+
+      &__remove {
+        cursor: pointer;
+
+        &:hover,
+        &:active,
+        &:focus {
+          background: lighten($ui-base-color, 12%);
+          color: lighten($darker-text-color, 4%);
+        }
+      }
+    }
+
+    &__multi-value__label,
+    &__input,
+    &__input-container {
+      color: $darker-text-color;
+    }
+
+    &__clear-indicator,
+    &__dropdown-indicator {
+      cursor: pointer;
+      transition: none;
+      color: $dark-text-color;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($dark-text-color, 4%);
+      }
+    }
+
+    &__indicator-separator {
+      background-color: lighten($ui-base-color, 8%);
+    }
+
+    &__menu {
+      @include search-popout;
+
+      padding: 0;
+      background: $ui-secondary-color;
+    }
+
+    &__menu-list {
+      padding: 6px;
+    }
+
+    &__option {
+      color: $inverted-text-color;
+      border-radius: 4px;
+      font-size: 14px;
+
+      &--is-focused,
+      &--is-selected {
+        background: darken($ui-secondary-color, 10%);
+      }
+    }
+  }
+}
+
+.column-settings__row {
+  .text-btn:not(.column-header__permission-btn) {
+    margin-bottom: 15px;
+  }
+}
+
+.notifications-permission-banner {
+  padding: 30px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+
+  &__close {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+  }
+
+  h2 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+
+  p {
+    color: $darker-text-color;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+}
+
+.column-title {
+  text-align: center;
+  padding: 40px;
+
+  .logo {
+    width: 50px;
+    margin: 0 auto;
+    margin-bottom: 40px;
+  }
+
+  h3 {
+    font-size: 24px;
+    line-height: 1.5;
+    font-weight: 700;
+    margin-bottom: 10px;
+  }
+
+  p {
+    font-size: 16px;
+    line-height: 24px;
+    font-weight: 400;
+    color: $darker-text-color;
+  }
+}
+
+.follow-recommendations-container {
+  display: flex;
+  flex-direction: column;
+}
+
+.column-actions {
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 40px;
+  padding-top: 40px;
+  padding-bottom: 200px;
+  flex-grow: 1;
+  position: relative;
+
+  &__background {
+    position: absolute;
+    left: 0;
+    bottom: 0;
+    height: 220px;
+    width: auto;
+  }
+}
+
+.column-list {
+  margin: 0 20px;
+  border: 1px solid lighten($ui-base-color, 8%);
+  background: darken($ui-base-color, 2%);
+  border-radius: 4px;
+
+  &__empty-message {
+    padding: 40px;
+    text-align: center;
+    font-size: 16px;
+    line-height: 24px;
+    font-weight: 400;
+    color: $darker-text-color;
+  }
+}
+
+.dismissable-banner {
+  background: $ui-base-color;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  align-items: center;
+  gap: 30px;
+
+  &__message {
+    flex: 1 1 auto;
+    padding: 20px 15px;
+    cursor: default;
+    font-size: 14px;
+    line-height: 18px;
+    color: $primary-text-color;
+  }
+
+  &__action {
+    padding: 15px;
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss
new file mode 100644
index 000000000..1c2e0aeb4
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss
@@ -0,0 +1,683 @@
+.compose-form {
+  padding: 10px;
+
+  .emoji-picker-dropdown {
+    position: absolute;
+    top: 0;
+    right: 0;
+
+    ::-webkit-scrollbar-track:hover,
+    ::-webkit-scrollbar-track:active {
+      background-color: rgba($base-overlay-background, 0.3);
+    }
+  }
+}
+
+.character-counter {
+  cursor: default;
+  font-family: $font-sans-serif, sans-serif;
+  font-size: 14px;
+  font-weight: 600;
+  color: $lighter-text-color;
+
+  &.character-counter--over {
+    color: $warning-red;
+  }
+}
+
+.no-reduce-motion .spoiler-input {
+  transition: height 0.4s ease, opacity 0.4s ease;
+}
+
+.spoiler-input {
+  height: 0;
+  transform-origin: bottom;
+  opacity: 0;
+
+  &.spoiler-input--visible {
+    height: 36px;
+    margin-bottom: 11px;
+    opacity: 1;
+  }
+
+  input {
+    display: block;
+    box-sizing: border-box;
+    margin: 0;
+    border: 0;
+    border-radius: 4px;
+    padding: 10px;
+    width: 100%;
+    outline: 0;
+    color: $inverted-text-color;
+    background: $simple-background-color;
+    font-size: 14px;
+    font-family: inherit;
+    resize: vertical;
+
+    &::placeholder {
+      color: $dark-text-color;
+    }
+
+    &:focus {
+      outline: 0;
+    }
+    @include single-column('screen and (max-width: 630px)') {
+      font-size: 16px;
+    }
+  }
+}
+
+.compose-form__warning {
+  color: $inverted-text-color;
+  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: $lighter-text-color;
+    font-weight: 500;
+    text-decoration: underline;
+
+    &:active,
+    &:focus,
+    &:hover {
+      text-decoration: none;
+    }
+  }
+}
+
+.compose-form__sensitive-button {
+  padding: 10px;
+  padding-top: 0;
+  font-size: 14px;
+  font-weight: 500;
+
+  &.active {
+    color: $highlight-text-color;
+  }
+
+  input[type='checkbox'] {
+    display: none;
+  }
+
+  .checkbox {
+    display: inline-block;
+    position: relative;
+    border: 1px solid $ui-primary-color;
+    box-sizing: border-box;
+    width: 18px;
+    height: 18px;
+    flex: 0 0 auto;
+    margin-left: 5px;
+    margin-right: 10px;
+    top: -1px;
+    border-radius: 4px;
+    vertical-align: middle;
+
+    &.active {
+      border-color: $highlight-text-color;
+      background: $highlight-text-color
+        url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>")
+        center center no-repeat;
+    }
+  }
+}
+
+.reply-indicator {
+  margin: 0 0 10px;
+  border-radius: 4px;
+  padding: 10px;
+  background: $ui-primary-color;
+  min-height: 23px;
+  overflow-y: auto;
+  flex: 0 2 auto;
+}
+
+.reply-indicator__header {
+  margin-bottom: 5px;
+  overflow: hidden;
+
+  & > .account.small {
+    color: $inverted-text-color;
+  }
+}
+
+.reply-indicator__cancel {
+  float: right;
+  line-height: 24px;
+}
+
+.reply-indicator__content {
+  position: relative;
+  font-size: 14px;
+  line-height: 20px;
+  word-wrap: break-word;
+  font-weight: 400;
+  overflow: hidden;
+  padding-top: 5px;
+  color: $inverted-text-color;
+  white-space: pre-wrap;
+
+  p,
+  pre {
+    margin-bottom: 20px;
+    white-space: pre-wrap;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: $lighter-text-color;
+    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;
+  }
+}
+
+.compose-form .compose-form__autosuggest-wrapper {
+  position: relative;
+}
+
+.compose-form .autosuggest-textarea,
+.compose-form .autosuggest-input {
+  position: relative;
+  width: 100%;
+
+  label {
+    .autosuggest-textarea__textarea {
+      display: block;
+      box-sizing: border-box;
+      margin: 0;
+      border: 0;
+      border-radius: 4px 4px 0 0;
+      padding: 10px 32px 0 10px;
+      width: 100%;
+      min-height: 100px;
+      outline: 0;
+      color: $inverted-text-color;
+      background: $simple-background-color;
+      font-size: 14px;
+      font-family: inherit;
+      resize: none;
+      scrollbar-color: initial;
+
+      &::placeholder {
+        color: $dark-text-color;
+      }
+
+      &::-webkit-scrollbar {
+        all: unset;
+      }
+
+      &: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;
+      }
+    }
+  }
+}
+
+.compose-form__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: $lighter-text-color;
+    font-size: 18px;
+    line-height: 24px;
+    text-align: center;
+    opacity: 0.8;
+  }
+}
+
+.autosuggest-textarea__suggestions-wrapper {
+  position: relative;
+  height: 0;
+}
+
+.autosuggest-textarea__suggestions {
+  box-sizing: border-box;
+  display: none;
+  position: absolute;
+  top: 100%;
+  width: 100%;
+  z-index: 99;
+  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+  background: $ui-secondary-color;
+  border-radius: 0 0 4px 4px;
+  color: $inverted-text-color;
+  font-size: 14px;
+  padding: 6px;
+}
+
+.autosuggest-textarea__suggestions--visible {
+  display: block;
+}
+
+.autosuggest-textarea__suggestions__item {
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 4px;
+
+  &:hover,
+  &:focus,
+  &:active,
+  &.selected {
+    background: darken($ui-secondary-color, 10%);
+  }
+
+  > .account,
+  > .emoji,
+  > .autosuggest-hashtag {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: flex-start;
+    line-height: 18px;
+    font-size: 14px;
+  }
+
+  .autosuggest-hashtag {
+    justify-content: space-between;
+
+    &__name {
+      flex: 1 1 auto;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    strong {
+      font-weight: 500;
+    }
+
+    &__uses {
+      flex: 0 0 auto;
+      text-align: right;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  & > .account.small {
+    .display-name {
+      & > span {
+        color: $lighter-text-color;
+      }
+    }
+  }
+}
+
+.compose-form__upload-wrapper {
+  overflow: hidden;
+}
+
+.compose-form__uploads-wrapper {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  font-family: inherit;
+  padding: 5px;
+  overflow: hidden;
+}
+
+.compose-form__upload {
+  flex: 1 1 0;
+  margin: 5px;
+  min-width: 40%;
+
+  .compose-form__upload-thumbnail {
+    position: relative;
+    border-radius: 4px;
+    height: 140px;
+    width: 100%;
+    background-color: $base-shadow-color;
+    background-position: center;
+    background-size: cover;
+    background-repeat: no-repeat;
+    overflow: hidden;
+
+    & > .close {
+      mix-blend-mode: difference;
+    }
+  }
+
+  .icon-button {
+    flex: 0 1 auto;
+    color: $secondary-text-color;
+    font-size: 14px;
+    font-weight: 500;
+    padding: 10px;
+    font-family: inherit;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: lighten($secondary-text-color, 7%);
+    }
+  }
+
+  &__warning {
+    position: absolute;
+    z-index: 2;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    box-sizing: border-box;
+    background: linear-gradient(
+      0deg,
+      rgba($base-shadow-color, 0.8) 0,
+      rgba($base-shadow-color, 0.35) 80%,
+      transparent
+    );
+  }
+}
+
+.compose-form__upload__actions {
+  background: linear-gradient(
+    180deg,
+    rgba($base-shadow-color, 0.8) 0,
+    rgba($base-shadow-color, 0.35) 80%,
+    transparent
+  );
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+
+.upload-progress {
+  display: flex;
+  padding: 10px;
+  color: $darker-text-color;
+  overflow: hidden;
+
+  .fa {
+    font-size: 34px;
+    margin-right: 10px;
+  }
+
+  span {
+    display: block;
+    font-size: 12px;
+    font-weight: 500;
+    text-transform: uppercase;
+  }
+}
+
+.upload-progress__message {
+  flex: 1 1 auto;
+}
+
+.upload-progress__backdrop {
+  position: relative;
+  margin-top: 5px;
+  border-radius: 6px;
+  width: 100%;
+  height: 6px;
+  background: darken($simple-background-color, 8%);
+}
+
+.upload-progress__tracker {
+  position: absolute;
+  top: 0;
+  left: 0;
+  height: 6px;
+  border-radius: 6px;
+  background: $ui-highlight-color;
+}
+
+.compose-form__modifiers {
+  color: $inverted-text-color;
+  font-family: inherit;
+  font-size: 14px;
+  background: $simple-background-color;
+}
+
+.compose-form__buttons-wrapper {
+  padding: 10px;
+  background: darken($simple-background-color, 8%);
+  border-radius: 0 0 4px 4px;
+  height: 27px;
+  display: flex;
+  justify-content: space-between;
+  flex: 0 0 auto;
+}
+
+.compose-form__buttons {
+  display: flex;
+  flex: 0 0 auto;
+
+  & .icon-button,
+  & .text-icon-button {
+    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;
+  }
+}
+
+.character-counter__wrapper {
+  align-self: center;
+  margin-right: 4px;
+}
+
+.privacy-dropdown.active {
+  .privacy-dropdown__value {
+    background: $simple-background-color;
+    border-radius: 4px 4px 0 0;
+    box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+
+    .icon-button {
+      transition: none;
+    }
+
+    &.active {
+      background: $ui-highlight-color;
+
+      .icon-button {
+        color: $primary-text-color;
+      }
+    }
+  }
+
+  &.top .privacy-dropdown__value {
+    border-radius: 0 0 4px 4px;
+  }
+
+  .privacy-dropdown__dropdown {
+    display: block;
+    box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
+  }
+}
+
+.privacy-dropdown__dropdown {
+  border-radius: 4px;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  background: $simple-background-color;
+  overflow: hidden;
+  transform-origin: 50% 0;
+}
+
+.privacy-dropdown__option {
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  color: $inverted-text-color;
+  cursor: pointer;
+
+  .privacy-dropdown__option__content {
+    flex: 1 1 auto;
+    color: $lighter-text-color;
+
+    &:not(:first-child) {
+      margin-left: 10px;
+    }
+
+    strong {
+      display: block;
+      color: $inverted-text-color;
+      font-weight: 500;
+    }
+  }
+
+  &:hover,
+  &.active {
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+
+    .privacy-dropdown__option__content {
+      color: $primary-text-color;
+
+      strong {
+        color: $primary-text-color;
+      }
+    }
+  }
+
+  &.active:hover {
+    background: lighten($ui-highlight-color, 4%);
+  }
+}
+
+.compose-form__publish {
+  display: flex;
+  justify-content: flex-end;
+  min-width: 0;
+  flex: 0 0 auto;
+  column-gap: 5px;
+
+  .compose-form__publish-button-wrapper {
+    overflow: hidden;
+    padding-top: 10px;
+
+    button {
+      padding: 7px 10px;
+      text-align: center;
+    }
+
+    & > .side_arm {
+      width: 36px;
+    }
+  }
+}
+
+.language-dropdown {
+  &__dropdown {
+    background: $simple-background-color;
+    box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+    border-radius: 4px;
+    overflow: hidden;
+    z-index: 2;
+
+    &.top {
+      transform-origin: 50% 100%;
+    }
+
+    &.bottom {
+      transform-origin: 50% 0;
+    }
+
+    .emoji-mart-search {
+      padding-right: 10px;
+    }
+
+    .emoji-mart-search-icon {
+      right: 10px + 5px;
+    }
+
+    .emoji-mart-scroll {
+      padding: 0 10px 10px;
+    }
+
+    &__results {
+      &__item {
+        cursor: pointer;
+        color: $inverted-text-color;
+        font-weight: 500;
+        padding: 10px;
+        border-radius: 4px;
+
+        &:focus,
+        &:active,
+        &:hover {
+          background: $ui-secondary-color;
+        }
+
+        &__common-name {
+          color: $darker-text-color;
+        }
+
+        &.active {
+          background: $ui-highlight-color;
+          color: $primary-text-color;
+          outline: 0;
+
+          .language-dropdown__dropdown__results__item__common-name {
+            color: $secondary-text-color;
+          }
+
+          &:hover {
+            background: lighten($ui-highlight-color, 4%);
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/directory.scss b/app/javascript/flavours/glitch/styles/components/directory.scss
new file mode 100644
index 000000000..5c763764d
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/directory.scss
@@ -0,0 +1,68 @@
+.scrollable .account-card {
+  margin: 10px;
+  background: lighten($ui-base-color, 8%);
+}
+
+.scrollable .account-card__title__avatar {
+  img,
+  .account__avatar {
+    border-color: lighten($ui-base-color, 8%);
+  }
+}
+
+.scrollable .account-card__bio::after {
+  background: linear-gradient(
+    to left,
+    lighten($ui-base-color, 8%),
+    transparent
+  );
+}
+
+.filter-form {
+  background: $ui-base-color;
+
+  &__column {
+    padding: 10px 15px;
+    padding-bottom: 0;
+  }
+
+  .radio-button {
+    display: block;
+  }
+}
+
+.radio-button {
+  font-size: 14px;
+  position: relative;
+  display: inline-block;
+  padding: 6px 0;
+  line-height: 18px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  cursor: pointer;
+
+  input[type='radio'],
+  input[type='checkbox'] {
+    display: none;
+  }
+
+  &__input {
+    display: inline-block;
+    position: relative;
+    border: 1px solid $ui-primary-color;
+    box-sizing: border-box;
+    width: 18px;
+    height: 18px;
+    flex: 0 0 auto;
+    margin-right: 10px;
+    top: -1px;
+    border-radius: 50%;
+    vertical-align: middle;
+
+    &.checked {
+      border-color: lighten($ui-highlight-color, 4%);
+      background: lighten($ui-highlight-color, 4%);
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/domains.scss b/app/javascript/flavours/glitch/styles/components/domains.scss
new file mode 100644
index 000000000..a99ccd02b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/domains.scss
@@ -0,0 +1,23 @@
+.domain {
+  padding: 10px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  .domain__domain-name {
+    flex: 1 1 auto;
+    display: block;
+    color: $primary-text-color;
+    text-decoration: none;
+    font-size: 14px;
+    font-weight: 500;
+  }
+}
+
+.domain__wrapper {
+  display: flex;
+}
+
+.domain_buttons {
+  height: 18px;
+  padding: 10px;
+  white-space: nowrap;
+}
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..52c7cd54a
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/doodle.scss
@@ -0,0 +1,90 @@
+$doodle-background: #d9e1e8;
+
+.doodle-modal {
+  @extend .boost-modal;
+
+  width: unset;
+}
+
+.doodle-modal__container {
+  background: $doodle-background;
+  text-align: center;
+  line-height: 0; // remove weird gap under canvas
+  canvas {
+    border: 5px solid $doodle-background;
+  }
+}
+
+.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: 0.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, 0.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..33a48eec4
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -0,0 +1,284 @@
+.drawer {
+  width: 300px;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  overflow-y: hidden;
+  padding: 10px 5px;
+  flex: none;
+
+  &: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%;
+  }
+}
+
+.drawer--header {
+  flex: none;
+  font-size: 16px;
+  background: lighten($ui-base-color, 8%);
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  border-radius: 4px;
+  overflow: hidden;
+
+  & > * {
+    display: block;
+    box-sizing: border-box;
+    border-bottom: 2px solid transparent;
+    padding: 15px 5px 13px;
+    height: 48px;
+    flex: 1 1 auto;
+    color: $darker-text-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;
+    }
+  }
+}
+
+.search {
+  position: relative;
+  margin-bottom: 10px;
+  flex: none;
+
+  @include limited-single-column(
+    'screen and (max-width: #{$no-gap-breakpoint})'
+  ) {
+    margin-bottom: 0;
+  }
+  @include single-column('screen and (max-width: 630px)') {
+    font-size: 16px;
+  }
+}
+
+.search-popout {
+  @include search-popout;
+}
+
+.navigation-bar {
+  padding: 10px;
+  color: $darker-text-color;
+  display: flex;
+  align-items: center;
+
+  a {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  .acct {
+    display: block;
+    color: $secondary-text-color;
+    font-weight: 500;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.navigation-bar__profile {
+  flex: 1 1 auto;
+  margin-left: 8px;
+  overflow: hidden;
+}
+
+.drawer--results {
+  overflow-x: hidden;
+  overflow-y: scroll;
+}
+
+.search-results__section {
+  margin-bottom: 5px;
+
+  h5 {
+    background: darken($ui-base-color, 4%);
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+    cursor: default;
+    display: flex;
+    padding: 15px;
+    font-weight: 500;
+    font-size: 16px;
+    color: $dark-text-color;
+
+    .fa {
+      display: inline-block;
+      margin-right: 5px;
+    }
+  }
+
+  .account:last-child,
+  & > div:last-child .status {
+    border-bottom: 0;
+  }
+
+  & > .hashtag {
+    display: block;
+    padding: 10px;
+    color: $secondary-text-color;
+    text-decoration: none;
+
+    &:hover,
+    &:active,
+    &:focus {
+      color: lighten($secondary-text-color, 4%);
+      text-decoration: underline;
+    }
+  }
+}
+
+.drawer__pager {
+  box-sizing: border-box;
+  padding: 0;
+  flex-grow: 1;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+  border-radius: 4px;
+}
+
+.drawer__inner {
+  position: absolute;
+  top: 0;
+  left: 0;
+  background: lighten($ui-base-color, 13%);
+  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;
+  }
+}
+
+.drawer__inner__mastodon {
+  background: lighten($ui-base-color, 13%)
+    url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>')
+    no-repeat bottom / 100% auto;
+  flex: 1;
+  min-height: 47px;
+  display: none;
+
+  > img {
+    display: block;
+    object-fit: contain;
+    object-position: bottom left;
+    width: 85%;
+    height: 100%;
+    pointer-events: none;
+    user-drag: none;
+    user-select: none;
+  }
+
+  > .mastodon {
+    display: block;
+    width: 100%;
+    height: 100%;
+    border: 0;
+    cursor: inherit;
+  }
+
+  @media screen and (min-height: 640px) {
+    display: block;
+  }
+}
+
+.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);
+}
+
+@for $i from 0 through 3 {
+  .mbstobon-#{$i} .drawer__inner__mastodon {
+    @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%);
+      }
+    }
+  }
+}
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..4427f2080
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/emoji.scss
@@ -0,0 +1,101 @@
+.emojione {
+  font-size: inherit;
+  vertical-align: middle;
+  object-fit: contain;
+  margin: -0.2ex 0.15em 0.2ex;
+  width: 16px;
+  height: 16px;
+
+  img {
+    width: auto;
+  }
+}
+
+.emoji-picker-dropdown__menu {
+  background: $simple-background-color;
+  position: relative;
+  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+  border-radius: 4px;
+  margin-top: 5px;
+  z-index: 2;
+
+  .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;
+  padding: 5px 5px 2px 2px;
+  outline: 0;
+  cursor: pointer;
+
+  &:active,
+  &:focus {
+    outline: 0 !important;
+  }
+
+  img {
+    filter: grayscale(100%);
+    opacity: 0.8;
+    display: block;
+    margin: 0;
+    width: 22px;
+    height: 22px;
+  }
+
+  &: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..6bb9827b3
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/emoji_picker.scss
@@ -0,0 +1,261 @@
+.emoji-mart {
+  &,
+  * {
+    box-sizing: border-box;
+    line-height: 1.15;
+  }
+
+  font-size: 13px;
+  display: inline-block;
+  color: $inverted-text-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: $lighter-text-color;
+  line-height: 0;
+}
+
+.emoji-mart-anchor {
+  position: relative;
+  flex: 1;
+  text-align: center;
+  padding: 12px 4px;
+  overflow: hidden;
+  transition: color 0.1s ease-out;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+
+  &:hover {
+    color: darken($lighter-text-color, 4%);
+  }
+}
+
+.emoji-mart-anchor-selected {
+  color: $highlight-text-color;
+
+  &:hover {
+    color: darken($highlight-text-color, 4%);
+  }
+
+  .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;
+  position: relative;
+
+  input {
+    font-size: 16px;
+    font-weight: 400;
+    padding: 7px 9px;
+    padding-right: 25px;
+    font-family: inherit;
+    display: block;
+    width: 100%;
+    background: rgba($ui-secondary-color, 0.3);
+    color: $inverted-text-color;
+    border: 1px solid $ui-secondary-color;
+    border-radius: 4px;
+
+    &::-moz-focus-inner {
+      border: 0;
+    }
+
+    &::-moz-focus-inner,
+    &:focus,
+    &:active {
+      outline: 0 !important;
+    }
+
+    &::-webkit-search-cancel-button {
+      display: none;
+    }
+  }
+}
+
+.emoji-mart-search-icon {
+  position: absolute;
+  top: 18px;
+  right: 45px + 5px;
+  z-index: 2;
+  padding: 2px 5px 1px;
+  border: 0;
+  background: none;
+  transition: all 100ms linear;
+  transition-property: opacity;
+  pointer-events: auto;
+  opacity: 0.7;
+
+  &:disabled {
+    cursor: default;
+    pointer-events: none;
+    opacity: 0.3;
+  }
+
+  svg {
+    fill: $action-button-color;
+  }
+}
+
+.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;
+  }
+}
+
+/* For screenreaders only, via https://stackoverflow.com/a/19758620 */
+.emoji-mart-sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  border: 0;
+}
+
+.emoji-mart-category-list {
+  margin: 0;
+  padding: 0;
+}
+
+.emoji-mart-category-list li {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: inline-block;
+}
+
+.emoji-mart-emoji {
+  position: relative;
+  display: inline-block;
+  background: transparent;
+  border: 0;
+  padding: 0;
+  font-size: 0;
+
+  span {
+    width: 22px;
+    height: 22px;
+  }
+}
+
+.emoji-mart-no-results {
+  font-size: 14px;
+  color: $light-text-color;
+  text-align: center;
+  padding: 5px 6px;
+  padding-top: 70px;
+
+  .emoji-mart-no-results-label {
+    margin-top: 0.2em;
+  }
+
+  .emoji-mart-emoji:hover::before {
+    cursor: default;
+    content: none;
+  }
+}
+
+.emoji-mart-preview {
+  display: none;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/error_boundary.scss b/app/javascript/flavours/glitch/styles/components/error_boundary.scss
new file mode 100644
index 000000000..3176690e2
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/error_boundary.scss
@@ -0,0 +1,30 @@
+.error-boundary {
+  color: $primary-text-color;
+  font-size: 15px;
+  line-height: 20px;
+
+  h1 {
+    font-size: 26px;
+    line-height: 36px;
+    font-weight: 400;
+    margin-bottom: 8px;
+  }
+
+  a {
+    color: $primary-text-color;
+    text-decoration: underline;
+  }
+
+  ul {
+    list-style: disc;
+    margin-left: 0;
+    padding-left: 1em;
+  }
+
+  textarea.web_app_crash-stacktrace {
+    width: 100%;
+    resize: none;
+    white-space: pre;
+    font-family: $font-monospace, monospace;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/explore.scss b/app/javascript/flavours/glitch/styles/components/explore.scss
new file mode 100644
index 000000000..bad77fc1c
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/explore.scss
@@ -0,0 +1,115 @@
+.account-card__header {
+  position: relative;
+}
+
+.explore__search-header {
+  background: darken($ui-base-color, 4%);
+  justify-content: center;
+  align-items: center;
+  padding: 15px;
+
+  .search {
+    width: 100%;
+    margin-bottom: 0;
+  }
+
+  .search__input {
+    border: 1px solid lighten($ui-base-color, 8%);
+    padding: 10px;
+  }
+
+  .search .fa {
+    top: 10px;
+    right: 10px;
+    color: $dark-text-color;
+  }
+
+  .search .fa-times-circle {
+    top: 12px;
+  }
+}
+
+.explore__search-results {
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+}
+
+.story {
+  display: flex;
+  align-items: center;
+  color: $primary-text-color;
+  text-decoration: none;
+  padding: 15px 0;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &:hover,
+  &:active,
+  &:focus {
+    background-color: lighten($ui-base-color, 4%);
+  }
+
+  &__details {
+    padding: 0 15px;
+    flex: 1 1 auto;
+
+    &__publisher {
+      color: $darker-text-color;
+      margin-bottom: 4px;
+    }
+
+    &__title {
+      font-size: 19px;
+      line-height: 24px;
+      font-weight: 500;
+      margin-bottom: 4px;
+    }
+
+    &__shared {
+      color: $darker-text-color;
+    }
+  }
+
+  &__thumbnail {
+    flex: 0 0 auto;
+    margin: 0 15px;
+    position: relative;
+    width: 120px;
+    height: 120px;
+
+    .skeleton {
+      width: 100%;
+      height: 100%;
+    }
+
+    img {
+      border-radius: 4px;
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    &__preview {
+      border-radius: 4px;
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: fill;
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 0;
+
+      &--hidden {
+        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..497b66b3e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -0,0 +1,26 @@
+@import 'misc';
+@import 'boost';
+@import 'accounts';
+@import 'domains';
+@import 'status';
+@import 'modal';
+@import 'compose_form';
+@import 'columns';
+@import 'regeneration_indicator';
+@import 'directory';
+@import 'search';
+@import 'emoji';
+@import 'doodle';
+@import 'drawer';
+@import 'media';
+@import 'sensitive';
+@import 'lists';
+@import 'emoji_picker';
+@import 'local_settings';
+@import 'error_boundary';
+@import 'single_column';
+@import 'announcements';
+@import 'explore';
+@import 'signed_out';
+@import 'privacy_policy';
+@import 'about';
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..d00a1941b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/lists.scss
@@ -0,0 +1,94 @@
+.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;
+  }
+}
+
+.list-adder {
+  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%;
+  }
+
+  &__account {
+    background: lighten($ui-base-color, 13%);
+  }
+
+  &__lists {
+    background: lighten($ui-base-color, 13%);
+    height: 50vh;
+    border-radius: 0 0 8px 8px;
+    overflow-y: auto;
+  }
+
+  .list {
+    padding: 10px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
+  .list__wrapper {
+    display: flex;
+  }
+
+  .list__display-name {
+    flex: 1 1 auto;
+    overflow: hidden;
+    text-decoration: none;
+    font-size: 16px;
+    padding: 10px;
+  }
+}
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..52516cfb5
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/local_settings.scss
@@ -0,0 +1,167 @@
+.glitch.local-settings {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  background: $ui-secondary-color;
+  color: $inverted-text-color;
+  border-radius: 8px;
+  height: 80vh;
+  width: 80vw;
+  max-width: 740px;
+  max-height: 450px;
+  overflow: hidden;
+
+  label,
+  legend {
+    display: block;
+    font-size: 14px;
+  }
+
+  .boolean label,
+  .radio_buttons label {
+    position: relative;
+    padding-left: 28px;
+    padding-top: 3px;
+
+    input {
+      position: absolute;
+      left: 0;
+      top: 0;
+    }
+  }
+
+  span.hint {
+    display: block;
+    color: $lighter-text-color;
+  }
+
+  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: lighten($ui-secondary-color, 8%);
+  border-bottom: 1px $ui-secondary-color solid;
+  cursor: pointer;
+  text-decoration: none;
+  outline: none;
+  transition: background 0.3s;
+
+  .text-icon-button {
+    color: inherit;
+    transition: unset;
+  }
+
+  &: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: lighten($ui-secondary-color, 8%);
+  width: 212px;
+  font-size: 15px;
+  line-height: 20px;
+  overflow-y: auto;
+}
+
+.glitch.local-settings__page {
+  display: block;
+  flex: auto;
+  padding: 15px 20px;
+  width: 360px;
+  overflow-y: auto;
+}
+
+.glitch.local-settings__page__item {
+  margin-bottom: 2px;
+
+  .hint a {
+    color: $lighter-text-color;
+    font-weight: 500;
+    text-decoration: underline;
+
+    &:active,
+    &:focus,
+    &:hover {
+      text-decoration: none;
+    }
+  }
+
+  #mastodon-settings--collapsed-auto-height {
+    width: calc(4ch + 20px);
+  }
+}
+
+.glitch.local-settings__page__item.string,
+.glitch.local-settings__page__item.radio_buttons {
+  margin-top: 10px;
+  margin-bottom: 10px;
+}
+
+@media screen and (max-width: 630px) {
+  .glitch.local-settings__navigation {
+    width: 40px;
+    flex-shrink: 0;
+  }
+
+  .glitch.local-settings__navigation__item {
+    padding: 10px;
+
+    span:last-of-type {
+      display: none;
+    }
+  }
+}
+
+.deprecated-settings-label {
+  white-space: nowrap;
+}
+
+.deprecated-settings-info {
+  text-align: start;
+
+  ul {
+    padding: 10px;
+    margin-left: 12px;
+    list-style: disc inside;
+  }
+
+  a {
+    color: $lighter-text-color;
+    font-weight: 500;
+    text-decoration: underline;
+
+    &:active,
+    &:focus,
+    &:hover {
+      text-decoration: none;
+    }
+  }
+}
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..6d6b8bc0e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -0,0 +1,799 @@
+.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: $darker-text-color;
+  border: 0;
+  width: 100%;
+  height: 100%;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: lighten($darker-text-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;
+  line-height: 18px;
+}
+
+.media-gallery__gifv {
+  &:hover {
+    .media-gallery__gifv__label {
+      opacity: 1;
+    }
+  }
+}
+
+.media-gallery {
+  box-sizing: border-box;
+  margin-top: 8px;
+  overflow: hidden;
+  border-radius: 4px;
+  position: relative;
+  width: 100%;
+  min-height: 64px;
+
+  @include fullwidth-gallery;
+}
+
+.media-gallery__item {
+  border: 0;
+  box-sizing: border-box;
+  display: block;
+  float: left;
+  position: relative;
+  border-radius: 4px;
+  overflow: hidden;
+
+  .full-width & {
+    border-radius: 0;
+  }
+
+  &.standalone {
+    .media-gallery__item-gifv-thumbnail {
+      transform: none;
+      top: 0;
+    }
+  }
+
+  &.letterbox {
+    background: $base-shadow-color;
+  }
+}
+
+.media-gallery__item-thumbnail {
+  cursor: zoom-in;
+  display: block;
+  text-decoration: none;
+  color: $secondary-text-color;
+  position: relative;
+  z-index: 1;
+
+  &,
+  img {
+    height: 100%;
+    width: 100%;
+    object-fit: contain;
+
+    &:not(.letterbox) {
+      height: 100%;
+      object-fit: cover;
+    }
+  }
+}
+
+.media-gallery__preview {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 0;
+  background: $base-overlay-background;
+
+  &--hidden {
+    display: none;
+  }
+}
+
+.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;
+  user-select: none;
+
+  &: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;
+}
+
+.video-modal__container {
+  max-width: 100vw;
+  max-height: 100vh;
+}
+
+.audio-modal__container {
+  width: 50vw;
+}
+
+.media-modal {
+  width: 100%;
+  height: 100%;
+  position: relative;
+
+  &__close,
+  &__zoom-button {
+    color: rgba($white, 0.7);
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: $white;
+      background-color: rgba($white, 0.15);
+    }
+
+    &:focus {
+      background-color: rgba($white, 0.3);
+    }
+  }
+}
+
+.media-modal__closer {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}
+
+.media-modal__navigation {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  pointer-events: none;
+  transition: opacity 0.3s linear;
+  will-change: opacity;
+
+  * {
+    pointer-events: auto;
+  }
+
+  &.media-modal__navigation--hidden {
+    opacity: 0;
+
+    * {
+      pointer-events: none;
+    }
+  }
+}
+
+.media-modal__nav {
+  background: transparent;
+  box-sizing: border-box;
+  border: 0;
+  color: rgba($primary-text-color, 0.7);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  font-size: 24px;
+  height: 20vmax;
+  margin: auto 0;
+  padding: 30px 15px;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+
+  &:hover,
+  &:focus,
+  &:active {
+    color: $primary-text-color;
+  }
+}
+
+.media-modal__nav--left {
+  left: 0;
+}
+
+.media-modal__nav--right {
+  right: 0;
+}
+
+.media-modal__overlay {
+  max-width: 600px;
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  margin: 0 auto;
+
+  .picture-in-picture__footer {
+    border-radius: 0;
+    background: transparent;
+    padding: 20px 0;
+
+    .icon-button {
+      color: $white;
+
+      &:hover,
+      &:focus,
+      &:active {
+        color: $white;
+        background-color: rgba($white, 0.15);
+      }
+
+      &:focus {
+        background-color: rgba($white, 0.3);
+      }
+
+      &.active {
+        color: $highlight-text-color;
+
+        &:hover,
+        &:focus,
+        &:active {
+          background: rgba($highlight-text-color, 0.15);
+        }
+
+        &:focus {
+          background: rgba($highlight-text-color, 0.3);
+        }
+      }
+
+      &.star-icon.active {
+        color: $gold-star;
+
+        &:hover,
+        &:focus,
+        &:active {
+          background: rgba($gold-star, 0.15);
+        }
+
+        &:focus {
+          background: rgba($gold-star, 0.3);
+        }
+      }
+
+      &.disabled {
+        color: $white;
+        background-color: transparent;
+        cursor: default;
+        opacity: 0.4;
+      }
+    }
+  }
+}
+
+.media-modal__pagination {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 20px;
+}
+
+.media-modal__page-dot {
+  flex: 0 0 auto;
+  background-color: $white;
+  opacity: 0.4;
+  height: 6px;
+  width: 6px;
+  border-radius: 50%;
+  margin: 0 4px;
+  padding: 0;
+  border: 0;
+  font-size: 0;
+  transition: opacity 0.2s ease-in-out;
+
+  &.active {
+    opacity: 1;
+  }
+}
+
+.media-modal__close {
+  position: absolute;
+  right: 8px;
+  top: 8px;
+  z-index: 100;
+}
+
+.detailed,
+.fullscreen {
+  .video-player__volume__current,
+  .video-player__volume::before {
+    bottom: 27px;
+  }
+
+  .video-player__volume__handle {
+    bottom: 23px;
+  }
+}
+
+.audio-player {
+  overflow: hidden;
+  box-sizing: border-box;
+  position: relative;
+  background: darken($ui-base-color, 8%);
+  border-radius: 4px;
+  padding-bottom: 44px;
+  direction: ltr;
+
+  &.editable {
+    border-radius: 0;
+    height: 100%;
+  }
+
+  &.inactive {
+    audio,
+    .video-player__controls {
+      visibility: hidden;
+    }
+  }
+
+  .video-player__volume::before,
+  .video-player__seek::before {
+    background: currentColor;
+    opacity: 0.15;
+  }
+
+  .video-player__seek__buffer {
+    background: currentColor;
+    opacity: 0.2;
+  }
+
+  .video-player__buttons button,
+  .video-player__buttons a {
+    color: currentColor;
+    opacity: 0.75;
+
+    &:active,
+    &:hover,
+    &:focus {
+      color: currentColor;
+      opacity: 1;
+    }
+  }
+
+  .video-player__time-sep,
+  .video-player__time-total,
+  .video-player__time-current {
+    color: currentColor;
+  }
+
+  .video-player__seek::before,
+  .video-player__seek__buffer,
+  .video-player__seek__progress {
+    top: 0;
+  }
+
+  .video-player__seek__handle {
+    top: -4px;
+  }
+
+  .video-player__controls {
+    padding-top: 10px;
+    background: transparent;
+  }
+}
+
+.video-player {
+  overflow: hidden;
+  position: relative;
+  background: $base-shadow-color;
+  max-width: 100%;
+  border-radius: 4px;
+  box-sizing: border-box;
+  direction: ltr;
+  color: $white;
+
+  &.editable {
+    border-radius: 0;
+    height: 100% !important;
+  }
+
+  &:focus {
+    outline: 0;
+  }
+
+  .detailed-status & {
+    width: 100%;
+    height: 100%;
+  }
+
+  @include fullwidth-gallery;
+
+  video {
+    display: block;
+    max-width: 100vw;
+    max-height: 80vh;
+    z-index: 1;
+    position: relative;
+  }
+
+  &.fullscreen {
+    width: 100% !important;
+    height: 100% !important;
+    margin: 0;
+
+    video {
+      max-width: 100% !important;
+      max-height: 100% !important;
+      width: 100% !important;
+      height: 100% !important;
+      outline: 0;
+    }
+  }
+
+  &.inline {
+    video {
+      object-fit: contain;
+      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 0.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: $darker-text-color;
+    transition: none;
+    pointer-events: none;
+
+    &.active {
+      display: block;
+      pointer-events: auto;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($darker-text-color, 7%);
+      }
+    }
+
+    &__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: 8px;
+    margin: 0 -5px;
+
+    .video-player__download__icon {
+      color: inherit;
+
+      .fa,
+      &:active .fa,
+      &:hover .fa,
+      &:focus .fa {
+        color: inherit;
+      }
+    }
+  }
+
+  &__buttons {
+    display: flex;
+    flex: 0 1 auto;
+    min-width: 30px;
+    align-items: center;
+    font-size: 16px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+
+    .player-button {
+      display: inline-block;
+      outline: 0;
+      flex: 0 0 auto;
+      background: transparent;
+      padding: 5px;
+      font-size: 16px;
+      border: 0;
+      color: rgba($white, 0.75);
+
+      &:active,
+      &:hover,
+      &:focus {
+        color: $white;
+      }
+    }
+  }
+
+  &__time {
+    display: inline;
+    flex: 0 1 auto;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    margin: 0 5px;
+  }
+
+  &__time-sep,
+  &__time-total,
+  &__time-current {
+    font-size: 14px;
+    font-weight: 500;
+  }
+
+  &__time-current {
+    color: $white;
+  }
+
+  &__time-sep {
+    display: inline-block;
+    margin: 0 6px;
+  }
+
+  &__time-sep,
+  &__time-total {
+    color: $white;
+  }
+
+  &__volume {
+    flex: 0 0 auto;
+    display: inline-flex;
+    cursor: pointer;
+    height: 24px;
+    position: relative;
+    overflow: hidden;
+
+    .no-reduce-motion & {
+      transition: all 100ms linear;
+    }
+
+    &.active {
+      overflow: visible;
+      width: 50px;
+      margin-right: 16px;
+    }
+
+    &::before {
+      content: '';
+      width: 50px;
+      background: rgba($white, 0.35);
+      border-radius: 4px;
+      display: block;
+      position: absolute;
+      height: 4px;
+      left: 0;
+      top: 50%;
+      transform: translate(0, -50%);
+    }
+
+    &__current {
+      display: block;
+      position: absolute;
+      height: 4px;
+      border-radius: 4px;
+      left: 0;
+      top: 50%;
+      transform: translate(0, -50%);
+      background: lighten($ui-highlight-color, 8%);
+    }
+
+    &__handle {
+      position: absolute;
+      z-index: 3;
+      border-radius: 50%;
+      width: 12px;
+      height: 12px;
+      top: 50%;
+      left: 0;
+      margin-left: -6px;
+      transform: translate(0, -50%);
+      background: lighten($ui-highlight-color, 8%);
+      box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+      opacity: 0;
+
+      .no-reduce-motion & {
+        transition: opacity 100ms linear;
+      }
+    }
+
+    &.active &__handle {
+      opacity: 1;
+    }
+  }
+
+  &__link {
+    padding: 2px 10px;
+
+    a {
+      text-decoration: none;
+      font-size: 14px;
+      font-weight: 500;
+      color: $white;
+
+      &:hover,
+      &:active,
+      &:focus {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  &__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: 14px;
+    }
+
+    &__progress,
+    &__buffer {
+      display: block;
+      position: absolute;
+      height: 4px;
+      border-radius: 4px;
+      top: 14px;
+      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: 10px;
+      margin-left: -6px;
+      background: lighten($ui-highlight-color, 8%);
+      box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+
+      .no-reduce-motion & {
+        transition: opacity 0.1s ease;
+      }
+
+      &.active {
+        opacity: 1;
+      }
+    }
+
+    &:hover {
+      .video-player__seek__handle {
+        opacity: 1;
+      }
+    }
+  }
+
+  &.detailed,
+  &.fullscreen {
+    .video-player__buttons {
+      .player-button {
+        padding-top: 10px;
+        padding-bottom: 10px;
+      }
+    }
+  }
+}
+
+.gifv {
+  video {
+    max-width: 100vw;
+    max-height: 80vh;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/misc.scss b/app/javascript/flavours/glitch/styles/components/misc.scss
new file mode 100644
index 000000000..86b8b99c1
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/misc.scss
@@ -0,0 +1,1814 @@
+.app-body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: -ms-autohiding-scrollbar;
+}
+
+.animated-number {
+  display: inline-flex;
+  flex-direction: column;
+  align-items: stretch;
+  overflow: hidden;
+  position: relative;
+}
+
+.link-button {
+  display: block;
+  font-size: 15px;
+  line-height: 20px;
+  color: $highlight-text-color;
+  border: 0;
+  background: transparent;
+  padding: 0;
+  cursor: pointer;
+  text-decoration: none;
+
+  &--destructive {
+    color: $error-value-color;
+  }
+
+  &:hover,
+  &:active {
+    text-decoration: underline;
+  }
+
+  &:disabled {
+    color: $ui-primary-color;
+    cursor: default;
+  }
+}
+
+.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: 15px;
+  font-weight: 500;
+  letter-spacing: 0;
+  line-height: 22px;
+  overflow: hidden;
+  padding: 7px 18px;
+  position: relative;
+  text-align: center;
+  text-decoration: none;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  width: auto;
+
+  &:active,
+  &:focus,
+  &:hover {
+    background-color: $ui-highlight-color;
+  }
+
+  &--destructive {
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: $error-red;
+      transition: none;
+    }
+  }
+
+  &:disabled {
+    background-color: $ui-primary-color;
+    cursor: default;
+  }
+
+  &.button-alternative {
+    color: $inverted-text-color;
+    background: $ui-primary-color;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-primary-color, 4%);
+    }
+  }
+
+  &.button-alternative-2 {
+    background: $ui-base-lighter-color;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-base-lighter-color, 4%);
+    }
+  }
+
+  &.button-secondary {
+    font-size: 16px;
+    line-height: 36px;
+    height: auto;
+    color: $darker-text-color;
+    text-transform: none;
+    background: transparent;
+    padding: 6px 17px;
+    border: 1px solid $ui-primary-color;
+
+    &:active,
+    &:focus,
+    &:hover {
+      border-color: lighten($ui-primary-color, 4%);
+      color: lighten($darker-text-color, 4%);
+      text-decoration: none;
+    }
+
+    &:disabled {
+      opacity: 0.5;
+    }
+  }
+
+  &.button-tertiary {
+    background: transparent;
+    padding: 6px 17px;
+    color: $highlight-text-color;
+    border: 1px solid $highlight-text-color;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background: $ui-highlight-color;
+      color: $primary-text-color;
+      border: 0;
+      padding: 7px 18px;
+    }
+
+    &:disabled {
+      opacity: 0.5;
+    }
+
+    &.button--confirmation {
+      color: $valid-value-color;
+      border-color: $valid-value-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        background: $valid-value-color;
+        color: $primary-text-color;
+      }
+    }
+
+    &.button--destructive {
+      color: $error-value-color;
+      border-color: $error-value-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        background: $error-value-color;
+        color: $primary-text-color;
+      }
+    }
+  }
+
+  &.button--block {
+    display: block;
+    width: 100%;
+  }
+
+  .layout-multiple-columns &.button--with-bell {
+    font-size: 12px;
+    padding: 0 8px;
+  }
+}
+
+.icon-button {
+  display: inline-block;
+  padding: 0;
+  color: $action-button-color;
+  border: 0;
+  border-radius: 4px;
+  background: transparent;
+  cursor: pointer;
+  transition: all 100ms ease-in;
+  transition-property: background-color, color;
+  text-decoration: none;
+
+  a {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: lighten($action-button-color, 7%);
+    background-color: rgba($action-button-color, 0.15);
+    transition: all 200ms ease-out;
+    transition-property: background-color, color;
+  }
+
+  &:focus {
+    background-color: rgba($action-button-color, 0.3);
+  }
+
+  &.disabled {
+    color: darken($action-button-color, 13%);
+    background-color: transparent;
+    cursor: default;
+  }
+
+  &.active {
+    color: $highlight-text-color;
+  }
+
+  &.copyable {
+    transition: background 300ms linear;
+  }
+
+  &.copied {
+    background: $valid-value-color;
+    transition: none;
+  }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus,
+  &:active {
+    outline: 0 !important;
+  }
+
+  &.inverted {
+    color: $lighter-text-color;
+
+    &:hover,
+    &:active,
+    &:focus {
+      color: darken($lighter-text-color, 7%);
+      background-color: rgba($lighter-text-color, 0.15);
+    }
+
+    &:focus {
+      background-color: rgba($lighter-text-color, 0.3);
+    }
+
+    &.disabled {
+      color: lighten($lighter-text-color, 7%);
+      background-color: transparent;
+    }
+
+    &.active {
+      color: $highlight-text-color;
+
+      &.disabled {
+        color: lighten($highlight-text-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);
+    }
+  }
+
+  &--with-counter {
+    display: inline-flex;
+    align-items: center;
+    width: auto !important;
+    padding: 0 4px 0 2px;
+  }
+
+  &__counter {
+    display: inline-block;
+    width: auto;
+    margin-left: 4px;
+    font-size: 12px;
+    font-weight: 500;
+  }
+}
+
+.text-icon,
+.text-icon-button {
+  font-weight: 600;
+  font-size: 11px;
+  line-height: 27px;
+  cursor: default;
+}
+
+.text-icon-button {
+  color: $lighter-text-color;
+  border: 0;
+  border-radius: 4px;
+  background: transparent;
+  cursor: pointer;
+  padding: 0 3px;
+  outline: 0;
+  transition: all 100ms ease-in;
+  transition-property: background-color, color;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: darken($lighter-text-color, 7%);
+    background-color: rgba($lighter-text-color, 0.15);
+    transition: all 200ms ease-out;
+    transition-property: background-color, color;
+  }
+
+  &:focus {
+    background-color: rgba($lighter-text-color, 0.3);
+  }
+
+  &.disabled {
+    color: lighten($lighter-text-color, 20%);
+    background-color: transparent;
+    cursor: default;
+  }
+
+  &.active {
+    color: $highlight-text-color;
+  }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus,
+  &:active {
+    outline: 0 !important;
+  }
+}
+
+body > [data-popper-placement] {
+  z-index: 3;
+}
+
+.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: '…';
+  }
+}
+
+.notification__favourite-icon-wrapper {
+  left: 0;
+  position: absolute;
+
+  .fa.star-icon {
+    color: $gold-star;
+  }
+}
+
+.icon-button.star-icon.active {
+  color: $gold-star;
+}
+
+.icon-button.bookmark-icon.active {
+  color: $red-bookmark;
+}
+
+.no-reduce-motion .icon-button.star-icon {
+  &.activate {
+    & > .fa-star {
+      animation: spring-rotate-in 1s linear;
+    }
+  }
+
+  &.deactivate {
+    & > .fa-star {
+      animation: spring-rotate-out 1s linear;
+    }
+  }
+}
+
+.notification__display-name {
+  color: inherit;
+  font-weight: 500;
+  text-decoration: none;
+
+  &:hover {
+    color: $primary-text-color;
+    text-decoration: underline;
+  }
+}
+
+.display-name {
+  display: block;
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+
+  a {
+    color: inherit;
+    text-decoration: inherit;
+  }
+
+  strong {
+    display: block;
+  }
+
+  > a: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;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  scrollbar-width: none; /* Firefox */
+  -ms-overflow-style: none; /* IE 10+ */
+
+  * {
+    scrollbar-width: none; /* Firefox */
+    -ms-overflow-style: none; /* IE 10+ */
+  }
+
+  &::-webkit-scrollbar,
+  *::-webkit-scrollbar {
+    width: 0;
+    height: 0;
+    background: transparent; /* Chrome/Safari/Webkit */
+  }
+
+  .image-loader__preview-canvas {
+    max-width: $media-modal-media-max-width;
+    max-height: $media-modal-media-max-height;
+    background: url('~images/void.png') repeat;
+    object-fit: contain;
+  }
+
+  .loading-bar__container {
+    position: relative;
+  }
+
+  .loading-bar {
+    position: absolute;
+  }
+
+  &.image-loader--amorphous .image-loader__preview-canvas {
+    display: none;
+  }
+}
+
+.zoomable-image {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  img {
+    max-width: $media-modal-media-max-width;
+    max-height: $media-modal-media-max-height;
+    width: auto;
+    height: auto;
+    object-fit: contain;
+  }
+}
+
+.dropdown-animation {
+  animation: dropdown 300ms cubic-bezier(0.1, 0.7, 0.1, 1);
+
+  @keyframes dropdown {
+    from {
+      opacity: 0;
+      transform: scaleX(0.85) scaleY(0.75);
+    }
+
+    to {
+      opacity: 1;
+      transform: scaleX(1) scaleY(1);
+    }
+  }
+
+  &.top {
+    transform-origin: bottom;
+  }
+
+  &.right {
+    transform-origin: left;
+  }
+
+  &.bottom {
+    transform-origin: top;
+  }
+
+  &.left {
+    transform-origin: right;
+  }
+
+  .reduce-motion & {
+    animation: none;
+  }
+}
+
+.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);
+  z-index: 9999;
+
+  &__text-button {
+    display: inline;
+    color: inherit;
+    background: transparent;
+    border: 0;
+    margin: 0;
+    padding: 0;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+
+    &:focus {
+      outline: 1px dotted;
+    }
+  }
+
+  &__container {
+    &__header {
+      border-bottom: 1px solid darken($ui-secondary-color, 8%);
+      padding: 4px 14px;
+      padding-bottom: 8px;
+      font-size: 13px;
+      line-height: 18px;
+      color: $inverted-text-color;
+    }
+
+    &__list {
+      list-style: none;
+
+      &--scrollable {
+        max-height: 300px;
+        overflow-y: scroll;
+      }
+    }
+
+    &--loading {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 30px 45px;
+    }
+  }
+}
+
+.dropdown-menu__arrow {
+  position: absolute;
+
+  &::before {
+    content: '';
+    display: block;
+    width: 14px;
+    height: 5px;
+    background-color: $ui-secondary-color;
+    mask-image: url("data:image/svg+xml;utf8,<svg width='14' height='5' xmlns='http://www.w3.org/2000/svg'><path d='M7 0L0 5h14L7 0z' fill='white'/></svg>");
+  }
+
+  &.top {
+    bottom: -5px;
+
+    &::before {
+      transform: rotate(180deg);
+    }
+  }
+
+  &.right {
+    left: -9px;
+
+    &::before {
+      transform: rotate(-90deg);
+    }
+  }
+
+  &.bottom {
+    top: -5px;
+  }
+
+  &.left {
+    right: -9px;
+
+    &::before {
+      transform: rotate(90deg);
+    }
+  }
+}
+
+.dropdown-menu__item {
+  font-size: 13px;
+  line-height: 18px;
+  display: block;
+  color: $inverted-text-color;
+
+  a,
+  button {
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+    display: block;
+    width: 100%;
+    padding: 4px 14px;
+    border: 0;
+    margin: 0;
+    box-sizing: border-box;
+    text-decoration: none;
+    background: $ui-secondary-color;
+    color: inherit;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    text-align: inherit;
+
+    &:focus,
+    &:hover,
+    &:active {
+      background: $ui-highlight-color;
+      color: $secondary-text-color;
+      outline: 0;
+    }
+  }
+}
+
+.dropdown-menu__item--text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  padding: 4px 14px;
+}
+
+.dropdown-menu__item.edited-timestamp__history__item {
+  border-bottom: 1px solid darken($ui-secondary-color, 8%);
+
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &.dropdown-menu__item--text,
+  a,
+  button {
+    padding: 8px 14px;
+  }
+}
+
+.inline-account {
+  display: inline-flex;
+  align-items: center;
+  vertical-align: top;
+
+  .account__avatar {
+    margin-right: 5px;
+    border-radius: 50%;
+  }
+
+  strong {
+    font-weight: 600;
+  }
+}
+
+.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: $inverted-text-color;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:focus {
+      outline: 0;
+    }
+
+    &:hover {
+      background: $ui-highlight-color;
+      color: $secondary-text-color;
+    }
+  }
+}
+
+.dropdown__icon {
+  vertical-align: middle;
+}
+
+.static-content {
+  padding: 10px;
+  padding-top: 20px;
+  color: $dark-text-color;
+
+  h1 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 40px;
+    text-align: center;
+  }
+
+  p {
+    font-size: 13px;
+    margin-bottom: 20px;
+  }
+}
+
+.column,
+.drawer {
+  flex: 1 1 100%;
+  overflow: hidden;
+}
+
+@media screen and (min-width: 631px) {
+  .columns-area {
+    padding: 0;
+  }
+
+  .column,
+  .drawer {
+    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,
+    .drawer {
+      padding-left: 5px;
+      padding-right: 5px;
+    }
+  }
+}
+
+.tabs-bar {
+  box-sizing: border-box;
+  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;
+  padding-bottom: 13px;
+  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 50ms linear;
+  transition-property: border-bottom, background, color;
+
+  .fa {
+    font-weight: 400;
+    font-size: 16px;
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    @include multi-columns('screen and (min-width: 631px)') {
+      background: lighten($ui-base-color, 14%);
+      border-bottom-color: lighten($ui-base-color, 14%);
+    }
+  }
+
+  &.active {
+    border-bottom: 2px solid $ui-highlight-color;
+    color: $highlight-text-color;
+  }
+
+  span {
+    margin-left: 5px;
+    display: none;
+  }
+
+  span.icon {
+    margin-left: 0;
+    display: inline;
+  }
+}
+
+.icon-with-badge {
+  position: relative;
+
+  &__badge {
+    position: absolute;
+    left: 9px;
+    top: -13px;
+    background: $ui-highlight-color;
+    border: 2px solid lighten($ui-base-color, 8%);
+    padding: 1px 6px;
+    border-radius: 6px;
+    font-size: 10px;
+    font-weight: 500;
+    line-height: 14px;
+    color: $primary-text-color;
+  }
+
+  &__issue-badge {
+    position: absolute;
+    left: 11px;
+    bottom: 1px;
+    display: block;
+    background: $error-red;
+    border-radius: 50%;
+    width: 0.625rem;
+    height: 0.625rem;
+  }
+}
+
+.column-link--transparent .icon-with-badge__badge {
+  border-color: darken($ui-base-color, 8%);
+}
+
+.scrollable {
+  overflow-y: scroll;
+  overflow-x: hidden;
+  flex: 1 1 auto;
+  -webkit-overflow-scrolling: touch;
+
+  &.optionally-scrollable {
+    overflow-y: auto;
+  }
+
+  @supports (display: grid) {
+    // hack to fix Chrome <57
+    contain: strict;
+  }
+
+  &--flex {
+    display: flex;
+    flex-direction: column;
+  }
+
+  &__append {
+    flex: 1 1 auto;
+    position: relative;
+    min-height: 120px;
+  }
+
+  .scrollable {
+    flex: 1 1 auto;
+  }
+}
+
+.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: background-color 0.2s ease;
+}
+
+.react-toggle:is(:hover, :focus-within):not(.react-toggle--disabled)
+  .react-toggle-track {
+  background-color: darken($ui-base-color, 10%);
+}
+
+.react-toggle--checked .react-toggle-track {
+  background-color: darken($ui-highlight-color, 2%);
+}
+
+.react-toggle--checked:is(:hover, :focus-within):not(.react-toggle--disabled)
+  .react-toggle-track {
+  background-color: $ui-highlight-color;
+}
+
+.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 {
+  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;
+  transition-property: border-color, left;
+}
+
+.react-toggle--checked .react-toggle-thumb {
+  left: 27px;
+  border-color: $ui-highlight-color;
+}
+
+.getting-started__wrapper,
+.getting_started,
+.flex-spacer {
+  background: $ui-base-color;
+}
+
+.getting-started__wrapper {
+  position: relative;
+  overflow-y: auto;
+}
+
+.flex-spacer {
+  flex: 1 1 auto;
+}
+
+.getting-started {
+  background: $ui-base-color;
+  flex: 1 0 auto;
+
+  p {
+    color: $secondary-text-color;
+  }
+
+  a {
+    color: $dark-text-color;
+  }
+
+  &__trends {
+    flex: 0 1 auto;
+    opacity: 1;
+    animation: fade 150ms linear;
+    margin-top: 10px;
+
+    h4 {
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+      padding: 10px;
+      font-size: 12px;
+      text-transform: uppercase;
+      font-weight: 500;
+
+      a {
+        color: $darker-text-color;
+        text-decoration: none;
+      }
+    }
+
+    @media screen and (max-height: 810px) {
+      .trends__item:nth-of-type(3) {
+        display: none;
+      }
+    }
+
+    @media screen and (max-height: 720px) {
+      .trends__item:nth-of-type(2) {
+        display: none;
+      }
+    }
+
+    @media screen and (max-height: 670px) {
+      display: none;
+    }
+
+    .trends__item {
+      border-bottom: 0;
+      padding: 10px;
+
+      &__current {
+        color: $darker-text-color;
+      }
+    }
+  }
+}
+
+.column-link__badge {
+  display: inline-block;
+  border-radius: 4px;
+  font-size: 12px;
+  line-height: 19px;
+  font-weight: 500;
+  background: $ui-base-color;
+  padding: 4px 8px;
+  margin: -6px 10px;
+}
+
+.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: $darker-text-color;
+  background: transparent;
+  border: 0;
+  border-bottom: 2px solid $ui-primary-color;
+  outline: 0;
+  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: $inverted-text-color;
+    border-bottom: 2px solid lighten($ui-base-color, 27%);
+
+    &:focus,
+    &:active {
+      color: $inverted-text-color;
+      border-bottom-color: $ui-highlight-color;
+    }
+  }
+}
+
+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;
+  }
+}
+
+button.icon-button.active i.fa-retweet {
+  transition-duration: 0.9s;
+  background-position: 0 100%;
+}
+
+.reduce-motion button.icon-button i.fa-retweet,
+.reduce-motion button.icon-button.active i.fa-retweet {
+  transition: none;
+}
+
+.reduce-motion button.icon-button.disabled i.fa-retweet {
+  color: darken($action-button-color, 13%);
+}
+
+.load-more {
+  display: block;
+  color: $dark-text-color;
+  background-color: transparent;
+  border: 0;
+  font-size: inherit;
+  text-align: center;
+  line-height: inherit;
+  margin: 0;
+  padding: 15px;
+  box-sizing: border-box;
+  width: 100%;
+  clear: both;
+  text-decoration: none;
+
+  &:hover {
+    background: lighten($ui-base-color, 2%);
+  }
+}
+
+.load-gap {
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
+.timeline-hint {
+  text-align: center;
+  color: $darker-text-color;
+  padding: 15px;
+  box-sizing: border-box;
+  width: 100%;
+  cursor: default;
+
+  strong {
+    font-weight: 500;
+  }
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+      color: lighten($highlight-text-color, 4%);
+    }
+  }
+}
+
+.missing-indicator {
+  padding-top: 20px + 48px;
+
+  .regeneration-indicator__figure {
+    background-image: url('~flavours/glitch/images/elephant_ui_disappointed.svg');
+  }
+}
+
+.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: $darker-text-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: $dark-text-color;
+  font-size: 12px;
+  font-weight: 400;
+  text-transform: uppercase;
+  overflow: visible;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.circular-progress {
+  color: lighten($ui-base-color, 26%);
+  animation: 1.4s linear 0s infinite normal none running simple-rotate;
+
+  circle {
+    stroke: currentColor;
+    stroke-dasharray: 80px, 200px;
+    stroke-dashoffset: 0;
+    animation: circular-progress 1.4s ease-in-out infinite;
+  }
+}
+
+@keyframes circular-progress {
+  0% {
+    stroke-dasharray: 1px, 200px;
+    stroke-dashoffset: 0;
+  }
+
+  50% {
+    stroke-dasharray: 100px, 200px;
+    stroke-dashoffset: -15px;
+  }
+
+  100% {
+    stroke-dasharray: 100px, 200px;
+    stroke-dashoffset: -125px;
+  }
+}
+
+@keyframes simple-rotate {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+@keyframes spring-rotate-in {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  30% {
+    transform: rotate(-484.8deg);
+  }
+
+  60% {
+    transform: rotate(-316.7deg);
+  }
+
+  90% {
+    transform: rotate(-375deg);
+  }
+
+  100% {
+    transform: rotate(-360deg);
+  }
+}
+
+@keyframes spring-rotate-out {
+  0% {
+    transform: rotate(-360deg);
+  }
+
+  30% {
+    transform: rotate(124.8deg);
+  }
+
+  60% {
+    transform: rotate(-43.27deg);
+  }
+
+  90% {
+    transform: rotate(15deg);
+  }
+
+  100% {
+    transform: rotate(0deg);
+  }
+}
+
+.spoiler-button {
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  z-index: 100;
+
+  &--minified {
+    display: flex;
+    left: 4px;
+    top: 4px;
+    width: auto;
+    height: auto;
+    align-items: center;
+  }
+
+  &--click-thru {
+    pointer-events: none;
+  }
+
+  &--hidden {
+    display: none;
+  }
+
+  &__overlay {
+    display: block;
+    background: transparent;
+    width: 100%;
+    height: 100%;
+    border: 0;
+
+    &__label {
+      display: inline-block;
+      background: rgba($base-overlay-background, 0.5);
+      border-radius: 8px;
+      padding: 8px 12px;
+      color: $primary-text-color;
+      font-weight: 500;
+      font-size: 14px;
+    }
+
+    &:hover,
+    &:focus,
+    &:active {
+      .spoiler-button__overlay__label {
+        background: rgba($base-overlay-background, 0.8);
+      }
+    }
+
+    &:disabled {
+      .spoiler-button__overlay__label {
+        background: rgba($base-overlay-background, 0.5);
+      }
+    }
+  }
+}
+
+.setting-toggle {
+  display: block;
+  line-height: 24px;
+}
+
+.setting-toggle__label,
+.setting-meta__label {
+  color: $darker-text-color;
+  display: inline-block;
+  margin-bottom: 14px;
+  margin-left: 8px;
+  vertical-align: middle;
+}
+
+.column-settings__row .radio-button {
+  display: block;
+}
+
+.setting-meta__label {
+  float: right;
+}
+
+@keyframes heartbeat {
+  0% {
+    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: 100vh;
+  justify-content: center;
+  left: 0;
+  opacity: 0;
+  position: fixed;
+  top: 0;
+  visibility: hidden;
+  width: 100vw;
+  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;
+  text-align: center;
+  color: $secondary-text-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: fixed;
+  top: 0;
+  left: 0;
+  z-index: 9999;
+}
+
+.icon-badge-wrapper {
+  position: relative;
+}
+
+.icon-badge {
+  position: absolute;
+  display: block;
+  right: -0.25em;
+  top: -0.25em;
+  background-color: $ui-highlight-color;
+  border-radius: 50%;
+  font-size: 75%;
+  width: 1em;
+  height: 1em;
+}
+
+.conversation {
+  display: flex;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  padding: 5px;
+  padding-bottom: 0;
+
+  &:focus {
+    background: lighten($ui-base-color, 2%);
+    outline: 0;
+  }
+
+  &__avatar {
+    flex: 0 0 auto;
+    padding: 10px;
+    padding-top: 12px;
+    position: relative;
+    cursor: pointer;
+  }
+
+  &__unread {
+    display: inline-block;
+    background: $highlight-text-color;
+    border-radius: 50%;
+    width: 0.625rem;
+    height: 0.625rem;
+    margin: -0.1ex 0.15em 0.1ex;
+  }
+
+  &__content {
+    flex: 1 1 auto;
+    padding: 10px 5px;
+    padding-right: 15px;
+    overflow: hidden;
+
+    &__info {
+      overflow: hidden;
+      display: flex;
+      flex-direction: row-reverse;
+      justify-content: space-between;
+    }
+
+    &__relative-time {
+      font-size: 15px;
+      color: $darker-text-color;
+      padding-left: 15px;
+    }
+
+    &__names {
+      color: $darker-text-color;
+      font-size: 15px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      margin-bottom: 4px;
+      flex-basis: 90px;
+      flex-grow: 1;
+
+      a {
+        color: $primary-text-color;
+        text-decoration: none;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+    }
+
+    .status__content {
+      margin: 0;
+    }
+  }
+
+  &--unread {
+    background: lighten($ui-base-color, 2%);
+
+    &:focus {
+      background: lighten($ui-base-color, 4%);
+    }
+
+    .conversation__content__info {
+      font-weight: 700;
+    }
+
+    .conversation__content__relative-time {
+      color: $primary-text-color;
+    }
+  }
+}
+
+.ui .flash-message {
+  margin-top: 10px;
+  margin-left: auto;
+  margin-right: auto;
+  margin-bottom: 0;
+  min-width: 75%;
+}
+
+::-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: $secondary-text-color;
+    max-width: 400px;
+
+    a {
+      color: $highlight-text-color;
+      text-decoration: underline;
+
+      &:hover {
+        text-decoration: none;
+      }
+    }
+
+    a {
+      word-break: break-word;
+    }
+  }
+}
+
+@keyframes flicker {
+  0% {
+    opacity: 1;
+  }
+
+  30% {
+    opacity: 0.75;
+  }
+
+  100% {
+    opacity: 1;
+  }
+}
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..65060f422
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -0,0 +1,1422 @@
+.modal-container--preloader {
+  background: lighten($ui-base-color, 8%);
+}
+
+.modal-root {
+  position: relative;
+  z-index: 9999;
+}
+
+.modal-root__overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba($base-overlay-background, 0.7);
+  transition: background 0.5s;
+}
+
+.modal-root__container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  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;
+}
+
+.media-modal__zoom-button {
+  position: absolute;
+  right: 64px;
+  top: 8px;
+  z-index: 100;
+  pointer-events: auto;
+  transition: opacity 0.3s linear;
+  will-change: opacity;
+}
+
+.media-modal__zoom-button--hidden {
+  pointer-events: none;
+  opacity: 0;
+}
+
+.onboarding-modal,
+.error-modal,
+.embed-modal {
+  background: $ui-secondary-color;
+  color: $inverted-text-color;
+  border-radius: 8px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.onboarding-modal__pager {
+  height: 80vh;
+  width: 80vw;
+  max-width: 520px;
+  max-height: 470px;
+
+  .react-swipeable-view-container > div {
+    width: 100%;
+    height: 100%;
+    box-sizing: border-box;
+    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;
+    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: $lighter-text-color;
+    border: 0;
+    font-size: 14px;
+    font-weight: 500;
+    padding: 10px 25px;
+    line-height: inherit;
+    height: auto;
+    margin: -10px;
+    border-radius: 4px;
+    background-color: transparent;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: darken($lighter-text-color, 4%);
+      background-color: darken($ui-secondary-color, 16%);
+    }
+
+    &.onboarding-modal__done,
+    &.onboarding-modal__next {
+      color: $inverted-text-color;
+
+      &:hover,
+      &:focus,
+      &:active {
+        color: lighten($inverted-text-color, 4%);
+      }
+    }
+  }
+}
+
+.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;
+  padding: 25px;
+  padding-bottom: 0;
+
+  &.onboarding-modal__page__wrapper--active {
+    pointer-events: auto;
+  }
+}
+
+.onboarding-modal__page {
+  cursor: default;
+  line-height: 21px;
+
+  h1 {
+    font-size: 18px;
+    font-weight: 500;
+    color: $inverted-text-color;
+    margin-bottom: 20px;
+  }
+
+  a {
+    color: $highlight-text-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: lighten($highlight-text-color, 4%);
+    }
+  }
+
+  .navigation-bar a {
+    color: inherit;
+  }
+
+  p {
+    font-size: 16px;
+    color: $lighter-text-color;
+    margin-top: 10px;
+    margin-bottom: 10px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    strong {
+      font-weight: 500;
+      background: $ui-base-color;
+      color: $secondary-text-color;
+      border-radius: 4px;
+      font-size: 14px;
+      padding: 3px 6px;
+
+      @each $lang in $cjk-langs {
+        &:lang(#{$lang}) {
+          font-weight: 700;
+        }
+      }
+    }
+  }
+}
+
+.onboarding-modal__page__wrapper-0 {
+  background: url('~images/elephant_ui_greeting.svg') no-repeat left bottom /
+    auto 250px;
+  height: 100%;
+  padding: 0;
+}
+
+.onboarding-modal__page-one {
+  &__lead {
+    padding: 65px;
+    padding-top: 45px;
+    padding-bottom: 0;
+    margin-bottom: 10px;
+
+    h1 {
+      font-size: 26px;
+      line-height: 36px;
+      margin-bottom: 8px;
+    }
+
+    p {
+      margin-bottom: 0;
+    }
+  }
+
+  &__extra {
+    padding-right: 65px;
+    padding-left: 185px;
+    text-align: center;
+  }
+}
+
+.display-case {
+  text-align: center;
+  font-size: 15px;
+  margin-bottom: 15px;
+
+  &__label {
+    font-weight: 500;
+    color: $inverted-text-color;
+    margin-bottom: 5px;
+    text-transform: uppercase;
+    font-size: 12px;
+  }
+
+  &__case {
+    background: $ui-base-color;
+    color: $secondary-text-color;
+    font-weight: 500;
+    padding: 10px;
+    border-radius: 4px;
+  }
+}
+
+.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: $secondary-text-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;
+  }
+}
+
+.onboard-sliders {
+  display: inline-block;
+  max-width: 30px;
+  max-height: auto;
+  margin-left: 10px;
+}
+
+.boost-modal,
+.confirmation-modal,
+.report-modal,
+.actions-modal,
+.mute-modal,
+.block-modal,
+.compare-history-modal {
+  background: lighten($ui-secondary-color, 8%);
+  color: $inverted-text-color;
+  border-radius: 8px;
+  overflow: hidden;
+  max-width: 90vw;
+  width: 480px;
+  position: relative;
+  flex-direction: column;
+
+  .status__relative-time {
+    color: $dark-text-color;
+    float: right;
+    font-size: 14px;
+    width: auto;
+    margin: initial;
+    padding: initial;
+  }
+
+  .status__visibility-icon {
+    color: $dark-text-color;
+    font-size: 14px;
+    padding: 0 4px;
+  }
+
+  .status__display-name {
+    display: flex;
+  }
+
+  .status__avatar {
+    height: 48px;
+    width: 48px;
+  }
+
+  .status__content__spoiler-link {
+    color: lighten($secondary-text-color, 8%);
+  }
+}
+
+.boost-modal .status-direct {
+  background-color: inherit;
+}
+
+.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 {
+  overflow-x: scroll;
+  padding: 10px;
+
+  .status {
+    user-select: text;
+    border-bottom: 0;
+  }
+}
+
+.boost-modal__action-bar,
+.confirmation-modal__action-bar,
+.mute-modal__action-bar,
+.block-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: $lighter-text-color;
+    padding-right: 10px;
+  }
+
+  .button {
+    flex: 0 0 auto;
+  }
+}
+
+.boost-modal__status-header {
+  font-size: 15px;
+}
+
+.boost-modal__status-time {
+  float: right;
+  font-size: 14px;
+}
+
+.mute-modal,
+.block-modal {
+  line-height: 24px;
+}
+
+.mute-modal .react-toggle,
+.block-modal .react-toggle {
+  vertical-align: middle;
+}
+
+.report-modal {
+  width: 90vw;
+  max-width: 700px;
+}
+
+.report-dialog-modal {
+  max-width: 90vw;
+  width: 480px;
+  height: 80vh;
+  background: lighten($ui-secondary-color, 8%);
+  color: $inverted-text-color;
+  border-radius: 8px;
+  overflow: hidden;
+  position: relative;
+  flex-direction: column;
+  display: flex;
+
+  &__container {
+    box-sizing: border-box;
+    border-top: 1px solid $ui-secondary-color;
+    padding: 20px;
+    flex-grow: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    overflow: auto;
+  }
+
+  &__title {
+    font-size: 28px;
+    line-height: 33px;
+    font-weight: 700;
+    margin-bottom: 15px;
+
+    @media screen and (max-height: 800px) {
+      font-size: 22px;
+    }
+  }
+
+  &__subtitle {
+    font-size: 17px;
+    font-weight: 600;
+    line-height: 22px;
+    margin-bottom: 4px;
+  }
+
+  &__lead {
+    font-size: 17px;
+    line-height: 22px;
+    color: lighten($inverted-text-color, 16%);
+    margin-bottom: 30px;
+
+    a {
+      text-decoration: none;
+      color: $inverted-text-color;
+      font-weight: 500;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  &__actions {
+    margin-top: 30px;
+    display: flex;
+
+    .button {
+      flex: 1 1 auto;
+    }
+  }
+
+  &__statuses {
+    flex-grow: 1;
+    min-height: 0;
+    overflow: auto;
+  }
+
+  .status__content a {
+    color: $highlight-text-color;
+  }
+
+  .status__content,
+  .status__content p {
+    color: $inverted-text-color;
+  }
+
+  .status__content__spoiler-link {
+    color: $primary-text-color;
+    background: $ui-primary-color;
+
+    &:hover {
+      background: lighten($ui-primary-color, 8%);
+    }
+  }
+
+  .dialog-option .poll__input {
+    border-color: $inverted-text-color;
+    color: $ui-secondary-color;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+
+    svg {
+      width: 8px;
+      height: auto;
+    }
+
+    &:active,
+    &:focus,
+    &:hover {
+      border-color: lighten($inverted-text-color, 15%);
+      border-width: 4px;
+    }
+
+    &.active {
+      border-color: $inverted-text-color;
+      background: $inverted-text-color;
+    }
+  }
+
+  .poll__option.dialog-option {
+    padding: 15px 0;
+    flex: 0 0 auto;
+    border-bottom: 1px solid $ui-secondary-color;
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    & > .poll__option__text {
+      font-size: 13px;
+      color: lighten($inverted-text-color, 16%);
+
+      strong {
+        font-size: 17px;
+        font-weight: 500;
+        line-height: 22px;
+        color: $inverted-text-color;
+        display: block;
+        margin-bottom: 4px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+  }
+
+  .flex-spacer {
+    background: transparent;
+  }
+
+  &__textarea {
+    display: block;
+    box-sizing: border-box;
+    width: 100%;
+    color: $inverted-text-color;
+    background: $simple-background-color;
+    padding: 10px;
+    font-family: inherit;
+    font-size: 17px;
+    line-height: 22px;
+    resize: vertical;
+    border: 0;
+    outline: 0;
+    border-radius: 4px;
+    margin: 20px 0;
+
+    &::placeholder {
+      color: $dark-text-color;
+    }
+
+    &:focus {
+      outline: 0;
+    }
+  }
+
+  &__toggle {
+    display: flex;
+    align-items: center;
+
+    & > span {
+      font-size: 17px;
+      font-weight: 500;
+      margin-left: 10px;
+    }
+  }
+
+  .button.button-secondary {
+    border-color: $inverted-text-color;
+    color: $inverted-text-color;
+    flex: 0 0 auto;
+
+    &:hover,
+    &:focus,
+    &:active {
+      border-color: lighten($inverted-text-color, 15%);
+      color: lighten($inverted-text-color, 15%);
+    }
+  }
+
+  hr {
+    border: 0;
+    background: transparent;
+    margin: 15px 0;
+  }
+
+  .emoji-mart-search {
+    padding-right: 10px;
+  }
+
+  .emoji-mart-search-icon {
+    right: 10px + 5px;
+  }
+}
+
+.report-modal__container {
+  display: flex;
+  border-top: 1px solid $ui-secondary-color;
+
+  @media screen and (max-width: 480px) {
+    flex-wrap: wrap;
+    overflow-y: auto;
+  }
+}
+
+.report-modal__statuses,
+.report-modal__comment {
+  box-sizing: border-box;
+  width: 50%;
+
+  @media screen and (max-width: 480px) {
+    width: 100%;
+  }
+}
+
+.report-modal__statuses,
+.focal-point-modal__content {
+  flex: 1 1 auto;
+  min-height: 20vh;
+  max-height: 80vh;
+  overflow-y: auto;
+  overflow-x: hidden;
+
+  .status__content a {
+    color: $highlight-text-color;
+  }
+
+  @media screen and (max-width: 480px) {
+    max-height: 10vh;
+  }
+}
+
+.focal-point-modal__content {
+  @media screen and (max-width: 480px) {
+    max-height: 40vh;
+  }
+}
+
+.setting-divider {
+  background: transparent;
+  border: 0;
+  margin: 0;
+  width: 100%;
+  height: 1px;
+  margin-bottom: 29px;
+}
+
+.report-modal__comment {
+  padding: 20px;
+  border-right: 1px solid $ui-secondary-color;
+  max-width: 320px;
+
+  p {
+    font-size: 14px;
+    line-height: 20px;
+    margin-bottom: 20px;
+  }
+
+  .setting-text {
+    display: block;
+    box-sizing: border-box;
+    width: 100%;
+    margin: 0;
+    color: $inverted-text-color;
+    background: $white;
+    padding: 10px;
+    font-family: inherit;
+    font-size: 14px;
+    resize: none;
+    outline: 0;
+    border-radius: 4px;
+    border: 1px solid $ui-secondary-color;
+    min-height: 100px;
+    max-height: 50vh;
+    margin-bottom: 10px;
+
+    &:focus {
+      border: 1px solid darken($ui-secondary-color, 8%);
+    }
+
+    &__wrapper {
+      background: $white;
+      border: 1px solid $ui-secondary-color;
+      margin-bottom: 10px;
+      border-radius: 4px;
+
+      .setting-text {
+        border: 0;
+        margin-bottom: 0;
+        border-radius: 0;
+
+        &:focus {
+          border: 0;
+        }
+      }
+
+      &__modifiers {
+        color: $inverted-text-color;
+        font-family: inherit;
+        font-size: 14px;
+        background: $white;
+      }
+    }
+
+    &__toolbar {
+      display: flex;
+      justify-content: space-between;
+      margin-bottom: 20px;
+    }
+  }
+
+  .setting-text-label {
+    display: block;
+    color: $inverted-text-color;
+    font-size: 14px;
+    font-weight: 500;
+    margin-bottom: 10px;
+  }
+
+  .setting-toggle {
+    margin-top: 20px;
+    margin-bottom: 24px;
+
+    &__label {
+      color: $inverted-text-color;
+      font-size: 14px;
+    }
+  }
+
+  @media screen and (max-width: 480px) {
+    padding: 10px;
+    max-width: 100%;
+    order: 2;
+
+    .setting-toggle {
+      margin-bottom: 4px;
+    }
+  }
+}
+
+.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;
+    max-height: 80vh;
+
+    &.with-status {
+      max-height: calc(80vh - 75px);
+    }
+
+    li:empty {
+      margin: 0;
+    }
+
+    li:not(:empty) {
+      a {
+        color: $inverted-text-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,
+.block-modal__action-bar {
+  .confirmation-modal__secondary-button {
+    flex-shrink: 1;
+  }
+}
+
+.confirmation-modal__secondary-button,
+.confirmation-modal__cancel-button,
+.mute-modal__cancel-button,
+.block-modal__cancel-button {
+  background-color: transparent;
+  color: $lighter-text-color;
+  font-size: 14px;
+  font-weight: 500;
+
+  &:hover,
+  &:focus,
+  &:active {
+    color: darken($lighter-text-color, 4%);
+    background-color: transparent;
+  }
+}
+
+.confirmation-modal__do_not_ask_again {
+  padding-left: 20px;
+  padding-right: 20px;
+  padding-bottom: 10px;
+  font-size: 14px;
+
+  label,
+  input {
+    vertical-align: middle;
+  }
+}
+
+.confirmation-modal__container,
+.mute-modal__container,
+.block-modal__container,
+.report-modal__target {
+  padding: 30px;
+  font-size: 16px;
+
+  strong {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 14px;
+    color: $inverted-text-color;
+    display: inline-block;
+    width: auto;
+    outline: 0;
+    font-family: inherit;
+    background: $simple-background-color
+      url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>")
+      no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($simple-background-color, 14%);
+    border-radius: 4px;
+    padding: 6px 10px;
+    padding-right: 30px;
+  }
+}
+
+.confirmation-modal__container,
+.report-modal__target {
+  text-align: center;
+}
+
+.block-modal,
+.mute-modal {
+  &__explanation {
+    margin-top: 20px;
+  }
+
+  .setting-toggle {
+    margin-top: 20px;
+    margin-bottom: 24px;
+    display: flex;
+    align-items: center;
+
+    &__label {
+      color: $inverted-text-color;
+      margin: 0;
+      margin-left: 8px;
+    }
+  }
+}
+
+.report-modal__target {
+  padding: 15px;
+
+  .report-modal__close {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+  }
+}
+
+.compare-history-modal {
+  .report-modal__target {
+    border-bottom: 1px solid $ui-secondary-color;
+  }
+
+  &__container {
+    padding: 30px;
+    pointer-events: all;
+    overflow-y: auto;
+  }
+
+  .status__content {
+    color: $inverted-text-color;
+    font-size: 19px;
+    line-height: 24px;
+
+    .emojione {
+      width: 24px;
+      height: 24px;
+      margin: -1px 0 0;
+    }
+
+    a {
+      color: $highlight-text-color;
+    }
+
+    hr {
+      height: 0.25rem;
+      padding: 0;
+      background-color: $ui-secondary-color;
+      border: 0;
+      margin: 20px 0;
+    }
+  }
+
+  .media-gallery,
+  .audio-player,
+  .video-player {
+    margin-top: 15px;
+  }
+}
+
+.embed-modal {
+  width: auto;
+  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 {
+      outline: 0;
+      box-sizing: border-box;
+      display: block;
+      width: 100%;
+      border: 0;
+      padding: 10px;
+      font-family: mastodon-font-monospace, monospace;
+      background: $ui-base-color;
+      color: $primary-text-color;
+      font-size: 14px;
+      margin: 0;
+      margin-bottom: 15px;
+      border-radius: 4px;
+
+      &::-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;
+      border-radius: 4px;
+    }
+  }
+}
+
+.focal-point {
+  position: relative;
+  cursor: move;
+  overflow: hidden;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: $base-shadow-color;
+
+  img,
+  video,
+  canvas {
+    display: block;
+    max-height: 80vh;
+    width: 100%;
+    height: auto;
+    margin: 0;
+    object-fit: contain;
+    background: $base-shadow-color;
+  }
+
+  &__reticle {
+    position: absolute;
+    width: 100px;
+    height: 100px;
+    transform: translate(-50%, -50%);
+    background: url('~images/reticle.png') no-repeat 0 0;
+    border-radius: 50%;
+    box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
+  }
+
+  &__overlay {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+
+  &__preview {
+    position: absolute;
+    bottom: 10px;
+    right: 10px;
+    z-index: 2;
+    cursor: move;
+    transition: opacity 0.1s ease;
+
+    &:hover {
+      opacity: 0.5;
+    }
+
+    strong {
+      color: $primary-text-color;
+      font-size: 14px;
+      font-weight: 500;
+      display: block;
+      margin-bottom: 5px;
+    }
+
+    div {
+      border-radius: 4px;
+      box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);
+    }
+  }
+
+  @media screen and (max-width: 480px) {
+    img,
+    video {
+      max-height: 100%;
+    }
+
+    &__preview {
+      display: none;
+    }
+  }
+}
+
+.filtered-status-info {
+  text-align: start;
+
+  .spoiler__text {
+    margin-top: 20px;
+  }
+
+  .account {
+    border-bottom: 0;
+  }
+
+  .account__display-name strong {
+    color: $inverted-text-color;
+  }
+
+  .status__content__spoiler {
+    display: none;
+
+    &--visible {
+      display: flex;
+    }
+  }
+
+  ul {
+    padding: 10px;
+    margin-left: 12px;
+    list-style: disc inside;
+  }
+
+  .filtered-status-edit-link {
+    color: $action-button-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.modal-root__container .privacy-dropdown {
+  flex-grow: 0;
+}
+
+.modal-root__container .privacy-dropdown__dropdown {
+  pointer-events: auto;
+  z-index: 9999;
+}
+
+img.modal-warning {
+  display: block;
+  margin: auto;
+  margin-bottom: 15px;
+  width: 60px;
+}
+
+.interaction-modal {
+  max-width: 90vw;
+  width: 600px;
+  background: $ui-base-color;
+  border-radius: 8px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  position: relative;
+  display: block;
+  padding: 20px;
+
+  h3 {
+    font-size: 22px;
+    line-height: 33px;
+    font-weight: 700;
+    text-align: center;
+  }
+
+  &__icon {
+    color: $highlight-text-color;
+    margin: 0 5px;
+  }
+
+  &__lead {
+    padding: 20px;
+    text-align: center;
+
+    h3 {
+      margin-bottom: 15px;
+    }
+
+    p {
+      font-size: 17px;
+      line-height: 22px;
+      color: $darker-text-color;
+    }
+  }
+
+  &__choices {
+    display: flex;
+
+    &__choice {
+      flex: 0 0 auto;
+      width: 50%;
+      box-sizing: border-box;
+      padding: 20px;
+
+      h3 {
+        margin-bottom: 20px;
+      }
+
+      p {
+        color: $darker-text-color;
+        margin-bottom: 20px;
+      }
+
+      .button {
+        margin-bottom: 10px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+  }
+
+  @media screen and (max-width: $no-gap-breakpoint - 1px) {
+    &__choices {
+      display: block;
+
+      &__choice {
+        width: auto;
+        margin-bottom: 20px;
+      }
+    }
+  }
+}
+
+.copypaste {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+
+  input {
+    display: block;
+    font-family: inherit;
+    background: darken($ui-base-color, 8%);
+    border: 1px solid $highlight-text-color;
+    color: $darker-text-color;
+    border-radius: 4px;
+    padding: 6px 9px;
+    line-height: 22px;
+    font-size: 14px;
+    transition: border-color 300ms linear;
+    flex: 1 1 auto;
+    overflow: hidden;
+
+    &:focus {
+      outline: 0;
+      background: darken($ui-base-color, 4%);
+    }
+  }
+
+  .button {
+    flex: 0 0 auto;
+    transition: background 300ms linear;
+  }
+
+  &.copied {
+    input {
+      border: 1px solid $valid-value-color;
+      transition: none;
+    }
+
+    .button {
+      background: $valid-value-color;
+      transition: none;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/privacy_policy.scss b/app/javascript/flavours/glitch/styles/components/privacy_policy.scss
new file mode 100644
index 000000000..93123075e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/privacy_policy.scss
@@ -0,0 +1,209 @@
+.privacy-policy {
+  background: $ui-base-color;
+  padding: 20px;
+
+  @media screen and (min-width: $no-gap-breakpoint) {
+    border-radius: 4px;
+  }
+
+  &__body {
+    margin-top: 20px;
+  }
+}
+
+.prose {
+  color: $secondary-text-color;
+  font-size: 15px;
+  line-height: 22px;
+
+  p,
+  ul,
+  ol {
+    margin-top: 1.25em;
+    margin-bottom: 1.25em;
+  }
+
+  img {
+    margin-top: 2em;
+    margin-bottom: 2em;
+    max-width: 100%;
+  }
+
+  video {
+    margin-top: 2em;
+    margin-bottom: 2em;
+    max-width: 100%;
+  }
+
+  figure {
+    margin-top: 2em;
+    margin-bottom: 2em;
+
+    figcaption {
+      font-size: 0.875em;
+      line-height: 1.4285714;
+      margin-top: 0.8571429em;
+    }
+  }
+
+  figure > * {
+    margin-top: 0;
+    margin-bottom: 0;
+  }
+
+  h1 {
+    font-size: 1.5em;
+    margin-top: 0;
+    margin-bottom: 1em;
+    line-height: 1.33;
+  }
+
+  h2 {
+    font-size: 1.25em;
+    margin-top: 1.6em;
+    margin-bottom: 0.6em;
+    line-height: 1.6;
+  }
+
+  h3,
+  h4,
+  h5,
+  h6 {
+    margin-top: 1.5em;
+    margin-bottom: 0.5em;
+    line-height: 1.5;
+  }
+
+  ol {
+    counter-reset: list-counter;
+  }
+
+  li {
+    margin-top: 0.5em;
+    margin-bottom: 0.5em;
+  }
+
+  ol > li {
+    counter-increment: list-counter;
+
+    &::before {
+      content: counter(list-counter) '.';
+      position: absolute;
+      left: 0;
+    }
+  }
+
+  ul > li::before {
+    content: '';
+    position: absolute;
+    background-color: $darker-text-color;
+    border-radius: 50%;
+    width: 0.375em;
+    height: 0.375em;
+    top: 0.5em;
+    left: 0.25em;
+  }
+
+  ul > li,
+  ol > li {
+    position: relative;
+    padding-left: 1.75em;
+  }
+
+  & > ul > li p {
+    margin-top: 0.75em;
+    margin-bottom: 0.75em;
+  }
+
+  & > ul > li > *:first-child {
+    margin-top: 1.25em;
+  }
+
+  & > ul > li > *:last-child {
+    margin-bottom: 1.25em;
+  }
+
+  & > ol > li > *:first-child {
+    margin-top: 1.25em;
+  }
+
+  & > ol > li > *:last-child {
+    margin-bottom: 1.25em;
+  }
+
+  ul ul,
+  ul ol,
+  ol ul,
+  ol ol {
+    margin-top: 0.75em;
+    margin-bottom: 0.75em;
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  strong,
+  b {
+    color: $primary-text-color;
+    font-weight: 700;
+  }
+
+  em,
+  i {
+    font-style: italic;
+  }
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: underline;
+
+    &:focus,
+    &:hover,
+    &:active {
+      text-decoration: none;
+    }
+  }
+
+  code {
+    font-size: 0.875em;
+    background: darken($ui-base-color, 8%);
+    border-radius: 4px;
+    padding: 0.2em 0.3em;
+  }
+
+  hr {
+    border: 0;
+    border-top: 1px solid lighten($ui-base-color, 4%);
+    margin-top: 3em;
+    margin-bottom: 3em;
+  }
+
+  hr + * {
+    margin-top: 0;
+  }
+
+  h2 + * {
+    margin-top: 0;
+  }
+
+  h3 + * {
+    margin-top: 0;
+  }
+
+  h4 + *,
+  h5 + *,
+  h6 + * {
+    margin-top: 0;
+  }
+
+  & > :first-child {
+    margin-top: 0;
+  }
+
+  & > :last-child {
+    margin-bottom: 0;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss b/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss
new file mode 100644
index 000000000..c65e6a9af
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss
@@ -0,0 +1,43 @@
+.regeneration-indicator {
+  text-align: center;
+  font-size: 16px;
+  font-weight: 500;
+  color: $dark-text-color;
+  background: $ui-base-color;
+  cursor: default;
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 20px;
+
+  &__figure {
+    &,
+    img {
+      display: block;
+      width: auto;
+      height: 160px;
+      margin: 0;
+    }
+  }
+
+  &--without-header {
+    padding-top: 20px + 48px;
+  }
+
+  &__label {
+    margin-top: 30px;
+
+    strong {
+      display: block;
+      margin-bottom: 10px;
+      color: $dark-text-color;
+    }
+
+    span {
+      font-size: 15px;
+      font-weight: 400;
+    }
+  }
+}
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..a6e98a868
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -0,0 +1,245 @@
+.search {
+  margin-bottom: 10px;
+  position: relative;
+}
+
+.search__input {
+  @include search-input;
+
+  display: block;
+  padding: 15px;
+  padding-right: 30px;
+  line-height: 18px;
+  font-size: 16px;
+
+  &::placeholder {
+    color: lighten($darker-text-color, 4%);
+  }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus,
+  &:active {
+    outline: 0 !important;
+  }
+
+  &:focus {
+    background: lighten($ui-base-color, 4%);
+  }
+}
+
+.search__icon {
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus {
+    outline: 0 !important;
+  }
+
+  .fa {
+    position: absolute;
+    top: 16px;
+    right: 10px;
+    z-index: 2;
+    display: inline-block;
+    opacity: 0;
+    transition: all 100ms linear;
+    transition-property: color, transform, opacity;
+    font-size: 18px;
+    width: 18px;
+    height: 18px;
+    color: $secondary-text-color;
+    cursor: default;
+    pointer-events: none;
+
+    &.active {
+      pointer-events: auto;
+      opacity: 0.3;
+    }
+  }
+
+  .fa-search {
+    transform: rotate(0deg);
+
+    &.active {
+      pointer-events: auto;
+      opacity: 0.3;
+    }
+  }
+
+  .fa-times-circle {
+    top: 17px;
+    transform: rotate(0deg);
+    color: $action-button-color;
+    cursor: pointer;
+
+    &.active {
+      transform: rotate(90deg);
+    }
+
+    &:hover {
+      color: lighten($action-button-color, 7%);
+    }
+  }
+}
+
+.search-results__header {
+  color: $dark-text-color;
+  background: lighten($ui-base-color, 2%);
+  padding: 15px;
+  font-weight: 500;
+  font-size: 16px;
+  cursor: default;
+
+  .fa {
+    display: inline-block;
+    margin-right: 5px;
+  }
+}
+
+.search-results__info {
+  padding: 20px;
+  color: $darker-text-color;
+  text-align: center;
+}
+
+.trends {
+  &__header {
+    color: $dark-text-color;
+    background: lighten($ui-base-color, 2%);
+    border-bottom: 1px solid darken($ui-base-color, 4%);
+    font-weight: 500;
+    padding: 15px;
+    font-size: 16px;
+    cursor: default;
+
+    .fa {
+      display: inline-block;
+      margin-right: 5px;
+    }
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    padding: 15px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+    gap: 15px;
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__name {
+      flex: 1 1 auto;
+      color: $dark-text-color;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+
+      strong {
+        font-weight: 500;
+      }
+
+      a {
+        color: $darker-text-color;
+        text-decoration: none;
+        font-size: 14px;
+        font-weight: 500;
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+
+        &:hover,
+        &:focus,
+        &:active {
+          span {
+            text-decoration: underline;
+          }
+        }
+      }
+    }
+
+    &__current {
+      flex: 0 0 auto;
+      font-size: 24px;
+      font-weight: 500;
+      text-align: right;
+      color: $secondary-text-color;
+      text-decoration: none;
+    }
+
+    &__sparkline {
+      flex: 0 0 auto;
+      width: 50px;
+
+      path:first-child {
+        fill: rgba($highlight-text-color, 0.25) !important;
+        fill-opacity: 1 !important;
+      }
+
+      path:last-child {
+        stroke: lighten($highlight-text-color, 6%) !important;
+        fill: none !important;
+      }
+    }
+
+    &--requires-review {
+      .trends__item__name {
+        color: $gold-star;
+
+        a {
+          color: $gold-star;
+        }
+      }
+
+      .trends__item__current {
+        color: $gold-star;
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba($gold-star, 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten($gold-star, 6%) !important;
+        }
+      }
+    }
+
+    &--disabled {
+      .trends__item__name {
+        color: lighten($ui-base-color, 12%);
+
+        a {
+          color: lighten($ui-base-color, 12%);
+        }
+      }
+
+      .trends__item__current {
+        color: lighten($ui-base-color, 12%);
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
+        }
+      }
+    }
+  }
+
+  &--compact &__item {
+    padding: 10px;
+  }
+}
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..490951fb4
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/sensitive.scss
@@ -0,0 +1,26 @@
+.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: 18px;
+  text-transform: uppercase;
+  opacity: 0.9;
+  transition: opacity 0.1s ease;
+
+  .media-gallery:hover & {
+    opacity: 1;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/signed_out.scss b/app/javascript/flavours/glitch/styles/components/signed_out.scss
new file mode 100644
index 000000000..efb49305d
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/signed_out.scss
@@ -0,0 +1,110 @@
+.sign-in-banner {
+  padding: 10px;
+
+  p {
+    color: $darker-text-color;
+    margin-bottom: 20px;
+
+    a {
+      color: $secondary-text-color;
+      text-decoration: none;
+      unicode-bidi: isolate;
+
+      &:hover {
+        text-decoration: underline;
+
+        .fa {
+          color: lighten($dark-text-color, 7%);
+        }
+      }
+    }
+  }
+
+  .button {
+    margin-bottom: 10px;
+  }
+}
+
+.server-banner {
+  padding: 20px 0;
+
+  &__introduction {
+    color: $darker-text-color;
+    margin-bottom: 20px;
+
+    strong {
+      font-weight: 600;
+    }
+
+    a {
+      color: inherit;
+      text-decoration: underline;
+
+      &:hover,
+      &:active,
+      &:focus {
+        text-decoration: none;
+      }
+    }
+  }
+
+  &__hero {
+    display: block;
+    border-radius: 4px;
+    width: 100%;
+    height: auto;
+    margin-bottom: 20px;
+    aspect-ratio: 1.9;
+    border: 0;
+    background: $ui-base-color;
+    object-fit: cover;
+  }
+
+  &__description {
+    margin-bottom: 20px;
+  }
+
+  &__meta {
+    display: flex;
+    gap: 10px;
+    max-width: 100%;
+
+    &__column {
+      flex: 0 0 auto;
+      width: calc(50% - 5px);
+      overflow: hidden;
+    }
+  }
+
+  &__number {
+    font-weight: 600;
+    color: $primary-text-color;
+    font-size: 14px;
+  }
+
+  &__number-label {
+    color: $darker-text-color;
+    font-weight: 500;
+    font-size: 14px;
+  }
+
+  h4 {
+    text-transform: uppercase;
+    color: $darker-text-color;
+    margin-bottom: 10px;
+    font-weight: 600;
+  }
+
+  .account {
+    padding: 0;
+    border: 0;
+  }
+
+  .account__avatar-wrapper {
+    margin-left: 0;
+  }
+
+  .spacer {
+    margin: 10px 0;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
new file mode 100644
index 000000000..036b3f6ef
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -0,0 +1,333 @@
+.compose-panel {
+  width: 285px;
+  margin-top: 10px;
+  display: flex;
+  flex-direction: column;
+  height: calc(100% - 10px);
+  overflow-y: hidden;
+
+  .hero-widget {
+    box-shadow: none;
+
+    &__text,
+    &__img,
+    &__img img {
+      border-radius: 0;
+    }
+
+    &__text {
+      padding: 15px;
+      color: $secondary-text-color;
+
+      strong {
+        font-weight: 700;
+        color: $primary-text-color;
+      }
+    }
+  }
+
+  .search__input {
+    line-height: 18px;
+    font-size: 16px;
+    padding: 15px;
+    padding-right: 30px;
+  }
+
+  .search__icon .fa {
+    top: 15px;
+  }
+
+  .navigation-bar {
+    flex: 0 1 48px;
+  }
+
+  .compose-form {
+    flex: 1;
+    overflow-y: hidden;
+    display: flex;
+    flex-direction: column;
+    min-height: 310px;
+  }
+
+  .compose-form__autosuggest-wrapper {
+    overflow-y: auto;
+    background-color: $white;
+    border-radius: 4px 4px 0 0;
+    flex: 0 1 auto;
+  }
+
+  .autosuggest-textarea__textarea {
+    overflow-y: hidden;
+  }
+}
+
+.navigation-panel {
+  margin-top: 10px;
+  margin-bottom: 10px;
+  height: calc(100% - 20px);
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+
+  & > a {
+    flex: 0 0 auto;
+  }
+
+  .logo {
+    height: 30px;
+    width: auto;
+  }
+}
+
+.navigation-panel,
+.compose-panel {
+  hr {
+    flex: 0 0 auto;
+    border: 0;
+    background: transparent;
+    border-top: 1px solid lighten($ui-base-color, 4%);
+    margin: 10px 0;
+  }
+
+  .flex-spacer {
+    background: transparent;
+  }
+}
+
+@media screen and (min-width: 600px) {
+  .tabs-bar__link {
+    span {
+      display: inline;
+    }
+  }
+}
+
+.columns-area--mobile {
+  flex-direction: column;
+  width: 100%;
+  margin: 0 auto;
+
+  .column,
+  .drawer {
+    width: 100%;
+    height: 100%;
+    padding: 0;
+  }
+
+  .account-card {
+    margin-bottom: 0;
+  }
+
+  .filter-form {
+    display: flex;
+  }
+
+  .autosuggest-textarea__textarea {
+    font-size: 16px;
+  }
+
+  .search__input {
+    line-height: 18px;
+    font-size: 16px;
+    padding: 15px;
+    padding-right: 30px;
+  }
+
+  .search__icon .fa {
+    top: 15px;
+  }
+
+  .scrollable {
+    overflow: visible;
+
+    @supports (display: grid) {
+      contain: content;
+    }
+  }
+
+  @media screen and (min-width: $no-gap-breakpoint) {
+    padding: 10px 0;
+    padding-top: 0;
+  }
+
+  .detailed-status {
+    padding: 15px;
+
+    .media-gallery,
+    .video-player,
+    .audio-player {
+      margin-top: 15px;
+    }
+  }
+
+  .account__header__bar {
+    padding: 5px 10px;
+  }
+
+  .navigation-bar,
+  .compose-form {
+    padding: 15px;
+  }
+
+  .compose-form .compose-form__publish .compose-form__publish-button-wrapper {
+    padding-top: 15px;
+  }
+
+  .notification__report {
+    padding: 15px 15px 15px (48px + 15px * 2);
+    min-height: 48px + 2px;
+
+    &__avatar {
+      left: 15px;
+      top: 17px;
+    }
+  }
+
+  .status {
+    padding: 15px;
+    min-height: 48px + 2px;
+
+    .media-gallery,
+    &__action-bar,
+    .video-player,
+    .audio-player {
+      margin-top: 10px;
+    }
+  }
+
+  .account {
+    padding: 15px 10px;
+
+    &__header__bio {
+      margin: 0 -10px;
+    }
+  }
+
+  .notification {
+    &__message {
+      padding-top: 15px;
+    }
+
+    .status {
+      padding-top: 8px;
+    }
+
+    .account {
+      padding-top: 8px;
+    }
+  }
+}
+
+@media screen and (min-width: $no-gap-breakpoint) {
+  .tabs-bar {
+    width: 100%;
+  }
+
+  .react-swipeable-view-container .columns-area--mobile {
+    height: calc(100% - 10px) !important;
+  }
+
+  .getting-started__wrapper {
+    margin-bottom: 10px;
+  }
+
+  .tabs-bar__link.optional {
+    display: none;
+  }
+
+  .search-page .search {
+    display: none;
+  }
+
+  .navigation-panel__legal {
+    display: none;
+  }
+}
+
+@media screen and (max-width: $no-gap-breakpoint - 1px) {
+  $sidebar-width: 285px;
+
+  .columns-area__panels__main {
+    width: calc(100% - $sidebar-width);
+  }
+
+  .columns-area__panels {
+    min-height: calc(100vh - $ui-header-height);
+  }
+
+  .columns-area__panels__pane--navigational {
+    min-width: $sidebar-width;
+
+    .columns-area__panels__pane__inner {
+      width: $sidebar-width;
+    }
+
+    .navigation-panel {
+      margin: 0;
+      background: $ui-base-color;
+      border-left: 1px solid lighten($ui-base-color, 8%);
+      height: 100vh;
+    }
+
+    .navigation-panel__sign-in-banner,
+    .navigation-panel__logo,
+    .getting-started__trends {
+      display: none;
+    }
+
+    .column-link__icon {
+      font-size: 18px;
+    }
+  }
+
+  .layout-single-column .ui__header {
+    display: flex;
+    background: $ui-base-color;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
+  .column-header,
+  .column-back-button,
+  .scrollable,
+  .error-column {
+    border-radius: 0 !important;
+  }
+}
+
+@media screen and (max-width: $no-gap-breakpoint - 285px - 1px) {
+  $sidebar-width: 55px;
+
+  .columns-area__panels__main {
+    width: calc(100% - $sidebar-width);
+  }
+
+  .columns-area__panels__pane--navigational {
+    min-width: $sidebar-width;
+
+    .columns-area__panels__pane__inner {
+      width: $sidebar-width;
+    }
+
+    .column-link span {
+      display: none;
+    }
+
+    .list-panel {
+      display: none;
+    }
+  }
+}
+
+.explore__search-header {
+  display: none;
+}
+
+@media screen and (max-width: $no-gap-breakpoint - 1px) {
+  .columns-area__panels__pane--compositional {
+    display: none;
+  }
+
+  .explore__search-header {
+    display: flex;
+  }
+}
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..1a7dfe9ae
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -0,0 +1,1114 @@
+@keyframes spring-flip-in {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  30% {
+    transform: rotate(-242.4deg);
+  }
+
+  60% {
+    transform: rotate(-158.35deg);
+  }
+
+  90% {
+    transform: rotate(-187.5deg);
+  }
+
+  100% {
+    transform: rotate(-180deg);
+  }
+}
+
+@keyframes spring-flip-out {
+  0% {
+    transform: rotate(-180deg);
+  }
+
+  30% {
+    transform: rotate(62.4deg);
+  }
+
+  60% {
+    transform: rotate(-21.635deg);
+  }
+
+  90% {
+    transform: rotate(7.5deg);
+  }
+
+  100% {
+    transform: rotate(0deg);
+  }
+}
+
+.status__content--with-action {
+  cursor: pointer;
+}
+
+.status__content {
+  position: relative;
+  margin: 10px 0;
+  font-size: 15px;
+  line-height: 20px;
+  word-wrap: break-word;
+  font-weight: 400;
+  overflow: visible;
+  padding-top: 5px;
+  clear: both;
+
+  &:focus {
+    outline: 0;
+  }
+
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -3px 0 0;
+  }
+
+  p,
+  pre {
+    margin-bottom: 20px;
+    white-space: pre-wrap;
+    unicode-bidi: plaintext;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: $secondary-text-color;
+    text-decoration: none;
+    unicode-bidi: isolate;
+
+    &:hover {
+      text-decoration: underline;
+
+      .fa {
+        color: lighten($dark-text-color, 7%);
+      }
+    }
+
+    &.mention {
+      &:hover {
+        text-decoration: none;
+
+        span {
+          text-decoration: underline;
+        }
+      }
+    }
+
+    .fa {
+      color: $dark-text-color;
+    }
+  }
+
+  .status__content__spoiler {
+    display: none;
+
+    &.status__content__spoiler--visible {
+      display: block;
+    }
+  }
+
+  a.unhandled-link {
+    color: $highlight-text-color;
+
+    .link-origin-tag {
+      color: $gold-star;
+      font-size: 0.8em;
+    }
+  }
+
+  .status__content__spoiler-link {
+    background: lighten($ui-base-color, 30%);
+
+    &:hover,
+    &:focus {
+      background: lighten($ui-base-color, 33%);
+      text-decoration: none;
+    }
+  }
+}
+
+.translate-button {
+  margin-top: 16px;
+  font-size: 15px;
+  line-height: 20px;
+  display: flex;
+  justify-content: space-between;
+  color: $dark-text-color;
+}
+
+.status__content__spoiler-link {
+  display: inline-block;
+  border-radius: 2px;
+  background: lighten($ui-base-color, 30%);
+  border: 0;
+  color: $inverted-text-color;
+  font-weight: 700;
+  font-size: 11px;
+  padding: 0 5px;
+  text-transform: uppercase;
+  line-height: inherit;
+  cursor: pointer;
+  vertical-align: top;
+
+  &: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,
+  .notification-follow-request {
+    padding-right: ($dismiss-overlay-width + 0.5rem);
+  }
+}
+
+.status__wrapper--filtered {
+  color: $dark-text-color;
+  border: 0;
+  font-size: inherit;
+  text-align: center;
+  line-height: inherit;
+  margin: 0;
+  padding: 15px;
+  box-sizing: border-box;
+  width: 100%;
+  clear: both;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
+.status__prepend-icon-wrapper {
+  left: -26px;
+  position: absolute;
+}
+
+.notification-follow,
+.notification-follow-request {
+  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: 10px 14px;
+  position: relative;
+  height: auto;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  cursor: auto;
+
+  @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: 28px; // 12px + 16px
+  }
+
+  @keyframes fade {
+    0% {
+      opacity: 0;
+    }
+
+    100% {
+      opacity: 1;
+    }
+  }
+
+  opacity: 1;
+  animation: fade 150ms linear;
+
+  .video-player,
+  .audio-player {
+    margin-top: 8px;
+  }
+
+  &.status-direct {
+    background: lighten($ui-base-color, 8%);
+    border-bottom-color: lighten($ui-base-color, 12%);
+  }
+
+  &.light {
+    .status__relative-time {
+      color: $lighter-text-color;
+    }
+
+    .status__display-name {
+      color: $inverted-text-color;
+    }
+
+    .display-name {
+      color: $light-text-color;
+
+      strong {
+        color: $inverted-text-color;
+      }
+    }
+
+    .status__content {
+      color: $inverted-text-color;
+
+      a {
+        color: $highlight-text-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, 0.75),
+        rgba($base-shadow-color, 0.65) 24px,
+        rgba($base-shadow-color, 0.8)
+      );
+      pointer-events: none;
+      content: '';
+    }
+
+    .display-name:hover .display-name__html {
+      text-decoration: none;
+    }
+
+    .status__content {
+      height: 20px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      padding-top: 0;
+
+      &::after {
+        content: '';
+        position: absolute;
+        top: 0;
+        bottom: 0;
+        left: 0;
+        right: 0;
+        background: linear-gradient(
+          rgba($ui-base-color, 0),
+          rgba($ui-base-color, 1)
+        );
+        pointer-events: none;
+      }
+
+      a:hover {
+        text-decoration: none;
+      }
+    }
+
+    &:focus > .status__content::after {
+      background: linear-gradient(
+        rgba(lighten($ui-base-color, 4%), 0),
+        rgba(lighten($ui-base-color, 4%), 1)
+      );
+    }
+
+    &.status-direct > .status__content::after {
+      background: linear-gradient(
+        rgba(lighten($ui-base-color, 8%), 0),
+        rgba(lighten($ui-base-color, 8%), 1)
+      );
+    }
+
+    .notification__message {
+      margin-bottom: 0;
+    }
+
+    .status__info .notification__message > span {
+      white-space: nowrap;
+    }
+  }
+
+  .notification__message {
+    margin: -10px 0 10px;
+  }
+}
+
+.notification-favourite {
+  .status.status-direct {
+    background: transparent;
+
+    .icon-button.disabled {
+      color: lighten($action-button-color, 13%);
+    }
+  }
+}
+
+.status__relative-time {
+  display: inline-block;
+  color: $dark-text-color;
+  font-size: 14px;
+  text-align: right;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.status__display-name {
+  color: $dark-text-color;
+  overflow: hidden;
+}
+
+.status__info__account .status__display-name {
+  display: block;
+  max-width: 100%;
+}
+
+.status__info {
+  display: flex;
+  justify-content: space-between;
+  font-size: 15px;
+
+  > span {
+    text-overflow: ellipsis;
+    overflow: hidden;
+  }
+
+  .notification__message > span {
+    word-wrap: break-word;
+  }
+}
+
+.status__info__icons {
+  display: flex;
+  align-items: center;
+  height: 1em;
+  color: $action-button-color;
+
+  .status__media-icon,
+  .status__visibility-icon,
+  .status__reply-icon,
+  .text-icon {
+    padding-left: 2px;
+    padding-right: 2px;
+  }
+
+  .status__collapse-button.active > .fa-angle-double-up {
+    transform: rotate(-180deg);
+  }
+}
+
+.no-reduce-motion .status__collapse-button {
+  &.activate {
+    & > .fa-angle-double-up {
+      animation: spring-flip-in 1s linear;
+    }
+  }
+
+  &.deactivate {
+    & > .fa-angle-double-up {
+      animation: spring-flip-out 1s linear;
+    }
+  }
+}
+
+.status__info__account {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+}
+
+.status-check-box__status {
+  display: block;
+  box-sizing: border-box;
+  width: 100%;
+  padding: 0 10px;
+
+  .detailed-status__display-name {
+    color: lighten($inverted-text-color, 16%);
+
+    span {
+      display: inline;
+    }
+
+    &:hover strong {
+      text-decoration: none;
+    }
+  }
+
+  .media-gallery,
+  .audio-player,
+  .video-player {
+    margin-top: 15px;
+    max-width: 250px;
+  }
+
+  .status__content {
+    padding: 0;
+    white-space: normal;
+  }
+
+  .media-gallery__item-thumbnail {
+    cursor: default;
+  }
+}
+
+.status__prepend {
+  margin-top: -2px;
+  margin-bottom: 8px;
+  margin-left: 58px;
+  color: $dark-text-color;
+  font-size: 14px;
+  position: relative;
+
+  .status__display-name strong {
+    color: $dark-text-color;
+  }
+
+  > span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.status__action-bar {
+  align-items: center;
+  display: flex;
+  margin-top: 8px;
+}
+
+.status__action-bar-button {
+  margin-right: 18px;
+
+  &.icon-button--with-counter {
+    margin-right: 14px;
+  }
+}
+
+.status__action-bar-dropdown {
+  height: 23.15px;
+  width: 23.15px;
+}
+
+.status__action-bar-spacer {
+  flex-grow: 1;
+}
+
+.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;
+
+  &--flex {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    align-items: flex-start;
+
+    .status__content,
+    .detailed-status__meta {
+      flex: 100%;
+    }
+  }
+
+  .status__content {
+    font-size: 19px;
+    line-height: 24px;
+
+    .emojione {
+      width: 24px;
+      height: 24px;
+      margin: -1px 0 0;
+    }
+  }
+
+  .video-player,
+  .audio-player {
+    margin-top: 8px;
+  }
+}
+
+.detailed-status__meta {
+  margin-top: 15px;
+  color: $dark-text-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;
+  line-height: 17px;
+  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;
+  }
+}
+
+a.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 .button.logo-button {
+  margin-bottom: 15px;
+}
+
+.detailed-status__display-name {
+  color: $secondary-text-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,
+  .status__content p,
+  .status__content a,
+  .status__content__text {
+    color: $dark-text-color;
+  }
+
+  .status__display-name strong {
+    color: $dark-text-color;
+  }
+
+  .status__avatar {
+    opacity: 0.5;
+  }
+
+  a.status__content__spoiler-link {
+    background: $ui-base-lighter-color;
+    color: $inverted-text-color;
+
+    &:hover,
+    &:focus {
+      background: lighten($ui-base-color, 29%);
+      text-decoration: none;
+    }
+  }
+}
+
+.status__relative-time,
+.detailed-status__datetime {
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+.status-card {
+  position: relative;
+  display: flex;
+  font-size: 14px;
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-radius: 4px;
+  color: $dark-text-color;
+  margin-top: 14px;
+  text-decoration: none;
+  overflow: hidden;
+
+  &__actions {
+    bottom: 0;
+    left: 0;
+    position: absolute;
+    right: 0;
+    top: 0;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    & > div {
+      background: rgba($base-shadow-color, 0.6);
+      border-radius: 8px;
+      padding: 12px 9px;
+      flex: 0 0 auto;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+
+    button,
+    a {
+      display: inline;
+      color: $secondary-text-color;
+      background: transparent;
+      border: 0;
+      padding: 0 8px;
+      text-decoration: none;
+      font-size: 18px;
+      line-height: 18px;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: $primary-text-color;
+      }
+    }
+
+    a {
+      font-size: 19px;
+      position: relative;
+      bottom: -1px;
+    }
+
+    a .fa,
+    a:hover .fa {
+      color: inherit;
+    }
+  }
+}
+
+a.status-card {
+  cursor: pointer;
+
+  &:hover {
+    background: lighten($ui-base-color, 8%);
+  }
+}
+
+.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: $darker-text-color;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-decoration: none;
+}
+
+.status-card__content {
+  flex: 1 1 auto;
+  overflow: hidden;
+  padding: 14px 14px 14px 8px;
+}
+
+.status-card__description {
+  color: $darker-text-color;
+}
+
+.status-card__host {
+  display: block;
+  margin-top: 5px;
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-card__image {
+  flex: 0 0 100px;
+  background: lighten($ui-base-color, 8%);
+  position: relative;
+
+  & > .fa {
+    font-size: 21px;
+    position: absolute;
+    transform-origin: 50% 50%;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+  }
+}
+
+.status-card.horizontal {
+  display: block;
+
+  .status-card__image {
+    width: 100%;
+  }
+
+  .status-card__image-image,
+  .status-card__image-preview {
+    border-radius: 4px 4px 0 0;
+  }
+
+  .status-card__title {
+    white-space: inherit;
+  }
+}
+
+.status-card.compact {
+  border-color: lighten($ui-base-color, 4%);
+
+  &.interactive {
+    border: 0;
+  }
+
+  .status-card__content {
+    padding: 8px;
+    padding-top: 10px;
+  }
+
+  .status-card__title {
+    white-space: nowrap;
+  }
+
+  .status-card__image {
+    flex: 0 0 60px;
+  }
+}
+
+a.status-card.compact:hover {
+  background-color: lighten($ui-base-color, 4%);
+}
+
+.status-card__image-image {
+  border-radius: 4px 0 0 4px;
+  display: block;
+  margin: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  background-size: cover;
+  background-position: center center;
+}
+
+.status-card__image-preview {
+  border-radius: 4px 0 0 4px;
+  display: block;
+  margin: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: fill;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 0;
+  background: $base-overlay-background;
+
+  &--hidden {
+    display: none;
+  }
+}
+
+.attachment-list {
+  display: flex;
+  font-size: 14px;
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-radius: 4px;
+  margin-top: 14px;
+  overflow: hidden;
+
+  &__icon {
+    flex: 0 0 auto;
+    color: $dark-text-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;
+    }
+  }
+
+  &__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: $dark-text-color;
+      font-weight: 500;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  &.compact {
+    border: 0;
+    margin-top: 4px;
+
+    .attachment-list__list {
+      padding: 0;
+      display: block;
+    }
+
+    .fa {
+      color: $dark-text-color;
+    }
+  }
+}
+
+.status__wrapper--filtered__button {
+  display: inline;
+  color: lighten($ui-highlight-color, 8%);
+  border: 0;
+  background: transparent;
+  padding: 0;
+  font-size: inherit;
+  line-height: inherit;
+
+  &:hover,
+  &:active {
+    text-decoration: underline;
+  }
+}
+
+.notification,
+.status {
+  position: relative;
+
+  &.unread {
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      border-left: 4px solid $highlight-text-color;
+      pointer-events: none;
+    }
+  }
+}
+
+.picture-in-picture {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  width: 300px;
+
+  &.left {
+    right: unset;
+    left: 20px;
+  }
+
+  &__footer {
+    border-radius: 0 0 4px 4px;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    padding-top: 12px;
+    display: flex;
+    justify-content: space-between;
+  }
+
+  &__header {
+    border-radius: 4px 4px 0 0;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    display: flex;
+    justify-content: space-between;
+
+    &__account {
+      display: flex;
+      text-decoration: none;
+      overflow: hidden;
+    }
+
+    .account__avatar {
+      margin-right: 10px;
+    }
+
+    .display-name {
+      color: $primary-text-color;
+      text-decoration: none;
+
+      strong,
+      span {
+        display: block;
+        text-overflow: ellipsis;
+        overflow: hidden;
+      }
+
+      span {
+        color: $darker-text-color;
+      }
+    }
+  }
+
+  .video-player,
+  .audio-player {
+    border-radius: 0;
+  }
+}
+
+.picture-in-picture-placeholder {
+  box-sizing: border-box;
+  border: 2px dashed lighten($ui-base-color, 8%);
+  background: $base-shadow-color;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin-top: 10px;
+  font-size: 16px;
+  font-weight: 500;
+  cursor: pointer;
+  color: $darker-text-color;
+
+  i {
+    display: block;
+    font-size: 24px;
+    font-weight: 400;
+    margin-bottom: 10px;
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    border-color: lighten($ui-base-color, 12%);
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
new file mode 100644
index 000000000..b90851546
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -0,0 +1,109 @@
+.container-alt {
+  width: 700px;
+  margin: 0 auto;
+
+  @media screen and (max-width: 740px) {
+    width: 100%;
+    margin: 0;
+  }
+}
+
+.logo-container {
+  margin: 50px auto;
+
+  h1 {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .logo {
+      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-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;
+  margin-top: 40px;
+  margin-bottom: 10px;
+  border-bottom: 1px solid $ui-base-color;
+
+  @media screen and (max-width: 440px) {
+    width: 100%;
+    margin: 0;
+    padding: 20px;
+  }
+
+  .avatar {
+    width: 40px;
+    height: 40px;
+    @include avatar-size(40px);
+
+    margin-right: 10px;
+
+    img {
+      width: 100%;
+      height: 100%;
+      display: block;
+      margin: 0;
+      border-radius: 4px;
+      @include avatar-radius;
+    }
+  }
+
+  .name {
+    flex: 1 1 auto;
+    color: $secondary-text-color;
+    width: calc(100% - 90px);
+
+    .username {
+      display: block;
+      font-weight: 500;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+  }
+
+  .logout-link {
+    display: block;
+    font-size: 32px;
+    line-height: 40px;
+    margin-left: 10px;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/contrast.scss b/app/javascript/flavours/glitch/styles/contrast.scss
new file mode 100644
index 000000000..4de31db9a
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/contrast.scss
@@ -0,0 +1,3 @@
+@import 'contrast/variables';
+@import 'index';
+@import 'contrast/diff';
diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
new file mode 100644
index 000000000..4fa1a0361
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
@@ -0,0 +1,78 @@
+.compose-form {
+  .compose-form__modifiers {
+    .compose-form__upload {
+      &-description {
+        input {
+          &::placeholder {
+            opacity: 1;
+          }
+        }
+      }
+    }
+  }
+}
+
+.status__content a,
+.link-footer a,
+.reply-indicator__content a,
+.status__content__read-more-button {
+  text-decoration: underline;
+
+  &:hover,
+  &:focus,
+  &:active {
+    text-decoration: none;
+  }
+
+  &.mention {
+    text-decoration: none;
+
+    span {
+      text-decoration: underline;
+    }
+
+    &:hover,
+    &:focus,
+    &:active {
+      span {
+        text-decoration: none;
+      }
+    }
+  }
+}
+
+.status__content a {
+  color: $highlight-text-color;
+}
+
+.nothing-here {
+  color: $darker-text-color;
+}
+
+.compose-form__poll-wrapper .button.button-secondary,
+.compose-form .autosuggest-textarea__textarea::placeholder,
+.compose-form .spoiler-input__input::placeholder,
+.report-dialog-modal__textarea::placeholder,
+.language-dropdown__dropdown__results__item__common-name,
+.compose-form .icon-button {
+  color: $inverted-text-color;
+}
+
+.text-icon-button.active {
+  color: $ui-highlight-color;
+}
+
+.language-dropdown__dropdown__results__item.active {
+  background: $ui-highlight-color;
+  font-weight: 500;
+}
+
+.link-button:disabled {
+  cursor: not-allowed;
+
+  &:hover,
+  &:focus,
+  &:active {
+    text-decoration: none !important;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/contrast/variables.scss b/app/javascript/flavours/glitch/styles/contrast/variables.scss
new file mode 100644
index 000000000..e38d24b27
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/contrast/variables.scss
@@ -0,0 +1,22 @@
+// Dependent colors
+$black: #000000;
+
+$classic-base-color: #282c37;
+$classic-primary-color: #9baec8;
+$classic-secondary-color: #d9e1e8;
+$classic-highlight-color: #6364ff;
+
+$ui-base-color: $classic-base-color !default;
+$ui-primary-color: $classic-primary-color !default;
+$ui-secondary-color: $classic-secondary-color !default;
+$ui-highlight-color: $classic-highlight-color !default;
+
+$darker-text-color: lighten($ui-primary-color, 20%) !default;
+$dark-text-color: lighten($ui-primary-color, 12%) !default;
+$secondary-text-color: lighten($ui-secondary-color, 6%) !default;
+$highlight-text-color: lighten($ui-highlight-color, 10%) !default;
+$action-button-color: lighten($ui-base-color, 50%);
+
+$inverted-text-color: $black !default;
+$lighter-text-color: darken($ui-base-color, 6%) !default;
+$light-text-color: darken($ui-primary-color, 40%) !default;
diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss
new file mode 100644
index 000000000..f25765d1d
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/dashboard.scss
@@ -0,0 +1,123 @@
+.dashboard__counters {
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 -5px;
+  margin-bottom: 20px;
+
+  & > div {
+    box-sizing: border-box;
+    flex: 0 0 33.333%;
+    padding: 0 5px;
+    margin-bottom: 10px;
+
+    & > div,
+    & > a {
+      padding: 20px;
+      background: lighten($ui-base-color, 4%);
+      border-radius: 4px;
+      box-sizing: border-box;
+      height: 100%;
+    }
+
+    & > a {
+      text-decoration: none;
+      color: inherit;
+      display: block;
+
+      &:hover,
+      &:focus,
+      &:active {
+        background: lighten($ui-base-color, 8%);
+      }
+    }
+  }
+
+  &__num,
+  &__text {
+    text-align: center;
+    font-weight: 500;
+    font-size: 24px;
+    color: $primary-text-color;
+    margin-bottom: 20px;
+    line-height: 30px;
+  }
+
+  &__text {
+    font-size: 18px;
+  }
+
+  &__label {
+    font-size: 14px;
+    color: $darker-text-color;
+    text-align: center;
+    font-weight: 500;
+  }
+}
+
+.dashboard {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+  grid-gap: 10px;
+
+  @media screen and (max-width: 1350px) {
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  }
+
+  &__item {
+    &--span-double-column {
+      grid-column: span 2;
+    }
+
+    &--span-double-row {
+      grid-row: span 2;
+    }
+
+    h4 {
+      padding-top: 20px;
+    }
+  }
+
+  &__quick-access {
+    display: flex;
+    align-items: baseline;
+    border-radius: 4px;
+    background: darken($ui-highlight-color, 2%);
+    color: $primary-text-color;
+    transition: all 100ms ease-in;
+    font-size: 14px;
+    padding: 0 16px;
+    line-height: 36px;
+    height: 36px;
+    text-decoration: none;
+    margin-bottom: 4px;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: $ui-highlight-color;
+      transition: all 200ms ease-out;
+    }
+
+    &.positive {
+      background: lighten($ui-base-color, 4%);
+      color: $valid-value-color;
+    }
+
+    &.negative {
+      background: lighten($ui-base-color, 4%);
+      color: $error-value-color;
+    }
+
+    span {
+      flex: 1 1 auto;
+    }
+
+    .fa {
+      flex: 0 0 auto;
+    }
+
+    strong {
+      font-weight: 700;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
new file mode 100644
index 000000000..602de9002
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -0,0 +1,1119 @@
+$no-columns-breakpoint: 600px;
+
+code {
+  font-family: $font-monospace, monospace;
+  font-weight: 400;
+}
+
+.form-container {
+  max-width: 450px;
+  padding: 20px;
+  padding-bottom: 50px;
+  margin: 50px auto;
+}
+
+.indicator-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  color: $primary-text-color;
+
+  &.success {
+    background: $success-green;
+  }
+
+  &.failure {
+    background: $error-red;
+  }
+}
+
+.simple_form {
+  &.hidden {
+    display: none;
+  }
+
+  .input {
+    margin-bottom: 15px;
+    overflow: hidden;
+
+    &.hidden {
+      margin: 0;
+    }
+
+    &.radio_buttons {
+      .radio {
+        margin-bottom: 15px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+
+      .radio > label {
+        position: relative;
+        padding-left: 28px;
+
+        input {
+          position: absolute;
+          top: -2px;
+          left: 0;
+        }
+      }
+    }
+
+    &.boolean {
+      position: relative;
+      margin-bottom: 0;
+
+      .label_input > label {
+        font-family: inherit;
+        font-size: 14px;
+        padding-top: 5px;
+        color: $primary-text-color;
+        display: block;
+        width: auto;
+      }
+
+      .label_input,
+      .hint {
+        padding-left: 28px;
+      }
+
+      .label_input__wrapper {
+        position: static;
+      }
+
+      label.checkbox {
+        position: absolute;
+        top: 2px;
+        left: 0;
+      }
+
+      label a {
+        color: $highlight-text-color;
+        text-decoration: underline;
+
+        &:hover,
+        &:active,
+        &:focus {
+          text-decoration: none;
+        }
+      }
+
+      .recommended,
+      .not_recommended,
+      .glitch_only {
+        position: absolute;
+        margin: 0 4px;
+        margin-top: -2px;
+      }
+    }
+  }
+
+  .row {
+    display: flex;
+    margin: 0 -5px;
+
+    .input {
+      box-sizing: border-box;
+      flex: 1 1 auto;
+      width: 50%;
+      padding: 0 5px;
+    }
+  }
+
+  .title {
+    font-size: 28px;
+    line-height: 33px;
+    font-weight: 700;
+    margin-bottom: 15px;
+  }
+
+  .lead {
+    font-size: 17px;
+    line-height: 22px;
+    color: $secondary-text-color;
+    margin-bottom: 30px;
+  }
+
+  .rules-list {
+    font-size: 17px;
+    line-height: 22px;
+    margin-bottom: 30px;
+  }
+
+  .hint {
+    color: $darker-text-color;
+
+    a {
+      color: $highlight-text-color;
+    }
+
+    code {
+      border-radius: 3px;
+      padding: 0.2em 0.4em;
+      background: darken($ui-base-color, 12%);
+    }
+
+    li {
+      list-style: disc;
+      margin-left: 18px;
+    }
+  }
+
+  ul.hint {
+    margin-bottom: 15px;
+  }
+
+  span.hint {
+    display: block;
+    font-size: 12px;
+    margin-top: 4px;
+  }
+
+  p.hint {
+    margin-bottom: 15px;
+    color: $darker-text-color;
+
+    &.subtle-hint {
+      text-align: center;
+      font-size: 12px;
+      line-height: 18px;
+      margin-top: 15px;
+      margin-bottom: 0;
+    }
+  }
+
+  .authentication-hint {
+    margin-bottom: 25px;
+  }
+
+  .card {
+    margin-bottom: 15px;
+  }
+
+  strong {
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+
+  .input.with_floating_label {
+    .label_input {
+      display: flex;
+
+      & > label {
+        font-family: inherit;
+        font-size: 14px;
+        color: $primary-text-color;
+        font-weight: 500;
+        min-width: 150px;
+        flex: 0 0 auto;
+      }
+
+      input,
+      select {
+        flex: 1 1 auto;
+      }
+    }
+
+    &.select .hint {
+      margin-top: 6px;
+      margin-left: 150px;
+    }
+  }
+
+  .input.with_label {
+    .label_input > label {
+      font-family: inherit;
+      font-size: 14px;
+      color: $primary-text-color;
+      display: block;
+      margin-bottom: 8px;
+      word-wrap: break-word;
+      font-weight: 500;
+    }
+
+    .hint {
+      margin-top: 6px;
+    }
+
+    ul {
+      flex: 390px;
+    }
+  }
+
+  .input.with_block_label {
+    max-width: none;
+
+    & > label {
+      font-family: inherit;
+      font-size: 14px;
+      color: $primary-text-color;
+      display: block;
+      font-weight: 500;
+      padding-top: 5px;
+    }
+
+    .hint {
+      margin-bottom: 15px;
+    }
+
+    ul {
+      columns: 2;
+    }
+  }
+
+  .input.with_block_label.user_role_permissions_as_keys ul {
+    columns: unset;
+  }
+
+  .input.datetime .label_input select {
+    display: inline-block;
+    width: auto;
+    flex: 0;
+  }
+
+  .required abbr {
+    text-decoration: none;
+    color: lighten($error-value-color, 12%);
+  }
+
+  .fields-group {
+    margin-bottom: 25px;
+
+    .input:last-child {
+      margin-bottom: 0;
+    }
+
+    &__thumbnail {
+      display: block;
+      margin: 0;
+      margin-bottom: 10px;
+      max-width: 100%;
+      height: auto;
+      border-radius: 4px;
+      background: url('images/void.png');
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+  }
+
+  .fields-row {
+    display: flex;
+    margin: 0 -10px;
+    padding-top: 5px;
+    margin-bottom: 25px;
+
+    .input {
+      max-width: none;
+    }
+
+    &__column {
+      box-sizing: border-box;
+      padding: 0 10px;
+      flex: 1 1 auto;
+      min-height: 1px;
+
+      &-6 {
+        max-width: 50%;
+      }
+
+      .actions {
+        margin-top: 27px;
+      }
+    }
+
+    .fields-group:last-child,
+    .fields-row__column.fields-group {
+      margin-bottom: 0;
+    }
+
+    @media screen and (max-width: $no-columns-breakpoint) {
+      display: block;
+      margin-bottom: 0;
+
+      &__column {
+        max-width: none;
+      }
+
+      .fields-group:last-child,
+      .fields-row__column.fields-group,
+      .fields-row__column {
+        margin-bottom: 25px;
+      }
+    }
+
+    .fields-group.invited-by {
+      margin-bottom: 30px;
+
+      .hint {
+        text-align: center;
+      }
+    }
+  }
+
+  .input.radio_buttons .radio label {
+    margin-bottom: 5px;
+    font-family: inherit;
+    font-size: 14px;
+    color: $primary-text-color;
+    display: block;
+    width: auto;
+  }
+
+  .check_boxes {
+    .checkbox {
+      label {
+        font-family: inherit;
+        font-size: 14px;
+        color: $primary-text-color;
+        display: inline-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.static .label_input__wrapper {
+    font-size: 16px;
+    padding: 10px;
+    border: 1px solid $dark-text-color;
+    border-radius: 4px;
+  }
+
+  input[type='text'],
+  input[type='number'],
+  input[type='email'],
+  input[type='password'],
+  input[type='url'],
+  input[type='datetime-local'],
+  textarea {
+    box-sizing: border-box;
+    font-size: 16px;
+    color: $primary-text-color;
+    display: block;
+    width: 100%;
+    outline: 0;
+    font-family: inherit;
+    resize: vertical;
+    background: darken($ui-base-color, 10%);
+    border: 1px solid darken($ui-base-color, 14%);
+    border-radius: 4px;
+    padding: 10px;
+
+    &::placeholder {
+      color: lighten($darker-text-color, 4%);
+    }
+
+    &:invalid {
+      box-shadow: none;
+    }
+
+    &:required:valid {
+      border-color: $valid-value-color;
+    }
+
+    &:hover {
+      border-color: darken($ui-base-color, 20%);
+    }
+
+    &:active,
+    &:focus {
+      border-color: $highlight-text-color;
+      background: darken($ui-base-color, 8%);
+    }
+  }
+
+  input[type='text'],
+  input[type='number'],
+  input[type='email'],
+  input[type='password'],
+  input[type='datetime-local'] {
+    &:focus:invalid:not(:placeholder-shown),
+    &:required:invalid:not(:placeholder-shown) {
+      border-color: lighten($error-red, 12%);
+    }
+  }
+
+  .input.field_with_errors {
+    label {
+      color: lighten($error-red, 12%);
+    }
+
+    input[type='text'],
+    input[type='number'],
+    input[type='email'],
+    input[type='password'],
+    input[type='datetime-local'],
+    textarea,
+    select {
+      border-color: lighten($error-red, 12%);
+    }
+
+    .error {
+      display: block;
+      font-weight: 500;
+      color: lighten($error-red, 12%);
+      margin-top: 4px;
+    }
+  }
+
+  .input.disabled {
+    opacity: 0.5;
+  }
+
+  .actions {
+    margin-top: 30px;
+    display: flex;
+
+    &.actions--top {
+      margin-top: 0;
+      margin-bottom: 30px;
+    }
+  }
+
+  .stacked-actions {
+    margin-top: 30px;
+    margin-bottom: 15px;
+  }
+
+  button,
+  .button,
+  .block-button {
+    display: block;
+    width: 100%;
+    border: 0;
+    border-radius: 4px;
+    background: darken($ui-highlight-color, 2%);
+    color: $primary-text-color;
+    font-size: 18px;
+    line-height: inherit;
+    height: auto;
+    padding: 10px;
+    text-decoration: none;
+    text-transform: uppercase;
+    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;
+    }
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: $ui-highlight-color;
+    }
+
+    &:disabled:hover {
+      background-color: $ui-primary-color;
+    }
+
+    &.negative {
+      background: $error-value-color;
+
+      &:hover {
+        background-color: lighten($error-value-color, 5%);
+      }
+
+      &:active,
+      &:focus {
+        background-color: darken($error-value-color, 5%);
+      }
+    }
+  }
+
+  .button.button-tertiary {
+    padding: 9px;
+
+    &:hover,
+    &:focus,
+    &:active {
+      padding: 10px;
+    }
+  }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 16px;
+    color: $primary-text-color;
+    display: block;
+    width: 100%;
+    outline: 0;
+    font-family: inherit;
+    resize: vertical;
+    background: darken($ui-base-color, 10%)
+      url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>")
+      no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($ui-base-color, 14%);
+    border-radius: 4px;
+    padding-left: 10px;
+    padding-right: 30px;
+    height: 41px;
+  }
+
+  h4 {
+    margin-bottom: 15px !important;
+  }
+
+  .label_input {
+    &__wrapper {
+      position: relative;
+    }
+
+    &__append {
+      position: absolute;
+      right: 3px;
+      top: 1px;
+      padding: 10px;
+      padding-bottom: 9px;
+      font-size: 16px;
+      color: $dark-text-color;
+      font-family: inherit;
+      pointer-events: none;
+      cursor: default;
+      max-width: 140px;
+      white-space: nowrap;
+      overflow: hidden;
+
+      &::after {
+        content: '';
+        display: block;
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 1px;
+        width: 5px;
+        background-image: linear-gradient(
+          to right,
+          rgba(darken($ui-base-color, 10%), 0),
+          darken($ui-base-color, 10%)
+        );
+      }
+    }
+  }
+}
+
+.block-icon {
+  display: block;
+  margin: 0 auto;
+  margin-bottom: 10px;
+  font-size: 24px;
+}
+
+.flash-message {
+  background: lighten($ui-base-color, 8%);
+  color: $darker-text-color;
+  border-radius: 4px;
+  padding: 15px 10px;
+  margin-bottom: 30px;
+  text-align: center;
+
+  &.notice {
+    border: 1px solid rgba($valid-value-color, 0.5);
+    background: rgba($valid-value-color, 0.25);
+    color: $valid-value-color;
+  }
+
+  &.warning {
+    border: 1px solid rgba($gold-star, 0.5);
+    background: rgba($gold-star, 0.25);
+    color: $gold-star;
+  }
+
+  &.alert {
+    border: 1px solid rgba($error-value-color, 0.5);
+    background: rgba($error-value-color, 0.1);
+    color: $error-value-color;
+  }
+
+  &.hidden {
+    display: none;
+  }
+
+  a {
+    display: inline-block;
+    color: $darker-text-color;
+    text-decoration: none;
+
+    &:hover {
+      color: $primary-text-color;
+      text-decoration: underline;
+    }
+  }
+
+  &.warning a {
+    font-weight: 700;
+    color: inherit;
+    text-decoration: underline;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: none;
+      color: inherit;
+    }
+  }
+
+  p {
+    margin-bottom: 15px;
+  }
+
+  .oauth-code {
+    outline: 0;
+    box-sizing: border-box;
+    display: block;
+    width: 100%;
+    border: 0;
+    padding: 10px;
+    font-family: $font-monospace, monospace;
+    background: $ui-base-color;
+    color: $primary-text-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;
+  }
+
+  &.translation-prompt {
+    text-align: unset;
+    color: unset;
+
+    a {
+      text-decoration: underline;
+    }
+  }
+}
+
+.flash-message-stack {
+  margin-bottom: 30px;
+
+  .flash-message {
+    border-radius: 0;
+    margin-bottom: 0;
+    border-top-width: 0;
+
+    &:first-child {
+      border-radius: 4px 4px 0 0;
+      border-top-width: 1px;
+    }
+
+    &:last-child {
+      border-radius: 0 0 4px 4px;
+
+      &:first-child {
+        border-radius: 4px;
+      }
+    }
+  }
+}
+
+.form-footer {
+  margin-top: 30px;
+  text-align: center;
+
+  a {
+    color: $darker-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.quick-nav {
+  list-style: none;
+  margin-bottom: 25px;
+  font-size: 14px;
+
+  li {
+    display: inline-block;
+    margin-right: 10px;
+  }
+
+  a {
+    color: $highlight-text-color;
+    text-transform: uppercase;
+    text-decoration: none;
+    font-weight: 700;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: lighten($highlight-text-color, 8%);
+    }
+  }
+}
+
+.oauth-prompt,
+.follow-prompt {
+  margin-bottom: 30px;
+  color: $darker-text-color;
+
+  h2 {
+    font-size: 16px;
+    margin-bottom: 30px;
+    text-align: center;
+  }
+
+  strong {
+    color: $secondary-text-color;
+    font-weight: 500;
+
+    @each $lang in $cjk-langs {
+      &:lang(#{$lang}) {
+        font-weight: 700;
+      }
+    }
+  }
+}
+
+.oauth-prompt {
+  h3 {
+    color: $ui-secondary-color;
+    font-size: 17px;
+    line-height: 22px;
+    font-weight: 500;
+    margin-bottom: 30px;
+  }
+
+  p {
+    font-size: 14px;
+    line-height: 18px;
+    margin-bottom: 30px;
+  }
+
+  .permissions-list {
+    border: 1px solid $ui-base-color;
+    border-radius: 4px;
+    background: darken($ui-base-color, 4%);
+    margin-bottom: 30px;
+  }
+
+  .actions {
+    margin: 0 -10px;
+    display: flex;
+
+    form {
+      box-sizing: border-box;
+      padding: 0 10px;
+      flex: 1 1 auto;
+      min-height: 1px;
+      width: 50%;
+    }
+  }
+}
+
+.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: $secondary-text-color;
+  flex: 150px;
+
+  samp {
+    display: block;
+    font-size: 14px;
+  }
+}
+
+.simple_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: $darker-text-color;
+
+  div {
+    margin-bottom: 4px;
+  }
+}
+
+.alternative-login {
+  margin-top: 20px;
+  margin-bottom: 20px;
+
+  h4 {
+    font-size: 16px;
+    color: $primary-text-color;
+    text-align: center;
+    margin-bottom: 20px;
+    border: 0;
+    padding: 0;
+  }
+
+  .button {
+    display: block;
+  }
+}
+
+.scope-danger {
+  color: $warning-red;
+}
+
+.form_admin_settings_site_short_description,
+.form_admin_settings_site_description,
+.form_admin_settings_site_extended_description,
+.form_admin_settings_site_terms,
+.form_admin_settings_custom_css,
+.form_admin_settings_closed_registrations_message {
+  textarea {
+    font-family: $font-monospace, monospace;
+  }
+}
+
+.input-copy {
+  background: darken($ui-base-color, 10%);
+  border: 1px solid darken($ui-base-color, 14%);
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  padding-right: 4px;
+  position: relative;
+  top: 1px;
+  transition: border-color 300ms linear;
+
+  &__wrapper {
+    flex: 1 1 auto;
+  }
+
+  input[type='text'] {
+    background: transparent;
+    border: 0;
+    padding: 10px;
+    font-size: 14px;
+    font-family: $font-monospace, monospace;
+  }
+
+  button {
+    flex: 0 0 auto;
+    margin: 4px;
+    text-transform: none;
+    font-weight: 400;
+    font-size: 14px;
+    padding: 7px 18px;
+    padding-bottom: 6px;
+    width: auto;
+    transition: background 300ms linear;
+  }
+
+  &.copied {
+    border-color: $valid-value-color;
+    transition: none;
+
+    button {
+      background: $valid-value-color;
+      transition: none;
+    }
+  }
+}
+
+.input.user_confirm_password,
+.input.user_website {
+  &:not(.field_with_errors) {
+    display: none;
+  }
+}
+
+.simple_form .h-captcha {
+  text-align: center;
+}
+
+.permissions-list {
+  &__item {
+    padding: 15px;
+    color: $ui-secondary-color;
+    border-bottom: 1px solid lighten($ui-base-color, 4%);
+    display: flex;
+    align-items: center;
+
+    &__text {
+      flex: 1 1 auto;
+
+      &__title {
+        font-weight: 500;
+      }
+
+      &__type {
+        color: $darker-text-color;
+      }
+    }
+
+    &__icon {
+      flex: 0 0 auto;
+      font-size: 18px;
+      width: 30px;
+      color: $valid-value-color;
+      display: flex;
+      align-items: center;
+    }
+
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
+// Only remove padding when listing applications, to prevent styling issues on
+// the Authorization page.
+.applications-list {
+  .permissions-list__item:last-child {
+    padding-bottom: 0;
+  }
+}
+
+.keywords-table {
+  thead {
+    th {
+      white-space: nowrap;
+    }
+
+    th:first-child {
+      width: 100%;
+    }
+  }
+
+  tfoot {
+    td {
+      border: 0;
+    }
+  }
+
+  .input.string {
+    margin-bottom: 0;
+  }
+
+  .label_input__wrapper {
+    margin-top: 10px;
+  }
+
+  .table-action-link {
+    margin-top: 10px;
+    white-space: nowrap;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss
new file mode 100644
index 000000000..1cb913c8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/index.scss
@@ -0,0 +1,24 @@
+@import 'mixins';
+@import 'variables';
+@import 'styles/fonts/roboto';
+@import 'styles/fonts/roboto-mono';
+
+@import 'reset';
+@import 'basics';
+@import 'branding';
+@import 'containers';
+@import 'lists';
+@import 'modal';
+@import 'widgets';
+@import 'forms';
+@import 'accounts';
+@import 'statuses';
+@import 'components/index';
+@import 'polls';
+@import 'about';
+@import 'tables';
+@import 'admin';
+@import 'accessibility';
+@import 'rtl';
+@import 'dashboard';
+@import 'rich_text';
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/mastodon-light.scss b/app/javascript/flavours/glitch/styles/mastodon-light.scss
new file mode 100644
index 000000000..8fc132651
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/mastodon-light.scss
@@ -0,0 +1,3 @@
+@import 'mastodon-light/variables';
+@import 'index';
+@import 'mastodon-light/diff';
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
new file mode 100644
index 000000000..ef248bf4f
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -0,0 +1,777 @@
+// Notes!
+// Sass color functions, "darken" and "lighten" are automatically replaced.
+
+html {
+  scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25);
+}
+
+// Change the colors of button texts
+.button {
+  color: $white;
+
+  &.button-alternative-2 {
+    color: $white;
+  }
+
+  &.button-tertiary {
+    color: $highlight-text-color;
+  }
+}
+
+.simple_form .button.button-tertiary {
+  color: $highlight-text-color;
+}
+
+.status-card__actions button,
+.status-card__actions a {
+  color: rgba($white, 0.8);
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: $white;
+  }
+}
+
+// Change default background colors of columns
+.column > .scrollable,
+.getting-started,
+.column-inline-form,
+.error-column,
+.regeneration-indicator {
+  background: $white;
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-top: 0;
+}
+
+.column > .scrollable.about {
+  border-top: 1px solid lighten($ui-base-color, 8%);
+}
+
+.about__meta,
+.about__section__title,
+.interaction-modal {
+  background: $white;
+  border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.rules-list li::before {
+  background: $ui-highlight-color;
+}
+
+.directory__card__img {
+  background: lighten($ui-base-color, 12%);
+}
+
+.filter-form {
+  background: $white;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
+.column-back-button,
+.column-header {
+  background: $white;
+  border: 1px solid lighten($ui-base-color, 8%);
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    border-top: 0;
+  }
+
+  &--slim-button {
+    top: -50px;
+    right: 0;
+  }
+}
+
+.column-header__back-button,
+.column-header__button,
+.column-header__button.active,
+.account__header {
+  background: $white;
+}
+
+.column-header__button.active {
+  color: $ui-highlight-color;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: $ui-highlight-color;
+    background: $white;
+  }
+}
+
+.account__header__bar .avatar .account__avatar {
+  border-color: $white;
+}
+
+.getting-started__footer a {
+  color: $ui-secondary-color;
+  text-decoration: underline;
+}
+
+.confirmation-modal__secondary-button,
+.confirmation-modal__cancel-button,
+.mute-modal__cancel-button,
+.block-modal__cancel-button {
+  color: lighten($ui-base-color, 26%);
+
+  &:hover,
+  &:focus,
+  &:active {
+    color: $primary-text-color;
+  }
+}
+
+.column-subheading {
+  background: darken($ui-base-color, 4%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
+.getting-started,
+.scrollable {
+  .column-link {
+    background: $white;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+    &:hover,
+    &:active,
+    &:focus {
+      background: $ui-base-color;
+    }
+  }
+}
+
+.getting-started .navigation-bar {
+  border-top: 1px solid lighten($ui-base-color, 8%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    border-top: 0;
+  }
+}
+
+.compose-form__autosuggest-wrapper,
+.poll__option input[type='text'],
+.compose-form .spoiler-input__input,
+.compose-form__poll-wrapper select,
+.search__input,
+.setting-text,
+.report-dialog-modal__textarea,
+.audio-player {
+  border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.report-dialog-modal .dialog-option .poll__input {
+  color: $white;
+}
+
+.search__input {
+  @media screen and (max-width: $no-gap-breakpoint) {
+    border-top: 0;
+    border-bottom: 0;
+  }
+}
+
+.list-editor .search .search__input {
+  border-top: 0;
+  border-bottom: 0;
+}
+
+.compose-form__poll-wrapper select {
+  background: $simple-background-color
+    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>")
+    no-repeat right 8px center / auto 16px;
+}
+
+.compose-form__poll-wrapper,
+.compose-form__poll-wrapper .poll__footer {
+  border-top-color: lighten($ui-base-color, 8%);
+}
+
+.notification__filter-bar {
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-top: 0;
+}
+
+.compose-form .compose-form__buttons-wrapper {
+  background: $ui-base-color;
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-top: 0;
+}
+
+.drawer__header,
+.drawer__inner {
+  background: $white;
+  border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.drawer__inner__mastodon {
+  background: $white
+    url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>')
+    no-repeat bottom / 100% auto;
+}
+
+// Change the colors used in compose-form
+.compose-form {
+  .compose-form__modifiers {
+    .compose-form__upload__actions .icon-button,
+    .compose-form__upload__warning .icon-button {
+      color: lighten($white, 7%);
+
+      &:active,
+      &:focus,
+      &:hover {
+        color: $white;
+      }
+    }
+  }
+
+  .compose-form__buttons-wrapper {
+    background: darken($ui-base-color, 6%);
+  }
+
+  .autosuggest-textarea__suggestions {
+    background: darken($ui-base-color, 6%);
+  }
+
+  .autosuggest-textarea__suggestions__item {
+    &:hover,
+    &:focus,
+    &:active,
+    &.selected {
+      background: lighten($ui-base-color, 4%);
+    }
+  }
+}
+
+.emoji-mart-bar {
+  border-color: lighten($ui-base-color, 4%);
+
+  &:first-child {
+    background: darken($ui-base-color, 6%);
+  }
+}
+
+.emoji-mart-search input {
+  background: rgba($ui-base-color, 0.3);
+  border-color: $ui-base-color;
+}
+
+.upload-progress__backdrop {
+  background: $ui-base-color;
+}
+
+// Change the background colors of statuses
+.focusable:focus {
+  background: $ui-base-color;
+}
+
+.detailed-status,
+.detailed-status__action-bar {
+  background: $white;
+}
+
+// Change the background colors of status__content__spoiler-link
+.reply-indicator__content .status__content__spoiler-link,
+.status__content .status__content__spoiler-link {
+  background: $ui-base-color;
+
+  &:hover,
+  &:focus {
+    background: lighten($ui-base-color, 4%);
+  }
+}
+
+// Change the background colors of media and video spoilers
+.media-spoiler,
+.video-player__spoiler {
+  background: $ui-base-color;
+}
+
+.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {
+  color: $white;
+}
+
+.account-gallery__item a {
+  background-color: $ui-base-color;
+}
+
+// Change the colors used in the dropdown menu
+.dropdown-menu {
+  background: $white;
+
+  &__arrow::before {
+    background-color: $white;
+  }
+
+  &__item {
+    a,
+    button {
+      background: $white;
+      color: $darker-text-color;
+    }
+  }
+}
+
+// Change the text colors on inverted background
+.privacy-dropdown__option.active,
+.privacy-dropdown__option:hover,
+.privacy-dropdown__option.active .privacy-dropdown__option__content,
+.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
+.dropdown-menu__item a:active,
+.dropdown-menu__item a:focus,
+.dropdown-menu__item a:hover,
+.actions-modal ul li:not(:empty) a.active,
+.actions-modal ul li:not(:empty) a.active button,
+.actions-modal ul li:not(:empty) a:active,
+.actions-modal ul li:not(:empty) a:active button,
+.actions-modal ul li:not(:empty) a:focus,
+.actions-modal ul li:not(:empty) a:focus button,
+.actions-modal ul li:not(:empty) a:hover,
+.actions-modal ul li:not(:empty) a:hover button,
+.language-dropdown__dropdown__results__item.active,
+.admin-wrapper .sidebar ul .simple-navigation-active-leaf a,
+.simple_form .block-button,
+.simple_form .button,
+.simple_form button {
+  color: $white;
+}
+
+.language-dropdown__dropdown__results__item
+  .language-dropdown__dropdown__results__item__common-name {
+  color: lighten($ui-base-color, 8%);
+}
+
+.language-dropdown__dropdown__results__item.active
+  .language-dropdown__dropdown__results__item__common-name {
+  color: darken($ui-base-color, 12%);
+}
+
+.dropdown-menu__separator,
+.dropdown-menu__item.edited-timestamp__history__item,
+.dropdown-menu__container__header,
+.compare-history-modal .report-modal__target,
+.report-dialog-modal .poll__option.dialog-option {
+  border-bottom-color: lighten($ui-base-color, 4%);
+}
+
+.report-dialog-modal__container {
+  border-top-color: lighten($ui-base-color, 4%);
+}
+
+// Change the background colors of modals
+.actions-modal,
+.boost-modal,
+.confirmation-modal,
+.mute-modal,
+.block-modal,
+.report-modal,
+.report-dialog-modal,
+.embed-modal,
+.error-modal,
+.onboarding-modal,
+.compare-history-modal,
+.report-modal__comment .setting-text__wrapper,
+.report-modal__comment .setting-text,
+.announcements,
+.picture-in-picture__header,
+.picture-in-picture__footer,
+.reactions-bar__item {
+  background: $white;
+  border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.reactions-bar__item:hover,
+.reactions-bar__item:focus,
+.reactions-bar__item:active,
+.language-dropdown__dropdown__results__item:hover,
+.language-dropdown__dropdown__results__item:focus,
+.language-dropdown__dropdown__results__item:active {
+  background-color: $ui-base-color;
+}
+
+.reactions-bar__item.active {
+  background-color: mix($white, $ui-highlight-color, 80%);
+  border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%);
+}
+
+.media-modal__overlay .picture-in-picture__footer {
+  border: 0;
+}
+
+.picture-in-picture__header {
+  border-bottom: 0;
+}
+
+.announcements,
+.picture-in-picture__footer {
+  border-top: 0;
+}
+
+.icon-with-badge__badge {
+  border-color: $white;
+  color: $white;
+}
+
+.report-modal__comment {
+  border-right-color: lighten($ui-base-color, 8%);
+}
+
+.report-modal__container {
+  border-top-color: lighten($ui-base-color, 8%);
+}
+
+.column-header__collapsible-inner {
+  background: darken($ui-base-color, 4%);
+  border: 1px solid lighten($ui-base-color, 8%);
+  border-top: 0;
+}
+
+.dashboard__quick-access,
+.focal-point__preview strong,
+.admin-wrapper .content__heading__tabs a.selected {
+  color: $white;
+}
+
+.button.button-tertiary {
+  &:hover,
+  &:focus,
+  &:active {
+    color: $white;
+  }
+}
+
+.button.button-secondary {
+  border-color: $darker-text-color;
+  color: $darker-text-color;
+
+  &:hover,
+  &:focus,
+  &:active {
+    border-color: darken($darker-text-color, 8%);
+    color: darken($darker-text-color, 8%);
+  }
+}
+
+.flash-message.warning {
+  color: lighten($gold-star, 16%);
+}
+
+.boost-modal__action-bar,
+.confirmation-modal__action-bar,
+.mute-modal__action-bar,
+.block-modal__action-bar,
+.onboarding-modal__paginator,
+.error-modal__footer {
+  background: darken($ui-base-color, 6%);
+
+  .onboarding-modal__nav,
+  .error-modal__nav {
+    &:hover,
+    &:focus,
+    &:active {
+      background-color: darken($ui-base-color, 12%);
+    }
+  }
+}
+
+.display-case__case {
+  background: $white;
+}
+
+.embed-modal .embed-modal__container .embed-modal__html {
+  background: $white;
+  border: 1px solid lighten($ui-base-color, 8%);
+
+  &:focus {
+    border-color: lighten($ui-base-color, 12%);
+    background: $white;
+  }
+}
+
+.react-toggle-track {
+  background: $ui-secondary-color;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: darken($ui-secondary-color, 10%);
+}
+
+.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled)
+  .react-toggle-track {
+  background: lighten($ui-highlight-color, 10%);
+}
+
+// Change the default color used for the text in an empty column or on the error column
+.empty-column-indicator,
+.error-column {
+  color: $primary-text-color;
+  background: $white;
+}
+
+// Change the default colors used on some parts of the profile pages
+.activity-stream-tabs {
+  background: $account-background-color;
+  border-bottom-color: lighten($ui-base-color, 8%);
+}
+
+.nothing-here,
+.page-header,
+.directory__tag > a,
+.directory__tag > div {
+  background: $white;
+  border: 1px solid lighten($ui-base-color, 8%);
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    border-left: 0;
+    border-right: 0;
+    border-top: 0;
+  }
+}
+
+.simple_form {
+  input[type='text'],
+  input[type='number'],
+  input[type='email'],
+  input[type='password'],
+  textarea {
+    &:hover {
+      border-color: lighten($ui-base-color, 12%);
+    }
+  }
+}
+
+.picture-in-picture-placeholder {
+  background: $white;
+  border-color: lighten($ui-base-color, 8%);
+  color: lighten($ui-base-color, 8%);
+}
+
+.directory__tag > a {
+  &:hover,
+  &:active,
+  &:focus {
+    background: $ui-base-color;
+  }
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    border: 0;
+  }
+}
+
+.batch-table {
+  &__toolbar,
+  &__row,
+  .nothing-here {
+    border-color: lighten($ui-base-color, 8%);
+  }
+}
+
+.activity-stream {
+  border: 1px solid lighten($ui-base-color, 8%);
+
+  &--under-tabs {
+    border-top: 0;
+  }
+
+  .entry {
+    background: $account-background-color;
+
+    .detailed-status.light,
+    .more.light,
+    .status.light {
+      border-bottom-color: lighten($ui-base-color, 8%);
+    }
+  }
+
+  .status.light {
+    .status__content {
+      color: $primary-text-color;
+    }
+
+    .display-name {
+      strong {
+        color: $primary-text-color;
+      }
+    }
+  }
+}
+
+.accounts-grid {
+  .account-grid-card {
+    .controls {
+      .icon-button {
+        color: $darker-text-color;
+      }
+    }
+
+    .name {
+      a {
+        color: $primary-text-color;
+      }
+    }
+
+    .username {
+      color: $darker-text-color;
+    }
+
+    .account__header__content {
+      color: $primary-text-color;
+    }
+  }
+}
+
+.simple_form {
+  .warning {
+    box-shadow: none;
+    background: rgba($error-red, 0.5);
+    text-shadow: none;
+  }
+
+  .recommended {
+    border-color: $ui-highlight-color;
+    color: $ui-highlight-color;
+    background-color: rgba($ui-highlight-color, 0.1);
+  }
+}
+
+.compose-form .compose-form__warning {
+  border-color: $ui-highlight-color;
+  background-color: rgba($ui-highlight-color, 0.1);
+
+  &,
+  a {
+    color: $ui-highlight-color;
+  }
+}
+
+.reply-indicator {
+  background: transparent;
+  border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.dismissable-banner {
+  border-left: 1px solid lighten($ui-base-color, 8%);
+  border-right: 1px solid lighten($ui-base-color, 8%);
+}
+
+.status__content,
+.reply-indicator__content {
+  a {
+    color: $highlight-text-color;
+  }
+}
+
+.button.logo-button {
+  color: $white;
+
+  svg {
+    fill: $white;
+  }
+}
+
+.notification__filter-bar button.active::after,
+.account__section-headline a.active::after {
+  border-color: transparent transparent $white;
+}
+
+.hero-widget,
+.moved-account-widget,
+.memoriam-widget,
+.activity-stream,
+.nothing-here,
+.directory__tag > a,
+.directory__tag > div,
+.card > a,
+.page-header,
+.compose-form .compose-form__warning {
+  box-shadow: none;
+}
+
+.mute-modal select {
+  border: 1px solid lighten($ui-base-color, 8%);
+  background: $simple-background-color
+    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>")
+    no-repeat right 8px center / auto 16px;
+}
+
+// Glitch-soc-specific changes
+
+.pillbar-button {
+  background: $ui-secondary-color;
+
+  &:not([disabled]) {
+    &:hover,
+    &:focus {
+      background: darken($ui-secondary-color, 10%);
+    }
+
+    &.active {
+      background-color: darken($ui-highlight-color, 2%);
+
+      &:hover,
+      &:focus {
+        background: lighten($ui-highlight-color, 10%);
+      }
+    }
+  }
+}
+
+.glitch.local-settings {
+  background: $ui-base-color;
+  border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.glitch.local-settings__navigation {
+  background: darken($ui-base-color, 8%);
+}
+
+.glitch.local-settings__navigation__item {
+  background: darken($ui-base-color, 8%);
+  border-bottom: 1px lighten($ui-base-color, 8%) solid;
+
+  &:hover {
+    background: $ui-base-color;
+  }
+
+  &.active {
+    background: $ui-highlight-color;
+    color: $white;
+  }
+
+  &.close,
+  &.close:hover {
+    background: $error-value-color;
+    color: $primary-text-color;
+  }
+}
+
+.notification__dismiss-overlay {
+  .wrappy {
+    box-shadow: unset;
+
+    .ckbox {
+      text-shadow: unset;
+    }
+  }
+}
+
+.status.collapsed .status__content::after {
+  background: linear-gradient(
+    rgba(darken($ui-base-color, 13%), 0),
+    rgba(darken($ui-base-color, 13%), 1)
+  );
+}
+
+.drawer__inner__mastodon {
+  background: $white
+    url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>')
+    no-repeat bottom / 100% auto !important;
+
+  .mastodon {
+    filter: contrast(75%) brightness(75%) !important;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
new file mode 100644
index 000000000..cae065878
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
@@ -0,0 +1,44 @@
+// Dependent colors
+$black: #000000;
+$white: #ffffff;
+
+$classic-base-color: #282c37;
+$classic-primary-color: #9baec8;
+$classic-secondary-color: #d9e1e8;
+$classic-highlight-color: #6364ff;
+
+// Differences
+$success-green: lighten(#3c754d, 8%);
+
+$base-overlay-background: $white !default;
+$valid-value-color: $success-green !default;
+
+$ui-base-color: $classic-secondary-color !default;
+$ui-base-lighter-color: #b0c0cf;
+$ui-primary-color: #9bcbed;
+$ui-secondary-color: $classic-base-color !default;
+$ui-highlight-color: $classic-highlight-color !default;
+
+$primary-text-color: $black !default;
+$darker-text-color: $classic-base-color !default;
+$highlight-text-color: darken($ui-highlight-color, 8%) !default;
+$dark-text-color: #444b5d;
+$action-button-color: #606984;
+
+$inverted-text-color: $black !default;
+$lighter-text-color: $classic-base-color !default;
+$light-text-color: #444b5d;
+
+// Newly added colors
+$account-background-color: $white !default;
+
+// Invert darkened and lightened colors
+@function darken($color, $amount) {
+  @return hsl(hue($color), saturation($color), lightness($color) + $amount);
+}
+
+@function lighten($color, $amount) {
+  @return hsl(hue($color), saturation($color), lightness($color) - $amount);
+}
+
+$emojis-requiring-inversion: 'chains';
diff --git a/app/javascript/flavours/glitch/styles/modal.scss b/app/javascript/flavours/glitch/styles/modal.scss
new file mode 100644
index 000000000..6170877b2
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/modal.scss
@@ -0,0 +1,37 @@
+.modal-layout {
+  background: $ui-base-color
+    url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>')
+    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;
+
+  > div {
+    flex: 1;
+    max-height: 235px;
+    position: relative;
+
+    img {
+      max-height: 100%;
+      max-width: 100%;
+      height: 100%;
+      position: absolute;
+      bottom: 0;
+      left: 0;
+    }
+  }
+}
+
+@media screen and (max-width: 600px) {
+  .account-header {
+    margin-top: 0;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
new file mode 100644
index 000000000..a4ce14a09
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -0,0 +1,298 @@
+.poll {
+  margin-top: 16px;
+  font-size: 14px;
+
+  ul,
+  .e-content & ul {
+    margin: 0;
+    list-style: none;
+  }
+
+  li {
+    margin-bottom: 10px;
+    position: relative;
+  }
+
+  &__chart {
+    border-radius: 4px;
+    display: block;
+    background: darken($ui-primary-color, 5%);
+    height: 5px;
+    min-width: 1%;
+
+    &.leading {
+      background: $ui-highlight-color;
+    }
+  }
+
+  progress {
+    border: 0;
+    display: block;
+    width: 100%;
+    height: 5px;
+    appearance: none;
+    background: transparent;
+
+    &::-webkit-progress-bar {
+      background: transparent;
+    }
+
+    // Those rules need to be entirely separate or they won't work, hence the
+    // duplication
+    &::-moz-progress-bar {
+      border-radius: 4px;
+      background: darken($ui-primary-color, 5%);
+    }
+
+    &::-ms-fill {
+      border-radius: 4px;
+      background: darken($ui-primary-color, 5%);
+    }
+
+    &::-webkit-progress-value {
+      border-radius: 4px;
+      background: darken($ui-primary-color, 5%);
+    }
+  }
+
+  &__option {
+    position: relative;
+    display: flex;
+    padding: 6px 0;
+    line-height: 18px;
+    cursor: default;
+    overflow: hidden;
+
+    &__text {
+      display: inline-block;
+      word-wrap: break-word;
+      overflow-wrap: break-word;
+      max-width: calc(100% - 45px - 25px);
+    }
+
+    input[type='radio'],
+    input[type='checkbox'] {
+      display: none;
+    }
+
+    .autosuggest-input {
+      flex: 1 1 auto;
+    }
+
+    input[type='text'] {
+      display: block;
+      box-sizing: border-box;
+      width: 100%;
+      font-size: 14px;
+      color: $inverted-text-color;
+      outline: 0;
+      font-family: inherit;
+      background: $simple-background-color;
+      border: 1px solid darken($simple-background-color, 14%);
+      border-radius: 4px;
+      padding: 6px 10px;
+
+      &:focus {
+        border-color: $highlight-text-color;
+      }
+    }
+
+    &.selectable {
+      cursor: pointer;
+    }
+
+    &.editable {
+      display: flex;
+      align-items: center;
+      overflow: visible;
+    }
+  }
+
+  &__input {
+    display: inline-block;
+    position: relative;
+    border: 1px solid $ui-primary-color;
+    box-sizing: border-box;
+    width: 18px;
+    height: 18px;
+    margin-inline-end: 10px;
+    top: -1px;
+    border-radius: 50%;
+    vertical-align: middle;
+    margin-top: auto;
+    margin-bottom: auto;
+    flex: 0 0 18px;
+
+    &.checkbox {
+      border-radius: 4px;
+    }
+
+    &.active {
+      border-color: $valid-value-color;
+      background: $valid-value-color;
+    }
+
+    &:active,
+    &:focus,
+    &:hover {
+      border-color: lighten($valid-value-color, 15%);
+      border-width: 4px;
+    }
+
+    &::-moz-focus-inner {
+      outline: 0 !important;
+      border: 0;
+    }
+
+    &:focus,
+    &:active {
+      outline: 0 !important;
+    }
+
+    &.disabled {
+      border-color: $dark-text-color;
+
+      &.active {
+        background: $dark-text-color;
+      }
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $dark-text-color;
+        border-width: 1px;
+      }
+    }
+  }
+
+  &__number {
+    display: inline-block;
+    width: 45px;
+    font-weight: 700;
+    flex: 0 0 45px;
+  }
+
+  &__voted {
+    padding: 0 5px;
+    display: inline-block;
+
+    &__mark {
+      font-size: 18px;
+    }
+  }
+
+  &__footer {
+    padding-top: 6px;
+    padding-bottom: 5px;
+    color: $dark-text-color;
+  }
+
+  &__link {
+    display: inline;
+    background: transparent;
+    padding: 0;
+    margin: 0;
+    border: 0;
+    color: $dark-text-color;
+    text-decoration: underline;
+    font-size: inherit;
+
+    &:hover {
+      text-decoration: none;
+    }
+
+    &:active,
+    &:focus {
+      background-color: rgba($dark-text-color, 0.1);
+    }
+  }
+
+  .button {
+    height: 36px;
+    padding: 0 16px;
+    margin-inline-end: 10px;
+    font-size: 14px;
+  }
+}
+
+.compose-form__poll-wrapper {
+  border-top: 1px solid darken($simple-background-color, 8%);
+  overflow-x: hidden;
+
+  ul {
+    padding: 10px;
+  }
+
+  .poll__footer {
+    border-top: 1px solid darken($simple-background-color, 8%);
+    padding: 10px;
+    display: flex;
+    align-items: center;
+
+    button,
+    select {
+      width: 100%;
+      flex: 1 1 50%;
+
+      &:focus {
+        border-color: $highlight-text-color;
+      }
+    }
+  }
+
+  .button.button-secondary {
+    font-size: 14px;
+    font-weight: 400;
+    padding: 6px 10px;
+    height: auto;
+    line-height: inherit;
+    color: $action-button-color;
+    border-color: $action-button-color;
+    margin-inline-end: 5px;
+  }
+
+  li {
+    display: flex;
+    align-items: center;
+
+    .poll__option {
+      flex: 0 0 auto;
+      width: calc(100% - (23px + 6px));
+      margin-inline-end: 6px;
+    }
+  }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 14px;
+    color: $inverted-text-color;
+    display: inline-block;
+    width: auto;
+    outline: 0;
+    font-family: inherit;
+    background: $simple-background-color
+      url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>")
+      no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($simple-background-color, 14%);
+    border-radius: 4px;
+    padding: 6px 10px;
+    padding-right: 30px;
+  }
+
+  .icon-button.disabled {
+    color: darken($simple-background-color, 14%);
+  }
+}
+
+.muted .poll {
+  color: $dark-text-color;
+
+  &__chart {
+    background: rgba(darken($ui-primary-color, 14%), 0.7);
+
+    &.leading {
+      background: rgba($ui-highlight-color, 0.5);
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/reset.scss b/app/javascript/flavours/glitch/styles/reset.scss
new file mode 100644
index 000000000..f54ed5bc7
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/reset.scss
@@ -0,0 +1,95 @@
+/* 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;
+}
+
+html {
+  scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);
+}
+
+::-webkit-scrollbar {
+  width: 12px;
+  height: 12px;
+}
+
+::-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/rich_text.scss b/app/javascript/flavours/glitch/styles/rich_text.scss
new file mode 100644
index 000000000..e60818353
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/rich_text.scss
@@ -0,0 +1,99 @@
+.status__content__text,
+.e-content,
+.reply-indicator__content {
+  pre,
+  blockquote {
+    margin-bottom: 20px;
+    white-space: pre-wrap;
+    unicode-bidi: plaintext;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  blockquote {
+    padding-left: 10px;
+    border-left: 3px solid $darker-text-color;
+    color: $darker-text-color;
+    white-space: normal;
+
+    p:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  & > ul,
+  & > ol {
+    margin-bottom: 20px;
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5 {
+    margin-top: 20px;
+    margin-bottom: 20px;
+  }
+
+  h1,
+  h2 {
+    font-weight: 700;
+    font-size: 1.2em;
+  }
+
+  h2 {
+    font-size: 1.1em;
+  }
+
+  h3,
+  h4,
+  h5 {
+    font-weight: 500;
+  }
+
+  b,
+  strong {
+    font-weight: 700;
+  }
+
+  em,
+  i {
+    font-style: italic;
+  }
+
+  sub {
+    font-size: smaller;
+    vertical-align: sub;
+  }
+
+  sup {
+    font-size: smaller;
+    vertical-align: super;
+  }
+
+  ul,
+  ol {
+    margin-left: 2em;
+
+    p {
+      margin: 0;
+    }
+  }
+
+  ul {
+    list-style-type: disc;
+  }
+
+  ol {
+    list-style-type: decimal;
+  }
+}
+
+.reply-indicator__content {
+  blockquote {
+    border-left-color: $inverted-text-color;
+    color: $inverted-text-color;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
new file mode 100644
index 000000000..64a5c2c03
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -0,0 +1,371 @@
+body.rtl {
+  direction: rtl;
+
+  .column-header > button {
+    text-align: right;
+    padding-left: 0;
+    padding-right: 15px;
+  }
+
+  .radio-button__input {
+    margin-right: 0;
+    margin-left: 10px;
+  }
+
+  .display-name {
+    text-align: right;
+  }
+
+  .notification__message {
+    margin-left: 0;
+    margin-right: 68px;
+  }
+
+  .drawer__inner__mastodon > img {
+    transform: scaleX(-1);
+  }
+
+  .notification__favourite-icon-wrapper {
+    left: auto;
+    right: -26px;
+  }
+
+  .column-link__icon,
+  .column-header__icon {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .compose-form .character-counter__wrapper {
+    margin-right: 0;
+    margin-left: 4px;
+  }
+
+  .boost-modal__status-time {
+    float: left;
+  }
+
+  .navigation-bar__profile {
+    margin-left: 0;
+    margin-right: 8px;
+  }
+
+  .search__input {
+    padding-right: 10px;
+    padding-left: 30px;
+  }
+
+  .search__icon .fa {
+    right: auto;
+    left: 10px;
+  }
+
+  .columns-area {
+    direction: rtl;
+  }
+
+  .column-header__buttons {
+    left: 0;
+    right: auto;
+    margin-left: 0;
+    margin-right: -15px;
+  }
+
+  .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;
+
+    .column-header__setting-btn {
+      &:first-child {
+        padding-left: 7px;
+        padding-right: 5px;
+      }
+
+      &:last-child {
+        padding-right: 7px;
+        padding-left: 5px;
+        margin-right: 5px;
+        margin-left: 0;
+      }
+    }
+  }
+
+  .setting-toggle__label {
+    margin-left: 0;
+    margin-right: 8px;
+  }
+
+  .setting-meta__label {
+    float: left;
+  }
+
+  .status__avatar {
+    margin-left: 10px;
+    margin-right: 0;
+
+    // Those are used for public pages
+    left: auto;
+    right: 10px;
+  }
+
+  .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: 58px;
+  }
+
+  .status__prepend-icon-wrapper {
+    left: auto;
+    right: -26px;
+  }
+
+  .activity-stream .pre-header .pre-header__icon {
+    left: auto;
+    right: 42px;
+  }
+
+  .account__header__tabs__buttons > .icon-button {
+    margin-right: 0;
+    margin-left: 8px;
+  }
+
+  .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;
+    text-align: left;
+  }
+
+  .status__action-bar {
+    &__counter {
+      margin-right: 0;
+      margin-left: 11px;
+
+      .status__action-bar-button {
+        margin-right: 0;
+        margin-left: 4px;
+      }
+    }
+  }
+
+  .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-name .display-name {
+    text-align: right;
+  }
+
+  .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: 2.14285714em;
+  }
+
+  .fa-li {
+    left: auto;
+    right: -2.14285714em;
+  }
+
+  .admin-wrapper {
+    direction: rtl;
+  }
+
+  .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 {
+    padding-left: 0;
+    padding-right: 25px;
+  }
+
+  .simple_form .input.with_label.boolean label.checkbox {
+    padding-left: 25px;
+    padding-right: 0;
+  }
+
+  .simple_form .check_boxes .checkbox input[type='checkbox'],
+  .simple_form .input.boolean input[type='checkbox'] {
+    left: auto;
+    right: 0;
+  }
+
+  .simple_form .input.radio_buttons .radio {
+    left: auto;
+    right: 0;
+  }
+
+  .simple_form .input.radio_buttons .radio > label {
+    padding-right: 28px;
+    padding-left: 0;
+  }
+
+  .simple_form .input-with-append .input input {
+    padding-left: 142px;
+    padding-right: 0;
+  }
+
+  .simple_form .input.boolean label.checkbox {
+    left: auto;
+    right: 0;
+  }
+
+  .simple_form .input.boolean .label_input,
+  .simple_form .input.boolean .hint {
+    padding-left: 0;
+    padding-right: 28px;
+  }
+
+  .simple_form .label_input__append {
+    right: auto;
+    left: 3px;
+
+    &::after {
+      right: auto;
+      left: 0;
+      background-image: linear-gradient(
+        to left,
+        rgba(darken($ui-base-color, 10%), 0),
+        darken($ui-base-color, 10%)
+      );
+    }
+  }
+
+  .simple_form select {
+    background: darken($ui-base-color, 10%)
+      url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>")
+      no-repeat left 8px center / auto 16px;
+  }
+
+  .table th,
+  .table td {
+    text-align: right;
+  }
+
+  .filters .filter-subset {
+    margin-right: 0;
+    margin-left: 45px;
+  }
+
+  @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;
+      }
+    }
+  }
+
+  .columns-area--mobile .column,
+  .columns-area--mobile .drawer {
+    padding-left: 0;
+    padding-right: 0;
+  }
+
+  .card__bar .display-name {
+    margin-left: 0;
+    margin-right: 15px;
+    text-align: right;
+  }
+
+  .fa-chevron-left::before {
+    content: '\F054';
+  }
+
+  .fa-chevron-right::before {
+    content: '\F053';
+  }
+
+  .column-back-button__icon {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .simple_form .input.radio_buttons .radio > label input {
+    left: auto;
+    right: 0;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/statuses.scss b/app/javascript/flavours/glitch/styles/statuses.scss
new file mode 100644
index 000000000..f7037d9dc
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/statuses.scss
@@ -0,0 +1,278 @@
+.activity-stream {
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  border-radius: 4px;
+  overflow: hidden;
+  margin-bottom: 10px;
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    margin-bottom: 0;
+    border-radius: 0;
+    box-shadow: none;
+  }
+
+  &--headless {
+    border-radius: 0;
+    margin: 0;
+    box-shadow: none;
+
+    .detailed-status,
+    .status {
+      border-radius: 0 !important;
+    }
+  }
+
+  div[data-component] {
+    width: 100%;
+  }
+
+  .entry {
+    background: $ui-base-color;
+
+    .detailed-status,
+    .status,
+    .load-more {
+      animation: none;
+    }
+
+    &:last-child {
+      .detailed-status,
+      .status,
+      .load-more {
+        border-bottom: 0;
+        border-radius: 0 0 4px 4px;
+      }
+    }
+
+    &:first-child {
+      .detailed-status,
+      .status,
+      .load-more {
+        border-radius: 4px 4px 0 0;
+      }
+
+      &:last-child {
+        .detailed-status,
+        .status,
+        .load-more {
+          border-radius: 4px;
+        }
+      }
+    }
+
+    @media screen and (max-width: 740px) {
+      .detailed-status,
+      .status,
+      .load-more {
+        border-radius: 0 !important;
+      }
+    }
+  }
+
+  &--highlighted .entry {
+    background: lighten($ui-base-color, 8%);
+  }
+}
+
+.button.logo-button {
+  flex: 0 auto;
+  font-size: 14px;
+  background: darken($ui-highlight-color, 2%);
+  color: $primary-text-color;
+  text-transform: none;
+  line-height: 1.2;
+  height: auto;
+  min-height: 36px;
+  min-width: 88px;
+  white-space: normal;
+  overflow-wrap: break-word;
+  hyphens: auto;
+  padding: 0 15px;
+  border: 0;
+
+  svg {
+    width: 20px;
+    height: auto;
+    vertical-align: middle;
+    margin-right: 5px;
+    fill: $primary-text-color;
+  }
+
+  &:active,
+  &:focus,
+  &:hover {
+    background: $ui-highlight-color;
+  }
+
+  &:disabled,
+  &.disabled {
+    &:active,
+    &:focus,
+    &:hover {
+      background: $ui-primary-color;
+    }
+  }
+
+  &.button--destructive {
+    &:active,
+    &:focus,
+    &:hover {
+      background: $error-red;
+    }
+  }
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    svg {
+      display: none;
+    }
+  }
+}
+
+a.button.logo-button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.embed {
+  .status__content[data-spoiler='folded'] {
+    .e-content {
+      display: none;
+    }
+
+    p:first-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .detailed-status {
+    padding: 15px;
+
+    .detailed-status__display-avatar .account__avatar {
+      width: 48px;
+      height: 48px;
+    }
+  }
+
+  .status {
+    padding: 15px 15px 15px (48px + 15px * 2);
+    min-height: 48px + 2px;
+
+    &__avatar {
+      left: 15px;
+      top: 17px;
+
+      .account__avatar {
+        width: 48px;
+        height: 48px;
+      }
+    }
+
+    &__content {
+      padding-top: 5px;
+    }
+
+    &__prepend {
+      padding: 8px 0;
+      padding-bottom: 2px;
+      margin: initial;
+      margin-left: 48px + 15px * 2;
+      padding-top: 15px;
+    }
+
+    &__prepend-icon-wrapper {
+      position: absolute;
+      margin: initial;
+      float: initial;
+      width: auto;
+      left: -32px;
+    }
+
+    .media-gallery,
+    &__action-bar,
+    .video-player {
+      margin-top: 10px;
+    }
+
+    &__action-bar-button {
+      font-size: 18px;
+      width: 23.1429px;
+      height: 23.1429px;
+      line-height: 23.15px;
+    }
+  }
+}
+
+// Styling from upstream's WebUI, as public pages use the same layout
+.embed {
+  .status {
+    .status__info {
+      font-size: 15px;
+      display: initial;
+    }
+
+    .status__relative-time {
+      color: $dark-text-color;
+      float: right;
+      font-size: 14px;
+      width: auto;
+      margin: initial;
+      padding: initial;
+      padding-bottom: 1px;
+    }
+
+    .status__visibility-icon {
+      padding: 0 4px;
+    }
+
+    .status__info .status__display-name {
+      display: block;
+      max-width: 100%;
+      padding: 6px 0;
+      padding-right: 25px;
+      margin: initial;
+    }
+
+    .status__avatar {
+      height: 48px;
+      position: absolute;
+      width: 48px;
+      margin: initial;
+    }
+  }
+}
+
+.rtl {
+  .embed {
+    .status {
+      padding-left: 10px;
+      padding-right: 68px;
+
+      .status__info .status__display-name {
+        padding-left: 25px;
+        padding-right: 0;
+      }
+
+      .status__relative-time,
+      .status__visibility-icon {
+        float: left;
+      }
+    }
+  }
+}
+
+.status__content__read-more-button {
+  display: block;
+  font-size: 15px;
+  line-height: 20px;
+  color: $highlight-text-color;
+  border: 0;
+  background: transparent;
+  padding: 0;
+  padding-top: 16px;
+  text-decoration: none;
+
+  &:hover,
+  &:active {
+    text-decoration: underline;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
new file mode 100644
index 000000000..14daf591e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -0,0 +1,371 @@
+.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;
+    background: darken($ui-base-color, 4%);
+  }
+
+  & > 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: $highlight-text-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,
+      & > th {
+        background: transparent;
+      }
+    }
+
+    & > tbody > tr:first-child {
+      & > td,
+      & > th {
+        border-top: 0;
+      }
+    }
+  }
+
+  &.horizontal-table {
+    border-collapse: collapse;
+    border-style: hidden;
+
+    & > tbody > tr > th,
+    & > tbody > tr > td {
+      padding: 11px 10px;
+      background: transparent;
+      border: 1px solid lighten($ui-base-color, 8%);
+      color: $secondary-text-color;
+    }
+
+    & > tbody > tr > th {
+      color: $darker-text-color;
+      font-weight: 600;
+    }
+  }
+
+  &.batch-table {
+    & > thead > tr > th {
+      background: $ui-base-color;
+      border-top: 1px solid darken($ui-base-color, 8%);
+      border-bottom: 1px solid darken($ui-base-color, 8%);
+
+      &:first-child {
+        border-radius: 4px 0 0;
+        border-left: 1px solid darken($ui-base-color, 8%);
+      }
+
+      &:last-child {
+        border-radius: 0 4px 0 0;
+        border-right: 1px solid darken($ui-base-color, 8%);
+      }
+    }
+  }
+
+  &--invites tbody td {
+    vertical-align: middle;
+  }
+}
+
+.table-wrapper {
+  overflow: auto;
+  margin-bottom: 20px;
+}
+
+samp {
+  font-family: $font-monospace, monospace;
+}
+
+button.table-action-link {
+  background: transparent;
+  border: 0;
+  font: inherit;
+}
+
+button.table-action-link,
+a.table-action-link {
+  text-decoration: none;
+  display: inline-block;
+  margin-right: 5px;
+  padding: 0 10px;
+  color: $darker-text-color;
+  font-weight: 500;
+
+  &:hover {
+    color: $primary-text-color;
+  }
+
+  i.fa {
+    font-weight: 400;
+    margin-right: 5px;
+  }
+
+  &:first-child {
+    padding-left: 0;
+  }
+}
+
+.batch-table {
+  &__toolbar,
+  &__row {
+    display: flex;
+
+    &__select {
+      box-sizing: border-box;
+      padding: 8px 16px;
+      cursor: pointer;
+      min-height: 100%;
+
+      input {
+        margin-top: 8px;
+      }
+
+      &--aligned {
+        display: flex;
+        align-items: center;
+
+        input {
+          margin-top: 0;
+        }
+      }
+    }
+
+    &__actions,
+    &__content {
+      padding: 8px 0;
+      padding-right: 16px;
+      flex: 1 1 auto;
+    }
+  }
+
+  &__toolbar {
+    position: sticky;
+    top: 0;
+    z-index: 1;
+    border: 1px solid darken($ui-base-color, 8%);
+    background: $ui-base-color;
+    border-radius: 4px 0 0;
+    height: 47px;
+    align-items: center;
+
+    &__actions {
+      text-align: right;
+      padding-right: 16px - 5px;
+    }
+  }
+
+  &__select-all {
+    background: $ui-base-color;
+    height: 47px;
+    align-items: center;
+    justify-content: center;
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    color: $secondary-text-color;
+    display: none;
+
+    &.active {
+      display: flex;
+    }
+
+    .selected,
+    .not-selected {
+      display: none;
+
+      &.active {
+        display: block;
+      }
+    }
+
+    strong {
+      font-weight: 700;
+    }
+
+    span {
+      padding: 8px;
+      display: inline-block;
+    }
+
+    button {
+      background: transparent;
+      border: 0;
+      font: inherit;
+      color: $highlight-text-color;
+      border-radius: 4px;
+      font-weight: 700;
+      padding: 8px;
+
+      &:hover,
+      &:focus,
+      &:active {
+        background: lighten($ui-base-color, 8%);
+      }
+    }
+  }
+
+  &__form {
+    padding: 16px;
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    background: $ui-base-color;
+
+    .fields-row {
+      padding-top: 0;
+      margin-bottom: 0;
+    }
+  }
+
+  &__row {
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    background: darken($ui-base-color, 4%);
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      .optional &:first-child {
+        border-top: 1px solid darken($ui-base-color, 8%);
+      }
+    }
+
+    &:hover {
+      background: darken($ui-base-color, 2%);
+    }
+
+    &:nth-child(even) {
+      background: $ui-base-color;
+
+      &:hover {
+        background: lighten($ui-base-color, 2%);
+      }
+    }
+
+    &__content {
+      padding-top: 12px;
+      padding-bottom: 16px;
+      overflow: hidden;
+
+      &--unpadded {
+        padding: 0;
+      }
+
+      &--with-image {
+        display: flex;
+        align-items: center;
+      }
+
+      &__image {
+        flex: 0 0 auto;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        margin-right: 10px;
+
+        .emojione {
+          width: 32px;
+          height: 32px;
+        }
+      }
+
+      &__text {
+        flex: 1 1 auto;
+      }
+
+      &__quote {
+        padding: 12px;
+        padding-top: 0;
+      }
+
+      &__extra {
+        flex: 0 0 auto;
+        text-align: right;
+        color: $darker-text-color;
+        font-weight: 500;
+      }
+    }
+
+    .directory__tag {
+      margin: 0;
+      width: 100%;
+
+      a {
+        background: transparent;
+        border-radius: 0;
+      }
+    }
+  }
+
+  &.optional .batch-table__toolbar,
+  &.optional .batch-table__row__select {
+    @media screen and (max-width: $no-gap-breakpoint) {
+      display: none;
+    }
+  }
+
+  .status__content {
+    padding-top: 0;
+
+    strong {
+      font-weight: 700;
+    }
+  }
+
+  .nothing-here {
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    box-shadow: none;
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      border-top: 1px solid darken($ui-base-color, 8%);
+    }
+  }
+
+  @media screen and (max-width: 870px) {
+    .accounts-table tbody td.optional {
+      display: none;
+    }
+  }
+}
+
+.one-liner {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss
new file mode 100644
index 000000000..0132da51f
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/variables.scss
@@ -0,0 +1,69 @@
+// 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
+
+$red-bookmark: $warning-red;
+
+// 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: #6364ff; // Brand purple
+
+// 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;
+$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;
+
+// Variables for texts
+$primary-text-color: $white !default;
+$darker-text-color: $ui-primary-color !default;
+$dark-text-color: $ui-base-lighter-color !default;
+$secondary-text-color: $ui-secondary-color !default;
+$highlight-text-color: lighten($ui-highlight-color, 8%) !default;
+$action-button-color: $ui-base-lighter-color !default;
+$passive-text-color: $gold-star !default;
+$active-passive-text-color: $success-green !default;
+
+// For texts on inverted backgrounds
+$inverted-text-color: $ui-base-color !default;
+$lighter-text-color: $ui-base-lighter-color !default;
+$light-text-color: $ui-primary-color !default;
+
+// Language codes that uses CJK fonts
+$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
+
+// Variables for components
+$media-modal-media-max-width: 100%;
+
+// put margins on top and bottom of image to avoid the screen covered by image.
+$media-modal-media-max-height: 80%;
+
+$no-gap-breakpoint: 1175px;
+
+$font-sans-serif: 'mastodon-font-sans-serif' !default;
+$font-display: 'mastodon-font-display' !default;
+$font-monospace: 'mastodon-font-monospace' !default;
+
+// 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/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
new file mode 100644
index 000000000..0f2b7ac5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -0,0 +1,402 @@
+@use 'sass:math';
+
+.hero-widget {
+  margin-bottom: 10px;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+  &__img {
+    width: 100%;
+    position: relative;
+    overflow: hidden;
+    border-radius: 4px 4px 0 0;
+    background: $base-shadow-color;
+
+    img {
+      object-fit: cover;
+      display: block;
+      width: 100%;
+      height: 100%;
+      margin: 0;
+      border-radius: 4px 4px 0 0;
+    }
+  }
+
+  &__text {
+    background: $ui-base-color;
+    padding: 20px;
+    border-radius: 0 0 4px 4px;
+    font-size: 15px;
+    color: $darker-text-color;
+    line-height: 20px;
+    word-wrap: break-word;
+    font-weight: 400;
+
+    .emojione {
+      width: 20px;
+      height: 20px;
+      margin: -3px 0 0;
+    }
+
+    p {
+      margin-bottom: 20px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    em {
+      display: inline;
+      margin: 0;
+      padding: 0;
+      font-weight: 700;
+      background: transparent;
+      font-family: inherit;
+      font-size: inherit;
+      line-height: inherit;
+      color: lighten($darker-text-color, 10%);
+    }
+
+    a {
+      color: $secondary-text-color;
+      text-decoration: none;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    display: none;
+  }
+}
+
+.endorsements-widget {
+  margin-bottom: 10px;
+  padding-bottom: 10px;
+
+  h4 {
+    padding: 10px;
+    text-transform: uppercase;
+    font-weight: 700;
+    font-size: 13px;
+    color: $darker-text-color;
+  }
+
+  .account {
+    padding: 10px 0;
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    .account__display-name {
+      display: flex;
+      align-items: center;
+    }
+  }
+
+  .trends__item {
+    padding: 10px;
+  }
+}
+
+.trends-widget {
+  h4 {
+    color: $darker-text-color;
+  }
+}
+
+.placeholder-widget {
+  padding: 16px;
+  border-radius: 4px;
+  border: 2px dashed $dark-text-color;
+  text-align: center;
+  color: $darker-text-color;
+  margin-bottom: 10px;
+}
+
+.moved-account-widget {
+  padding: 15px;
+  padding-bottom: 20px;
+  border-radius: 4px;
+  background: $ui-base-color;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  color: $secondary-text-color;
+  font-weight: 400;
+  margin-bottom: 10px;
+
+  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: $darker-text-color;
+    }
+  }
+
+  &__card {
+    .detailed-status__display-avatar {
+      position: relative;
+      cursor: pointer;
+    }
+
+    .detailed-status__display-name {
+      margin-bottom: 0;
+      text-decoration: none;
+
+      span {
+        font-weight: 400;
+      }
+    }
+  }
+}
+
+.memoriam-widget {
+  padding: 20px;
+  border-radius: 4px;
+  background: $base-shadow-color;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  font-size: 14px;
+  color: $darker-text-color;
+  margin-bottom: 10px;
+}
+
+.directory {
+  background: $ui-base-color;
+  border-radius: 4px;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+  &__tag {
+    box-sizing: border-box;
+    margin-bottom: 10px;
+
+    & > a,
+    & > div {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      background: $ui-base-color;
+      border-radius: 4px;
+      padding: 15px;
+      text-decoration: none;
+      color: inherit;
+      box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+    }
+
+    & > a {
+      &:hover,
+      &:active,
+      &:focus {
+        background: lighten($ui-base-color, 8%);
+      }
+    }
+
+    &.active > a {
+      background: $ui-highlight-color;
+      cursor: default;
+    }
+
+    &.disabled > div {
+      opacity: 0.5;
+      cursor: default;
+    }
+
+    h4 {
+      flex: 1 1 auto;
+      font-size: 18px;
+      font-weight: 700;
+      color: $primary-text-color;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+
+      .fa {
+        color: $darker-text-color;
+      }
+
+      small {
+        display: block;
+        font-weight: 400;
+        font-size: 15px;
+        margin-top: 8px;
+        color: $darker-text-color;
+      }
+    }
+
+    &.active h4 {
+      &,
+      .fa,
+      small {
+        color: $primary-text-color;
+      }
+    }
+
+    .avatar-stack {
+      flex: 0 0 auto;
+      width: (36px + 4px) * 3;
+    }
+
+    &.active .avatar-stack .account__avatar {
+      border-color: $ui-highlight-color;
+    }
+  }
+}
+
+.accounts-table {
+  width: 100%;
+
+  .account {
+    padding: 0;
+    border: 0;
+  }
+
+  strong {
+    font-weight: 700;
+  }
+
+  thead th {
+    text-align: center;
+    text-transform: uppercase;
+    color: $darker-text-color;
+    font-weight: 700;
+    padding: 10px;
+
+    &:first-child {
+      text-align: left;
+    }
+  }
+
+  tbody td {
+    padding: 15px 0;
+    vertical-align: middle;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
+  tbody tr:last-child td {
+    border-bottom: 0;
+  }
+
+  &__count {
+    width: 120px;
+    text-align: center;
+    font-size: 15px;
+    font-weight: 500;
+    color: $primary-text-color;
+
+    small {
+      display: block;
+      color: $darker-text-color;
+      font-weight: 400;
+      font-size: 14px;
+    }
+  }
+
+  tbody td.accounts-table__extra {
+    width: 120px;
+    text-align: right;
+    color: $darker-text-color;
+    padding-right: 16px;
+
+    a {
+      text-decoration: none;
+      color: inherit;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  &__comment {
+    width: 50%;
+    vertical-align: initial !important;
+  }
+
+  &__interrelationships {
+    width: 21px;
+  }
+
+  .fa {
+    font-size: 16px;
+
+    &.active {
+      color: $highlight-text-color;
+    }
+
+    &.passive {
+      color: $passive-text-color;
+    }
+
+    &.active.passive {
+      color: $active-passive-text-color;
+    }
+  }
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    tbody td.optional {
+      display: none;
+    }
+  }
+}
+
+.moved-account-widget,
+.memoriam-widget,
+.directory {
+  @media screen and (max-width: $no-gap-breakpoint) {
+    margin-bottom: 0;
+    box-shadow: none;
+    border-radius: 0;
+  }
+}
+
+.placeholder-widget {
+  a {
+    text-decoration: none;
+    font-weight: 500;
+    color: $ui-highlight-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
new file mode 100644
index 000000000..672dd5440
--- /dev/null
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -0,0 +1,48 @@
+#  (REQUIRED) The location of the pack files.
+pack:
+  admin:
+    - packs/admin.jsx
+    - packs/public.jsx
+  auth: packs/public.jsx
+  common:
+    filename: packs/common.js
+    stylesheet: true
+  embed: packs/public.jsx
+  error: packs/error.js
+  home:
+    filename: packs/home.js
+    preload:
+      - flavours/glitch/async/compose
+      - flavours/glitch/async/getting_started
+      - flavours/glitch/async/home_timeline
+      - flavours/glitch/async/notifications
+  mailer:
+  modal:
+  public: packs/public.jsx
+  settings: packs/settings.js
+  share: packs/share.jsx
+
+#  (OPTIONAL) The directory which contains localization files for
+#  the flavour, relative to this directory. The contents of this
+#  directory must be `.json` files whose names correspond to
+#  language tags and whose default exports are a messages object.
+locales: locales
+
+#  (OPTIONAL) Which flavour to inherit locales from
+inherit_locales: vanilla
+
+#  (OPTIONAL) A file to use as the preview screenshot for the flavour,
+#  or an array thereof. These are the full path from `app/javascript/`.
+screenshot: flavours/glitch/images/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/utils/backend_links.js b/app/javascript/flavours/glitch/utils/backend_links.js
new file mode 100644
index 000000000..2028a1e60
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/backend_links.js
@@ -0,0 +1,18 @@
+export const preferencesLink = '/settings/preferences';
+export const profileLink = '/settings/profile';
+export const signOutLink = '/auth/sign_out';
+export const privacyPolicyLink = '/privacy-policy';
+export const accountAdminLink = (id) => `/admin/accounts/${id}`;
+export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
+export const filterEditLink = (id) => `/filters/${id}/edit`;
+export const relationshipsLink = '/relationships';
+export const securityLink = '/auth/edit';
+export const preferenceLink = (setting_name) => {
+  switch (setting_name) {
+  case 'user_setting_expand_spoilers':
+  case 'user_setting_disable_swiping':
+    return `/settings/preferences/appearance#${setting_name}`;
+  default:
+    return preferencesLink;
+  }
+};
diff --git a/app/javascript/flavours/glitch/utils/base64.js b/app/javascript/flavours/glitch/utils/base64.js
new file mode 100644
index 000000000..8226e2c54
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/base64.js
@@ -0,0 +1,10 @@
+export const decode = base64 => {
+  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;
+};
diff --git a/app/javascript/flavours/glitch/utils/config.js b/app/javascript/flavours/glitch/utils/config.js
new file mode 100644
index 000000000..932cd0cbf
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/config.js
@@ -0,0 +1,10 @@
+import ready from '../ready';
+
+export let assetHost = '';
+
+ready(() => {
+  const cdnHost = document.querySelector('meta[name=cdn-host]');
+  if (cdnHost) {
+    assetHost = cdnHost.content || '';
+  }
+});
diff --git a/app/javascript/flavours/glitch/utils/content_warning.js b/app/javascript/flavours/glitch/utils/content_warning.js
new file mode 100644
index 000000000..91d452baa
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/content_warning.js
@@ -0,0 +1,31 @@
+import { expandSpoilers } from 'flavours/glitch/initial_state';
+
+function _autoUnfoldCW(spoiler_text, skip_unfold_regex) {
+  if (!expandSpoilers)
+    return false;
+
+  if (!skip_unfold_regex)
+    return true;
+
+  let regex = null;
+
+  try {
+    regex = new RegExp(skip_unfold_regex.trim(), 'i');
+  } catch (e) {
+    // Bad regex, skip filters
+    return true;
+  }
+
+  return !regex.test(spoiler_text);
+}
+
+export function autoHideCW(settings, spoiler_text) {
+  return !_autoUnfoldCW(spoiler_text, settings.getIn(['content_warnings', 'filter']));
+}
+
+export function autoUnfoldCW(settings, status) {
+  if (!status)
+    return false;
+
+  return _autoUnfoldCW(status.get('spoiler_text'), settings.getIn(['content_warnings', 'filter']));
+}
diff --git a/app/javascript/flavours/glitch/utils/dom_helpers.js b/app/javascript/flavours/glitch/utils/dom_helpers.js
new file mode 100644
index 000000000..d94aeb9d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/dom_helpers.js
@@ -0,0 +1,14 @@
+//  Package imports.
+import { supportsPassiveEvents } from 'detect-passive-events';
+
+//  This will either be a passive lister options object (if passive
+//  events are supported), or `false`.
+export const withPassive = supportsPassiveEvents ? { 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/utils/filters.js b/app/javascript/flavours/glitch/utils/filters.js
new file mode 100644
index 000000000..97b433a99
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/filters.js
@@ -0,0 +1,16 @@
+export const toServerSideType = columnType => {
+  switch (columnType) {
+  case 'home':
+  case 'notifications':
+  case 'public':
+  case 'thread':
+  case 'account':
+    return columnType;
+  default:
+    if (columnType.indexOf('list:') > -1) {
+      return 'home';
+    } else {
+      return 'public'; // community, account, hashtag
+    }
+  }
+};
diff --git a/app/javascript/flavours/glitch/utils/hashtag.js b/app/javascript/flavours/glitch/utils/hashtag.js
new file mode 100644
index 000000000..d91cd5591
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/hashtag.js
@@ -0,0 +1,8 @@
+export function recoverHashtags (recognizedTags, text) {
+  return recognizedTags.map(tag => {
+    const re = new RegExp(`(?:^|[^/)\w])#(${tag.name})`, 'i');
+    const matched_hashtag = text.match(re);
+    return matched_hashtag ? matched_hashtag[1] : null;
+  },
+  ).filter(x => x !== null);
+}
diff --git a/app/javascript/flavours/glitch/utils/html.js b/app/javascript/flavours/glitch/utils/html.js
new file mode 100644
index 000000000..5159df9db
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/html.js
@@ -0,0 +1,5 @@
+export const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  wrapper.innerHTML = html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, '');
+  return wrapper.textContent;
+};
diff --git a/app/javascript/flavours/glitch/utils/icons.jsx b/app/javascript/flavours/glitch/utils/icons.jsx
new file mode 100644
index 000000000..c3e362e39
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/icons.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+// Copied from emoji-mart for consistency with emoji picker and since
+// they don't export the icons in the package
+export const loupeIcon = (
+  <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+    <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
+  </svg>
+);
+
+export const deleteIcon = (
+  <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+    <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
+  </svg>
+);
diff --git a/app/javascript/flavours/glitch/utils/idna.js b/app/javascript/flavours/glitch/utils/idna.js
new file mode 100644
index 000000000..efab5bacf
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/idna.js
@@ -0,0 +1,10 @@
+import punycode from 'punycode';
+
+const IDNA_PREFIX = 'xn--';
+
+export const decode = domain => {
+  return domain
+    .split('.')
+    .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+    .join('.');
+};
diff --git a/app/javascript/flavours/glitch/utils/js_helpers.js b/app/javascript/flavours/glitch/utils/js_helpers.js
new file mode 100644
index 000000000..2ebd5b6c5
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/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/utils/log_out.js b/app/javascript/flavours/glitch/utils/log_out.js
new file mode 100644
index 000000000..f82041150
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/log_out.js
@@ -0,0 +1,34 @@
+import Rails from '@rails/ujs';
+import { signOutLink } from 'flavours/glitch/utils/backend_links';
+
+export const logOut = () => {
+  const form = document.createElement('form');
+
+  const methodInput = document.createElement('input');
+  methodInput.setAttribute('name', '_method');
+  methodInput.setAttribute('value', 'delete');
+  methodInput.setAttribute('type', 'hidden');
+  form.appendChild(methodInput);
+
+  const csrfToken = Rails.csrfToken();
+  const csrfParam = Rails.csrfParam();
+
+  if (csrfParam && csrfToken) {
+    const csrfInput = document.createElement('input');
+    csrfInput.setAttribute('name', csrfParam);
+    csrfInput.setAttribute('value', csrfToken);
+    csrfInput.setAttribute('type', 'hidden');
+    form.appendChild(csrfInput);
+  }
+
+  const submitButton = document.createElement('input');
+  submitButton.setAttribute('type', 'submit');
+  form.appendChild(submitButton);
+
+  form.method = 'post';
+  form.action = signOutLink;
+  form.style.display = 'none';
+
+  document.body.appendChild(form);
+  submitButton.click();
+};
diff --git a/app/javascript/flavours/glitch/utils/notifications.js b/app/javascript/flavours/glitch/utils/notifications.js
new file mode 100644
index 000000000..3cdf7caea
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/notifications.js
@@ -0,0 +1,30 @@
+// Handles browser quirks, based on
+// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+const checkNotificationPromise = () => {
+  try {
+    // eslint-disable-next-line promise/catch-or-return, promise/valid-params
+    Notification.requestPermission().then();
+  } catch(e) {
+    return false;
+  }
+
+  return true;
+};
+
+const handlePermission = (permission, callback) => {
+  // Whatever the user answers, we make sure Chrome stores the information
+  if(!('permission' in Notification)) {
+    Notification.permission = permission;
+  }
+
+  callback(Notification.permission);
+};
+
+export const requestNotificationPermission = (callback) => {
+  if (checkNotificationPromise()) {
+    Notification.requestPermission().then((permission) => handlePermission(permission, callback)).catch(console.warn);
+  } else {
+    Notification.requestPermission((permission) => handlePermission(permission, callback));
+  }
+};
diff --git a/app/javascript/flavours/glitch/utils/numbers.js b/app/javascript/flavours/glitch/utils/numbers.js
new file mode 100644
index 000000000..6ef563ad8
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/numbers.js
@@ -0,0 +1,79 @@
+// @ts-check
+
+export const DECIMAL_UNITS = Object.freeze({
+  ONE: 1,
+  TEN: 10,
+  HUNDRED: Math.pow(10, 2),
+  THOUSAND: Math.pow(10, 3),
+  MILLION: Math.pow(10, 6),
+  BILLION: Math.pow(10, 9),
+  TRILLION: Math.pow(10, 12),
+});
+
+const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
+const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
+
+/**
+ * @typedef {[number, number, number]} ShortNumber
+ * Array of: shorten number, unit of shorten number and maximum fraction digits
+ */
+
+/**
+ * @param {number} sourceNumber Number to convert to short number
+ * @returns {ShortNumber} Calculated short number
+ * @example
+ * shortNumber(5936);
+ * // => [5.936, 1000, 1]
+ */
+export function toShortNumber(sourceNumber) {
+  if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
+    return [sourceNumber, DECIMAL_UNITS.ONE, 0];
+  } else if (sourceNumber < DECIMAL_UNITS.MILLION) {
+    return [
+      sourceNumber / DECIMAL_UNITS.THOUSAND,
+      DECIMAL_UNITS.THOUSAND,
+      sourceNumber < TEN_THOUSAND ? 1 : 0,
+    ];
+  } else if (sourceNumber < DECIMAL_UNITS.BILLION) {
+    return [
+      sourceNumber / DECIMAL_UNITS.MILLION,
+      DECIMAL_UNITS.MILLION,
+      sourceNumber < TEN_MILLIONS ? 1 : 0,
+    ];
+  } else if (sourceNumber < DECIMAL_UNITS.TRILLION) {
+    return [
+      sourceNumber / DECIMAL_UNITS.BILLION,
+      DECIMAL_UNITS.BILLION,
+      0,
+    ];
+  }
+
+  return [sourceNumber, DECIMAL_UNITS.ONE, 0];
+}
+
+/**
+ * @param {number} sourceNumber Original number that is shortened
+ * @param {number} division The scale in which short number is displayed
+ * @returns {number} Number that can be used for plurals when short form used
+ * @example
+ * pluralReady(1793, DECIMAL_UNITS.THOUSAND)
+ * // => 1790
+ */
+export function pluralReady(sourceNumber, division) {
+  // eslint-disable-next-line eqeqeq
+  if (division == null || division < DECIMAL_UNITS.HUNDRED) {
+    return sourceNumber;
+  }
+
+  let closestScale = division / DECIMAL_UNITS.TEN;
+
+  return Math.trunc(sourceNumber / closestScale) * closestScale;
+}
+
+/**
+ * @param {number} num
+ * @returns {number}
+ */
+export function roundTo10(num) {
+  return Math.round(num * 0.1) / 0.1;
+}
diff --git a/app/javascript/flavours/glitch/utils/privacy_preference.js b/app/javascript/flavours/glitch/utils/privacy_preference.js
new file mode 100644
index 000000000..51bdf072d
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/privacy_preference.js
@@ -0,0 +1,5 @@
+export const order = ['public', 'unlisted', 'private', 'direct'];
+
+export function privacyPreference (a, b) {
+  return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
+}
diff --git a/app/javascript/flavours/glitch/utils/react_helpers.js b/app/javascript/flavours/glitch/utils/react_helpers.js
new file mode 100644
index 000000000..ea11acdb6
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/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/utils/resize_image.js b/app/javascript/flavours/glitch/utils/resize_image.js
new file mode 100644
index 000000000..fb8c3c11e
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/resize_image.js
@@ -0,0 +1,189 @@
+import EXIF from 'exif-js';
+
+const MAX_IMAGE_PIXELS = 2073600; // 1920x1080px
+
+const _browser_quirks = {};
+
+// Some browsers will automatically draw images respecting their EXIF orientation
+// while others won't, and the safest way to detect that is to examine how it
+// is done on a known image.
+// See https://github.com/w3c/csswg-drafts/issues/4666
+// and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881
+const dropOrientationIfNeeded = (orientation) => new Promise(resolve => {
+  switch (_browser_quirks['image-orientation-automatic']) {
+  case true:
+    resolve(1);
+    break;
+  case false:
+    resolve(orientation);
+    break;
+  default:
+    // black 2x1 JPEG, with the following meta information set:
+    // - EXIF Orientation: 6 (Rotated 90° CCW)
+    const testImageURL =
+      'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' +
+      'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' +
+      'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' +
+      'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' +
+      'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' +
+      'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==';
+    const img = new Image();
+    img.onload = () => {
+      const automatic = (img.width === 1 && img.height === 2);
+      _browser_quirks['image-orientation-automatic'] = automatic;
+      resolve(automatic ? 1 : orientation);
+    };
+    img.onerror = () => {
+      _browser_quirks['image-orientation-automatic'] = false;
+      resolve(orientation);
+    };
+    img.src = testImageURL;
+  }
+});
+
+// Some browsers don't allow reading from a canvas and instead return all-white
+// or randomized data. Use a pre-defined image to check if reading the canvas
+// works.
+const checkCanvasReliability = () => new Promise((resolve, reject) => {
+  switch(_browser_quirks['canvas-read-unreliable']) {
+  case true:
+    reject('Canvas reading unreliable');
+    break;
+  case false:
+    resolve();
+    break;
+  default:
+    // 2×2 GIF with white, red, green and blue pixels
+    const testImageURL =
+      'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7';
+    const refData =
+      [255, 255, 255, 255,  255, 0, 0, 255,  0, 255, 0, 255,  0, 0, 255, 255];
+    const img = new Image();
+    img.onload = () => {
+      const canvas  = document.createElement('canvas');
+      const context = canvas.getContext('2d');
+      context.drawImage(img, 0, 0, 2, 2);
+      const imageData = context.getImageData(0, 0, 2, 2);
+      if (imageData.data.every((x, i) => refData[i] === x)) {
+        _browser_quirks['canvas-read-unreliable'] = false;
+        resolve();
+      } else {
+        _browser_quirks['canvas-read-unreliable'] = true;
+        reject('Canvas reading unreliable');
+      }
+    };
+    img.onerror = () => {
+      _browser_quirks['canvas-read-unreliable'] = true;
+      reject('Failed to load test image');
+    };
+    img.src = testImageURL;
+  }
+});
+
+const getImageUrl = inputFile => new Promise((resolve, reject) => {
+  if (window.URL && URL.createObjectURL) {
+    try {
+      resolve(URL.createObjectURL(inputFile));
+    } catch (error) {
+      reject(error);
+    }
+    return;
+  }
+
+  const reader = new FileReader();
+  reader.onerror = (...args) => reject(...args);
+  reader.onload  = ({ target }) => resolve(target.result);
+
+  reader.readAsDataURL(inputFile);
+});
+
+const loadImage = inputFile => new Promise((resolve, reject) => {
+  getImageUrl(inputFile).then(url => {
+    const img = new Image();
+
+    img.onerror = (...args) => reject(...args);
+    img.onload  = () => resolve(img);
+
+    img.src = url;
+  }).catch(reject);
+});
+
+const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
+  if (!['image/jpeg', 'image/webp'].includes(type)) {
+    resolve(1);
+    return;
+  }
+
+  EXIF.getData(img, () => {
+    const orientation = EXIF.getTag(img, 'Orientation');
+    if (orientation !== 1) {
+      dropOrientationIfNeeded(orientation).then(resolve).catch(() => resolve(orientation));
+    } else {
+      resolve(orientation);
+    }
+  });
+});
+
+const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => {
+  const canvas  = document.createElement('canvas');
+
+  if (4 < orientation && orientation < 9) {
+    canvas.width  = height;
+    canvas.height = width;
+  } else {
+    canvas.width  = width;
+    canvas.height = height;
+  }
+
+  const context = canvas.getContext('2d');
+
+  switch (orientation) {
+  case 2: context.transform(-1, 0, 0, 1, width, 0); break;
+  case 3: context.transform(-1, 0, 0, -1, width, height); break;
+  case 4: context.transform(1, 0, 0, -1, 0, height); break;
+  case 5: context.transform(0, 1, 1, 0, 0, 0); break;
+  case 6: context.transform(0, 1, -1, 0, height, 0); break;
+  case 7: context.transform(0, -1, -1, 0, height, width); break;
+  case 8: context.transform(0, -1, 1, 0, 0, width); break;
+  }
+
+  context.drawImage(img, 0, 0, width, height);
+
+  canvas.toBlob(resolve, type);
+});
+
+const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => {
+  const { width, height } = img;
+
+  const newWidth  = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height)));
+  const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width)));
+
+  checkCanvasReliability()
+    .then(getOrientation(img, type))
+    .then(orientation => processImage(img, {
+      width: newWidth,
+      height: newHeight,
+      orientation,
+      type,
+    }))
+    .then(resolve)
+    .catch(reject);
+});
+
+export default inputFile => new Promise((resolve) => {
+  if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
+    resolve(inputFile);
+    return;
+  }
+
+  loadImage(inputFile).then(img => {
+    if (img.width * img.height < MAX_IMAGE_PIXELS) {
+      resolve(inputFile);
+      return;
+    }
+
+    resizeImage(img, inputFile.type)
+      .then(resolve)
+      .catch(() => resolve(inputFile));
+  }).catch(() => resolve(inputFile));
+});
diff --git a/app/javascript/flavours/glitch/utils/scrollbar.js b/app/javascript/flavours/glitch/utils/scrollbar.js
new file mode 100644
index 000000000..929b036d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/scrollbar.js
@@ -0,0 +1,34 @@
+/** @type {number | null} */
+let cachedScrollbarWidth = null;
+
+/**
+ * @return {number}
+ */
+const getActualScrollbarWidth = () => {
+  const outer = document.createElement('div');
+  outer.style.visibility = 'hidden';
+  outer.style.overflow = 'scroll';
+  document.body.appendChild(outer);
+
+  const inner = document.createElement('div');
+  outer.appendChild(inner);
+
+  const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
+  outer.parentNode.removeChild(outer);
+
+  return scrollbarWidth;
+};
+
+/**
+ * @return {number}
+ */
+export const getScrollbarWidth = () => {
+  if (cachedScrollbarWidth !== null) {
+    return cachedScrollbarWidth;
+  }
+
+  const scrollbarWidth = getActualScrollbarWidth();
+  cachedScrollbarWidth = scrollbarWidth;
+
+  return scrollbarWidth;
+};
diff --git a/app/javascript/flavours/glitch/uuid.js b/app/javascript/flavours/glitch/uuid.js
new file mode 100644
index 000000000..0d2cfaa77
--- /dev/null
+++ b/app/javascript/flavours/glitch/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..9b7fc189d
--- /dev/null
+++ b/app/javascript/flavours/vanilla/names.yml
@@ -0,0 +1,41 @@
+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
+cs:
+  flavours:
+    vanilla:
+      description: Standardní rozhraní Mastodonu. Některé funkce GlitchSoc v něm nejsou podporované.
+      name: Standardní Mastodon
+  skins:
+    vanilla:
+      default: Výchozí
+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
+es:
+  flavours:
+    vanilla:
+      description: El diseño predeterminado en las instancias de Mastodon. Puede que no soporte todas las características de GlitchSoc.
+      name: Mastodon Original
+  skins:
+    vanilla:
+      default: Predeterminado
+
+ja:
+  flavours:
+    vanilla:
+      description: バニラのMastodonインスタンスで使われるテーマです。このテーマはGlitchSocのすべての機能をサポートしない可能性があります。
+      name: Vanilla Mastodon
+  skins:
+    vanilla:
+      default: デフォルト
diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml
new file mode 100644
index 000000000..ccab925aa
--- /dev/null
+++ b/app/javascript/flavours/vanilla/theme.yml
@@ -0,0 +1,43 @@
+#  (REQUIRED) The location of the pack files inside `pack_directory`.
+pack:
+  admin:
+    - admin.jsx
+    - public.jsx
+  auth: public.jsx
+  common:
+    filename: common.js
+    stylesheet: true
+  embed: public.jsx
+  error: error.js
+  home:
+    filename: application.js
+    preload:
+      - features/getting_started
+      - features/compose
+      - features/home_timeline
+      - features/notifications
+  mailer:
+  modal:
+  public: public.jsx
+  settings: public.jsx
+  share: share.jsx
+
+#  (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 are the full path from `app/javascript/`.
+screenshot: images/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/fonts/premillenium/MSSansSerif.ttf b/app/javascript/fonts/premillenium/MSSansSerif.ttf
new file mode 100644
index 000000000..3afd76ff2
--- /dev/null
+++ b/app/javascript/fonts/premillenium/MSSansSerif.ttf
Binary files differdiff --git a/app/javascript/images/alert_badge.png b/app/javascript/images/alert_badge.png
new file mode 100644
index 000000000..681f6e651
--- /dev/null
+++ b/app/javascript/images/alert_badge.png
Binary files differdiff --git a/app/javascript/images/clippy_frame.png b/app/javascript/images/clippy_frame.png
new file mode 100644
index 000000000..7f2cd6a59
--- /dev/null
+++ b/app/javascript/images/clippy_frame.png
Binary files differdiff --git a/app/javascript/images/clippy_wave.gif b/app/javascript/images/clippy_wave.gif
new file mode 100644
index 000000000..4d2e38a3d
--- /dev/null
+++ b/app/javascript/images/clippy_wave.gif
Binary files differdiff --git a/app/javascript/images/icon_about.png b/app/javascript/images/icon_about.png
new file mode 100644
index 000000000..08b76dcd9
--- /dev/null
+++ b/app/javascript/images/icon_about.png
Binary files differdiff --git a/app/javascript/images/icon_blocks.png b/app/javascript/images/icon_blocks.png
new file mode 100644
index 000000000..8b1490875
--- /dev/null
+++ b/app/javascript/images/icon_blocks.png
Binary files differdiff --git a/app/javascript/images/icon_bookmarks.png b/app/javascript/images/icon_bookmarks.png
new file mode 100644
index 000000000..b0cff1344
--- /dev/null
+++ b/app/javascript/images/icon_bookmarks.png
Binary files differdiff --git a/app/javascript/images/icon_developers.png b/app/javascript/images/icon_developers.png
new file mode 100644
index 000000000..c6d2e1829
--- /dev/null
+++ b/app/javascript/images/icon_developers.png
Binary files differdiff --git a/app/javascript/images/icon_direct.png b/app/javascript/images/icon_direct.png
new file mode 100644
index 000000000..71e898a98
--- /dev/null
+++ b/app/javascript/images/icon_direct.png
Binary files differdiff --git a/app/javascript/images/icon_docs.png b/app/javascript/images/icon_docs.png
new file mode 100644
index 000000000..6af1c8268
--- /dev/null
+++ b/app/javascript/images/icon_docs.png
Binary files differdiff --git a/app/javascript/images/icon_domain_blocks.png b/app/javascript/images/icon_domain_blocks.png
new file mode 100644
index 000000000..ed3750485
--- /dev/null
+++ b/app/javascript/images/icon_domain_blocks.png
Binary files differdiff --git a/app/javascript/images/icon_follow_requests.png b/app/javascript/images/icon_follow_requests.png
new file mode 100644
index 000000000..4123e2a69
--- /dev/null
+++ b/app/javascript/images/icon_follow_requests.png
Binary files differdiff --git a/app/javascript/images/icon_home.png b/app/javascript/images/icon_home.png
new file mode 100644
index 000000000..66ce779c0
--- /dev/null
+++ b/app/javascript/images/icon_home.png
Binary files differdiff --git a/app/javascript/images/icon_invite.png b/app/javascript/images/icon_invite.png
new file mode 100644
index 000000000..21156ec96
--- /dev/null
+++ b/app/javascript/images/icon_invite.png
Binary files differdiff --git a/app/javascript/images/icon_keyboard_shortcuts.png b/app/javascript/images/icon_keyboard_shortcuts.png
new file mode 100644
index 000000000..d66f3939e
--- /dev/null
+++ b/app/javascript/images/icon_keyboard_shortcuts.png
Binary files differdiff --git a/app/javascript/images/icon_likes.png b/app/javascript/images/icon_likes.png
new file mode 100644
index 000000000..17d7a9c59
--- /dev/null
+++ b/app/javascript/images/icon_likes.png
Binary files differdiff --git a/app/javascript/images/icon_lists.png b/app/javascript/images/icon_lists.png
new file mode 100644
index 000000000..3828946e8
--- /dev/null
+++ b/app/javascript/images/icon_lists.png
Binary files differdiff --git a/app/javascript/images/icon_local.png b/app/javascript/images/icon_local.png
new file mode 100644
index 000000000..5f82df395
--- /dev/null
+++ b/app/javascript/images/icon_local.png
Binary files differdiff --git a/app/javascript/images/icon_logout.png b/app/javascript/images/icon_logout.png
new file mode 100644
index 000000000..7ff806f58
--- /dev/null
+++ b/app/javascript/images/icon_logout.png
Binary files differdiff --git a/app/javascript/images/icon_mobile_apps.png b/app/javascript/images/icon_mobile_apps.png
new file mode 100644
index 000000000..a7cbd78c1
--- /dev/null
+++ b/app/javascript/images/icon_mobile_apps.png
Binary files differdiff --git a/app/javascript/images/icon_mutes.png b/app/javascript/images/icon_mutes.png
new file mode 100644
index 000000000..c2225e966
--- /dev/null
+++ b/app/javascript/images/icon_mutes.png
Binary files differdiff --git a/app/javascript/images/icon_notifications.png b/app/javascript/images/icon_notifications.png
new file mode 100644
index 000000000..0aaf5e68d
--- /dev/null
+++ b/app/javascript/images/icon_notifications.png
Binary files differdiff --git a/app/javascript/images/icon_pin.png b/app/javascript/images/icon_pin.png
new file mode 100644
index 000000000..2329d8c54
--- /dev/null
+++ b/app/javascript/images/icon_pin.png
Binary files differdiff --git a/app/javascript/images/icon_profile_directory.png b/app/javascript/images/icon_profile_directory.png
new file mode 100644
index 000000000..05a94213a
--- /dev/null
+++ b/app/javascript/images/icon_profile_directory.png
Binary files differdiff --git a/app/javascript/images/icon_public.png b/app/javascript/images/icon_public.png
new file mode 100644
index 000000000..3c09460db
--- /dev/null
+++ b/app/javascript/images/icon_public.png
Binary files differdiff --git a/app/javascript/images/icon_settings.png b/app/javascript/images/icon_settings.png
new file mode 100644
index 000000000..07f5c4519
--- /dev/null
+++ b/app/javascript/images/icon_settings.png
Binary files differdiff --git a/app/javascript/images/icon_tos.png b/app/javascript/images/icon_tos.png
new file mode 100644
index 000000000..d0dbb13f7
--- /dev/null
+++ b/app/javascript/images/icon_tos.png
Binary files differdiff --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/images/start.png b/app/javascript/images/start.png
new file mode 100644
index 000000000..7843455b6
--- /dev/null
+++ b/app/javascript/images/start.png
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/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 961503287..e1db44359 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -224,7 +224,9 @@ export function submitCompose(routerHistory) {
 
       if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') {
         insertIfOnline('community');
-        insertIfOnline('public');
+        if (!response.data.local_only) {
+          insertIfOnline('public');
+        }
         insertIfOnline(`account:${response.data.account.id}`);
       }
     }).catch(function (error) {
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx
index 4a8f40301..a40eb87e3 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx
@@ -20,6 +20,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { length } from 'stringz';
 import { countableText } from '../util/counter';
 import Icon from 'mastodon/components/icon';
+import { maxChars } from '../../../initial_state';
 
 const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
 
@@ -89,7 +90,7 @@ class ComposeForm extends ImmutablePureComponent {
     const fulltext = this.getFulltextForCharacterCounting();
     const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
 
-    return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
+    return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
   };
 
   handleSubmit = (e) => {
@@ -279,7 +280,7 @@ class ComposeForm extends ImmutablePureComponent {
           </div>
 
           <div className='character-counter__wrapper'>
-            <CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} />
+            <CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
           </div>
         </div>
 
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.jsx b/app/javascript/mastodon/features/compose/components/poll_form.jsx
index c0acd7eeb..f81d7355a 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/poll_form.jsx
@@ -89,7 +89,7 @@ class OptionIntl extends React.PureComponent {
 
           <AutosuggestInput
             placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
-            maxLength={50}
+            maxLength={100}
             value={title}
             lang={lang}
             spellCheck
@@ -160,7 +160,7 @@ class PollForm extends ImmutablePureComponent {
         </ul>
 
         <div className='poll__footer'>
-          <button type='button' disabled={options.size >= 4} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
+          <button type='button' disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
 
           {/* eslint-disable-next-line jsx-a11y/no-onchange */}
           <select value={expiresIn} onChange={this.handleSelectDuration}>
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 1f0f9d5b1..56b2f4eb2 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -135,4 +135,7 @@ export const languages = initialState?.languages;
 // @ts-expect-error
 export const statusPageUrl = getMeta('status_page_url');
 
+// Glitch-soc-specific settings
+export const maxChars = (initialState && initialState.max_toot_chars) || 500;
+
 export default initialState;
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 236f16861..0d0805f52 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -120,6 +120,10 @@
   "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",
   "community.column_settings.local_only": "Local only",
   "community.column_settings.media_only": "Media Only",
@@ -387,6 +391,7 @@
   "navigation_bar.followed_tags": "Followed hashtags",
   "navigation_bar.follows_and_followers": "Follows and followers",
   "navigation_bar.lists": "Lists",
+  "navigation_bar.misc": "Misc",
   "navigation_bar.logout": "Logout",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.personal": "Personal",
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 803736eaa..dee4baea9 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -120,6 +120,10 @@
   "column_header.pin": "ピン留めする",
   "column_header.show_settings": "設定を表示",
   "column_header.unpin": "ピン留めを外す",
+  "column.heading": "その他",
+  "column.subheading": "その他のオプション",
+  "column_subheading.lists": "リスト",
+  "column_subheading.navigation": "ナビゲーション",
   "column_subheading.settings": "設定",
   "community.column_settings.local_only": "ローカルのみ表示",
   "community.column_settings.media_only": "メディアのみ表示",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 77d1d7c85..4df5d8431 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -120,6 +120,10 @@
   "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",
   "community.column_settings.local_only": "Tylko Lokalne",
   "community.column_settings.media_only": "Tylko multimedia",
@@ -388,6 +392,7 @@
   "navigation_bar.follows_and_followers": "Obserwowani i obserwujący",
   "navigation_bar.lists": "Listy",
   "navigation_bar.logout": "Wyloguj",
+  "navigation_bar.misc": "Różne",
   "navigation_bar.mutes": "Wyciszeni użytkownicy",
   "navigation_bar.personal": "Osobiste",
   "navigation_bar.pins": "Przypięte wpisy",
diff --git a/app/javascript/packs/admin.jsx b/app/javascript/packs/admin.jsx
index 038e9b434..599015000 100644
--- a/app/javascript/packs/admin.jsx
+++ b/app/javascript/packs/admin.jsx
@@ -1,228 +1,7 @@
 import './public-path';
-import { delegate } from '@rails/ujs';
 import ready from '../mastodon/ready';
 
-const setAnnouncementEndsAttributes = (target) => {
-  const valid = target?.value && target?.validity?.valid;
-  const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at');
-  if (valid) {
-    element.classList.remove('optional');
-    element.required = true;
-    element.min = target.value;
-  } else {
-    element.classList.add('optional');
-    element.removeAttribute('required');
-    element.removeAttribute('min');
-  }
-};
-
-delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => {
-  setAnnouncementEndsAttributes(target);
-});
-
-const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
-
-const showSelectAll = () => {
-  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
-  selectAllMatchingElement.classList.add('active');
-};
-
-const hideSelectAll = () => {
-  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
-  const hiddenField = document.querySelector('#select_all_matching');
-  const selectedMsg = document.querySelector('.batch-table__select-all .selected');
-  const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
-
-  selectAllMatchingElement.classList.remove('active');
-  selectedMsg.classList.remove('active');
-  notSelectedMsg.classList.add('active');
-  hiddenField.value = '0';
-};
-
-delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
-  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
-
-  [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => {
-    content.checked = target.checked;
-  });
-
-  if (selectAllMatchingElement) {
-    if (target.checked) {
-      showSelectAll();
-    } else {
-      hideSelectAll();
-    }
-  }
-});
-
-delegate(document, '.batch-table__select-all button', 'click', () => {
-  const hiddenField = document.querySelector('#select_all_matching');
-  const active = hiddenField.value === '1';
-  const selectedMsg = document.querySelector('.batch-table__select-all .selected');
-  const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
-
-  if (active) {
-    hiddenField.value = '0';
-    selectedMsg.classList.remove('active');
-    notSelectedMsg.classList.add('active');
-  } else {
-    hiddenField.value = '1';
-    notSelectedMsg.classList.remove('active');
-    selectedMsg.classList.add('active');
-  }
-});
-
-delegate(document, batchCheckboxClassName, 'change', () => {
-  const checkAllElement = document.querySelector('#batch_checkbox_all');
-  const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
-
-  if (checkAllElement) {
-    checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
-    checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
-
-    if (selectAllMatchingElement) {
-      if (checkAllElement.checked) {
-        showSelectAll();
-      } else {
-        hideSelectAll();
-      }
-    }
-  }
-});
-
-delegate(document, '.media-spoiler-show-button', 'click', () => {
-  [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => {
-    element.click();
-  });
-});
-
-delegate(document, '.media-spoiler-hide-button', 'click', () => {
-  [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => {
-    element.click();
-  });
-});
-
-delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
-  target.form.submit();
-});
-
-const onDomainBlockSeverityChange = (target) => {
-  const rejectMediaDiv   = document.querySelector('.input.with_label.domain_block_reject_media');
-  const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');
-
-  if (rejectMediaDiv) {
-    rejectMediaDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
-  }
-
-  if (rejectReportsDiv) {
-    rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
-  }
-};
-
-delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
-
-const onEnableBootstrapTimelineAccountsChange = (target) => {
-  const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts');
-
-  if (bootstrapTimelineAccountsField) {
-    bootstrapTimelineAccountsField.disabled = !target.checked;
-    if (target.checked) {
-      bootstrapTimelineAccountsField.parentElement.classList.remove('disabled');
-      bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled');
-    } else {
-      bootstrapTimelineAccountsField.parentElement.classList.add('disabled');
-      bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled');
-    }
-  }
-};
-
-delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
-
-const onChangeRegistrationMode = (target) => {
-  const enabled = target.value === 'approved';
-
-  [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
-    input.disabled = !enabled;
-    if (enabled) {
-      let element = input;
-      do {
-        element.classList.remove('disabled');
-        element = element.parentElement;
-      } while (element && !element.classList.contains('fields-group'));
-    } else {
-      let element = input;
-      do {
-        element.classList.add('disabled');
-        element = element.parentElement;
-      } while (element && !element.classList.contains('fields-group'));
-    }
-  });
-};
-
-const convertUTCDateTimeToLocal = (value) => {
-  const date = new Date(value + 'Z');
-  const twoChars = (x) => (x.toString().padStart(2, '0'));
-  return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
-};
-
-const convertLocalDatetimeToUTC = (value) => {
-  const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/;
-  const match = re.exec(value);
-  const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]);
-  const fullISO8601 = date.toISOString();
-  return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
-};
-
-delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
-
 ready(() => {
-  const domainBlockSeverityInput = document.getElementById('domain_block_severity');
-  if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
-
-  const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
-  if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
-
-  const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
-  if (registrationMode) onChangeRegistrationMode(registrationMode);
-
-  const checkAllElement = document.querySelector('#batch_checkbox_all');
-  if (checkAllElement) {
-    checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
-    checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
-  }
-
-  document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => {
-    const domain = document.querySelector('input[type="text"]#by_domain')?.value;
-
-    if (domain) {
-      const url = new URL(event.target.href);
-      url.searchParams.set('_domain', domain);
-      e.target.href = url;
-    }
-  });
-
-  [].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => {
-    if (element.value) {
-      element.value = convertUTCDateTimeToLocal(element.value);
-    }
-    if (element.placeholder) {
-      element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
-    }
-  });
-
-  delegate(document, 'form', 'submit', ({ target }) => {
-    [].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => {
-      if (element.value && element.validity.valid) {
-        element.value = convertLocalDatetimeToUTC(element.value);
-      }
-    });
-  });
-
-  const announcementStartsAt = document.querySelector('input[type="datetime-local"]#announcement_starts_at');
-  if (announcementStartsAt) {
-    setAnnouncementEndsAttributes(announcementStartsAt);
-  }
-
   const React    = require('react');
   const ReactDOM = require('react-dom');
 
diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js
new file mode 100644
index 000000000..05dff8e49
--- /dev/null
+++ b/app/javascript/packs/common.js
@@ -0,0 +1,2 @@
+import './public-path';
+import 'styles/application.scss';
diff --git a/app/javascript/packs/public.jsx b/app/javascript/packs/public.jsx
index 2642aae13..ad6bf237f 100644
--- a/app/javascript/packs/public.jsx
+++ b/app/javascript/packs/public.jsx
@@ -1,5 +1,4 @@
 import './public-path';
-import escapeTextContentForBrowser from 'escape-html';
 import loadPolyfills from '../mastodon/load_polyfills';
 import ready from '../mastodon/ready';
 import { start } from '../mastodon/common';
@@ -8,22 +7,6 @@ import 'cocoon-js-vanilla';
 
 start();
 
-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 IntlMessageFormat = require('intl-messageformat').default;
   const { timeAgoString } = require('../mastodon/components/relative_timestamp');
@@ -200,92 +183,6 @@ function main() {
     });
   });
 
-  delegate(document, '#account_display_name', 'input', ({ target }) => {
-    const name = document.querySelector('.card .display-name strong');
-    if (name) {
-      if (target.value) {
-        name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
-      } else {
-        name.textContent = target.dataset.default;
-      }
-    }
-  });
-
-  delegate(document, '#account_avatar', 'change', ({ target }) => {
-    const avatar = document.querySelector('.card .avatar img');
-    const [file] = target.files || [];
-    const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
-
-    avatar.src = url;
-  });
-
-  const getProfileAvatarAnimationHandler = (swapTo) => {
-    //animate avatar gifs on the profile page when moused over
-    return ({ target }) => {
-      const swapSrc = target.getAttribute(swapTo);
-      //only change the img source if autoplay is off and the image src is actually different
-      if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
-        target.src = swapSrc;
-      }
-    };
-  };
-
-  delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
-
-  delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
-
-  delegate(document, '#account_header', 'change', ({ target }) => {
-    const header = document.querySelector('.card .card__img img');
-    const [file] = target.files || [];
-    const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
-
-    header.src = url;
-  });
-
-  delegate(document, '#account_locked', 'change', ({ target }) => {
-    const lock = document.querySelector('.card .display-name i');
-
-    if (lock) {
-      if (target.checked) {
-        delete lock.dataset.hidden;
-      } else {
-        lock.dataset.hidden = 'true';
-      }
-    }
-  });
-
-  delegate(document, '.input-copy input', 'click', ({ target }) => {
-    target.focus();
-    target.select();
-    target.setSelectionRange(0, target.value.length);
-  });
-
-  delegate(document, '.input-copy button', 'click', ({ target }) => {
-    const input = target.parentNode.querySelector('.input-copy__wrapper input');
-
-    const oldReadOnly = input.readonly;
-
-    input.readonly = false;
-    input.focus();
-    input.select();
-    input.setSelectionRange(0, input.value.length);
-
-    try {
-      if (document.execCommand('copy')) {
-        input.blur();
-        target.parentNode.classList.add('copied');
-
-        setTimeout(() => {
-          target.parentNode.classList.remove('copied');
-        }, 700);
-      }
-    } catch (err) {
-      console.error(err);
-    }
-
-    input.readonly = oldReadOnly;
-  });
-
   const toggleSidebar = () => {
     const sidebar = document.querySelector('.sidebar ul');
     const toggleButton = document.querySelector('.sidebar__toggle__icon');
diff --git a/app/javascript/skins/glitch/contrast/common.scss b/app/javascript/skins/glitch/contrast/common.scss
new file mode 100644
index 000000000..90919d6d4
--- /dev/null
+++ b/app/javascript/skins/glitch/contrast/common.scss
@@ -0,0 +1 @@
+@import 'flavours/glitch/styles/contrast';
diff --git a/app/javascript/skins/glitch/contrast/names.yml b/app/javascript/skins/glitch/contrast/names.yml
new file mode 100644
index 000000000..5e7357439
--- /dev/null
+++ b/app/javascript/skins/glitch/contrast/names.yml
@@ -0,0 +1,12 @@
+en:
+  skins:
+    glitch:
+      contrast: High contrast
+cs:
+  skins:
+    glitch:
+      contrast: Vysoký kontrast
+es:
+  skins:
+    glitch:
+      contrast: Alto contraste
diff --git a/app/javascript/skins/glitch/mastodon-light/common.scss b/app/javascript/skins/glitch/mastodon-light/common.scss
new file mode 100644
index 000000000..c37f407b3
--- /dev/null
+++ b/app/javascript/skins/glitch/mastodon-light/common.scss
@@ -0,0 +1 @@
+@import 'flavours/glitch/styles/mastodon-light';
diff --git a/app/javascript/skins/glitch/mastodon-light/names.yml b/app/javascript/skins/glitch/mastodon-light/names.yml
new file mode 100644
index 000000000..a2c20548f
--- /dev/null
+++ b/app/javascript/skins/glitch/mastodon-light/names.yml
@@ -0,0 +1,12 @@
+en:
+  skins:
+    glitch:
+      mastodon-light: Mastodon (light)
+cs:
+  skins:
+    glitch:
+      mastodon-light: Mastodon (světlý)
+es:
+  skins:
+    glitch:
+      mastodon-light: Mastodon (claro)
diff --git a/app/javascript/skins/vanilla/contrast/common.scss b/app/javascript/skins/vanilla/contrast/common.scss
new file mode 100644
index 000000000..5f752b6d4
--- /dev/null
+++ b/app/javascript/skins/vanilla/contrast/common.scss
@@ -0,0 +1 @@
+@import 'styles/contrast';
diff --git a/app/javascript/skins/vanilla/contrast/names.yml b/app/javascript/skins/vanilla/contrast/names.yml
new file mode 100644
index 000000000..51d23f72d
--- /dev/null
+++ b/app/javascript/skins/vanilla/contrast/names.yml
@@ -0,0 +1,12 @@
+en:
+  skins:
+    vanilla:
+      contrast: High contrast
+cs:
+  skins:
+    vanilla:
+      contrast: Vysoký kontrast
+es:
+  skins:
+    vanilla:
+      contrast: Alto contraste
diff --git a/app/javascript/skins/vanilla/mastodon-light/common.scss b/app/javascript/skins/vanilla/mastodon-light/common.scss
new file mode 100644
index 000000000..e1a3ea2c6
--- /dev/null
+++ b/app/javascript/skins/vanilla/mastodon-light/common.scss
@@ -0,0 +1 @@
+@import 'styles/mastodon-light';
diff --git a/app/javascript/skins/vanilla/mastodon-light/names.yml b/app/javascript/skins/vanilla/mastodon-light/names.yml
new file mode 100644
index 000000000..fc6721e15
--- /dev/null
+++ b/app/javascript/skins/vanilla/mastodon-light/names.yml
@@ -0,0 +1,12 @@
+en:
+  skins:
+    vanilla:
+      mastodon-light: Mastodon (light)
+cs:
+  skins:
+    vanilla:
+      mastodon-light: Mastodon (světlý)
+es:
+  skins:
+    glitch:
+      mastodon-light: Mastodon (claro)
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..53b771c5e
--- /dev/null
+++ b/app/javascript/skins/vanilla/win95/names.yml
@@ -0,0 +1,8 @@
+en:
+  skins:
+    vanilla:
+      win95: Masto95
+cs:
+  skins:
+    vanilla:
+      win95: Windows 95
diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss
index 10dd63009..66f3eed9f 100644
--- a/app/javascript/styles/fonts/roboto-mono.scss
+++ b/app/javascript/styles/fonts/roboto-mono.scss
@@ -1,11 +1,11 @@
 @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')
+    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')
+    url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular')
       format('svg');
   font-weight: 400;
   font-display: swap;
diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss
index 672eab086..07cf0cb00 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 Italic'),
-    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')
+    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-display: swap;
@@ -14,10 +14,10 @@
 @font-face {
   font-family: mastodon-font-sans-serif;
   src: local('Roboto Bold'),
-    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')
+    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-display: swap;
@@ -27,10 +27,10 @@
 @font-face {
   font-family: mastodon-font-sans-serif;
   src: local('Roboto Medium'),
-    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')
+    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-display: swap;
@@ -40,10 +40,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')
+    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-display: swap;
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index c92191ea2..acb4baf4f 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -538,6 +538,22 @@ body,
   }
 }
 
+.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;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 0fee136cf..862252781 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1908,7 +1908,7 @@ a.account__display-name {
   .image-loader__preview-canvas {
     max-width: $media-modal-media-max-width;
     max-height: $media-modal-media-max-height;
-    background: url('../images/void.png') repeat;
+    background: url('~images/void.png') repeat;
     object-fit: contain;
   }
 
@@ -7148,7 +7148,7 @@ noscript {
     width: 100px;
     height: 100px;
     transform: translate(-50%, -50%);
-    background: url('../images/reticle.png') no-repeat 0 0;
+    background: url('~images/reticle.png') no-repeat 0 0;
     border-radius: 50%;
     box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
   }
diff --git a/app/javascript/styles/win95.scss b/app/javascript/styles/win95.scss
new file mode 100644
index 000000000..66d451303
--- /dev/null
+++ b/app/javascript/styles/win95.scss
@@ -0,0 +1,2684 @@
+//  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;
+$win95-window-header: #00007f;
+$win95-tooltip-yellow: #ffffcc;
+$win95-blue: blue;
+$win95-cyan: #008080;
+
+$ui-base-lighter-color: $win95-dark-grey;
+$ui-highlight-color: $win95-window-header;
+
+@mixin win95-border-outset() {
+  border-left: 2px solid #efefef;
+  border-top: 2px solid #efefef;
+  border-right: 2px solid #404040;
+  border-bottom: 2px solid #404040;
+  border-radius:0px;
+}
+
+@mixin win95-border-outset-sides-only() {
+  border-left: 2px solid #efefef;
+  border-right: 2px solid #404040;
+  border-radius:0px;
+}
+
+@mixin win95-outset() {
+  box-shadow: inset -1px -1px 0px #000000,
+              inset 1px 1px 0px #ffffff,
+              inset -2px -2px 0px #808080,
+              inset 2px 2px 0px #dfdfdf;
+  border-radius:0px;
+}
+
+@mixin win95-outset-no-highlight() {
+  box-shadow: inset -1px -1px 0px #000000,
+              inset -2px -2px 0px #808080;
+  border-radius:0px;
+}
+
+@mixin win95-border-inset() {
+  border-left: 2px solid #404040;
+  border-top: 2px solid #404040;
+  border-right: 2px solid #efefef;
+  border-bottom: 2px solid #efefef;
+  border-radius:0px;
+}
+
+@mixin win95-border-slight-inset() {
+  border-left: 1px solid #404040;
+  border-top: 1px solid #404040;
+  border-right: 1px solid #efefef;
+  border-bottom: 1px solid #efefef;
+  border-radius:0px;
+}
+
+@mixin win95-inset() {
+  box-shadow: inset 1px 1px 0px #000000,
+              inset -1px -1px 0px #ffffff,
+              inset 2px 2px 0px #808080,
+              inset -2px -2px 0px #dfdfdf;
+  border-width:0px;
+  border-radius:0px;
+}
+
+
+@mixin win95-tab() {
+  box-shadow: inset -1px 0px 0px #000000,
+              inset 1px 0px 0px #ffffff,
+              inset 0px 1px 0px #ffffff,
+              inset 0px 2px 0px #dfdfdf,
+              inset -2px 0px 0px #808080,
+              inset 2px 0px 0px #dfdfdf;
+  border-radius:0px;
+  border-top-left-radius: 1px;
+  border-top-right-radius: 1px;
+}
+
+@mixin win95-border-groove() {
+  border-radius: 0px;
+  border: 2px groove #bfbfbf;
+}
+
+@mixin win95-reset() {
+  box-shadow: unset;
+  border: 0px solid transparent;
+}
+
+@font-face {
+  font-family:"premillenium";
+  src: url('~fonts/premillenium/MSSansSerif.ttf') format('truetype');
+}
+
+@import 'application';
+
+/* borrowed from cybrespace style: wider columns and full column width images */
+
+@media screen and (min-width: 1300px) {
+  .drawer {
+    width: 17%; /* Not part of the flex fun */
+    max-width: 400px;
+    min-width: 330px;
+  }
+  .layout-multiple-columns .column {
+    flex-grow: 1 !important;
+    max-width: 400px;
+  }
+}
+
+/* Don't show outline around statuses if we're in
+ * mouse or touch mode (rather than keyboard) */
+[data-whatinput="mouse"], [data-whatinput="touch"] {
+  .status__content:focus, .status:focus,
+  .status__wrapper:focus, .status__content__text:focus {
+    outline: none;
+  }
+}
+
+/* Less emphatic show more */
+.status__content__read-more-button {
+  font-size: 14px;
+  color: $dark-text-color;
+
+  .status__prepend-icon {
+    padding-right: 4px;
+  }
+}
+
+/* Show a little arrowey thing after the time in a
+ * status to signal that you can click it to see
+ * a detailed view */
+.status time:after,
+.detailed-status__datetime span:after {
+  font: normal normal normal 14px/1 FontAwesome;
+  content: "\00a0\00a0\f08e";
+}
+
+/* Don't display the elephant mascot (we have our
+ * own, thanks) */
+.drawer__inner__mastodon {
+  display: none;
+}
+
+/* Let the compose area/drawer be short, but
+ * expand if necessary */
+.drawer .drawer__inner {
+  overflow: visible;
+  height:inherit;
+  background-image: none;
+}
+.drawer__pager {
+  overflow-y:auto;
+}
+
+/* Put a reasonable background on the single-column compose form */
+.layout-single-column .compose-panel {
+  background-color: $ui-base-color;
+  height: auto;
+  max-height: 100%;
+  overflow-y: visible;
+  margin-top: 65px;
+}
+
+/* Better distinguish the search bar */
+.layout-single-column .compose-panel .search {
+  position:relative;
+  top: -55px;
+  margin-bottom: -55px;
+}
+
+/* Use display: none instead of visibility:hidden
+ * to hide the suggested follows list on non-mobile */
+@media screen and (min-width: 630px) {
+  .search-results .trends {
+     display:none;
+  }
+}
+
+/* Don't display the weird triangles on the modal layout,
+ * because they look strange on cybrespace themes. */
+.modal-layout__mastodon {
+  display:none;
+}
+
+/* main win95 style */
+
+html {
+  scrollbar-color: $win95-mid-grey transparent;
+}
+
+body {
+  font-size:13px;
+  font-family: "MS Sans Serif", "premillenium", sans-serif;
+  color:black;
+}
+
+.ui,
+.ui .columns-area,
+body.admin {
+  background: $win95-cyan;
+}
+
+.loading-bar {
+  height:5px;
+  background-color: #000080;
+}
+
+.tabs-bar__wrapper {
+  background-color: $win95-cyan;
+}
+
+.tabs-bar {
+  background: $win95-bg;
+  @include win95-outset();
+  height: 30px;
+}
+
+.tabs-bar__link {
+  color:black;
+  border:2px outset $win95-bg;
+  border-top-width: 1px;
+  border-left-width: 1px;
+  margin:2px;
+  padding:3px;
+}
+
+.tabs-bar__link.active {
+  @include win95-inset();
+  color:black;
+}
+
+.tabs-bar__link:last-child::before {
+  content:"Start";
+  color:black;
+  font-weight:bold;
+  font-size:15px;
+  width:80%;
+  display:block;
+  position:absolute;
+  right:0px;
+}
+
+.tabs-bar__link:last-child {
+  position:relative;
+  flex-basis:60px !important;
+  font-size:0px;
+  color:$win95-bg;
+
+  background-image: url("~images/start.png");
+  background-repeat:no-repeat;
+  background-position:8%;
+  background-clip:padding-box;
+  background-size:auto 50%;
+}
+
+.drawer .drawer__inner {
+  overflow: visible;
+  height:inherit;
+  background:$win95-bg;
+}
+
+.drawer:after {
+    display:block;
+    content: " ";
+
+    position:absolute;
+    bottom:15px;
+    left:15px;
+    width:132px;
+    height:117px;
+    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;
+}
+
+.drawer__pager {
+  overflow-y:auto;
+  z-index:1;
+}
+
+.privacy-dropdown__dropdown {
+  z-index:2;
+}
+
+.column > .scrollable {
+  background: $win95-bg;
+  @include win95-border-outset();
+  border-top-width:0px;
+}
+
+.column-header__wrapper {
+  color:white;
+  font-weight:bold;
+  background:#7f7f7f;
+}
+
+.column-header {
+  padding:0px;
+  font-size:13px;
+  background:#7f7f7f;
+  @include win95-border-outset();
+  border-bottom-width:0px;
+  color:white;
+  font-weight:bold;
+  align-items:baseline;
+  min-height: 24px;
+}
+
+.column-header > button {
+  padding: 0px;
+  min-height: 22px;
+}
+
+.column-header__wrapper.active {
+  background:$win95-window-header;
+}
+
+.column-header__wrapper.active::before {
+  display:none;
+}
+.column-header.active {
+  box-shadow:unset;
+  background:$win95-window-header;
+}
+
+.column-header.active .column-header__icon {
+  color:white;
+}
+
+.column-header__buttons {
+  max-height: 20px;
+  margin: 2px;
+  margin-left: -2px;
+}
+
+.column-header__buttons button {
+  margin-left: 2px;
+}
+
+.column-header__button {
+  background: $win95-bg;
+  color: black;
+  @include win95-outset();
+
+  line-height:0px;
+  font-size:14px;
+  padding:0px 4px;
+
+  &:hover {
+    color: black;
+  }
+}
+
+.column-header__button.active, .column-header__button.active:hover {
+  @include win95-inset();
+  background-color:#7f7f7f;
+}
+
+// selectivity -- needs to override .column-header > button
+.column-header .column-header__back-button {
+  background: $win95-bg;
+  color: black;
+  padding:2px;
+  padding-right: 4px;
+  max-height: 20px;
+  min-height: unset;
+  margin: 2px;
+  @include win95-outset();
+  font-size: 13px;
+  line-height: 17px;
+  font-weight:bold;
+}
+
+.column-header__buttons .column-header__back-button {
+    margin: 0;
+}
+
+.column-back-button {
+  background:$win95-bg;
+  color:black;
+  @include win95-outset();
+  font-size:13px;
+  font-weight:bold;
+
+  padding: 2px;
+  height: 26px;
+}
+
+.column-back-button--slim-button {
+  position:absolute;
+  top:-22px;
+  right:4px;
+  max-height:20px;
+  padding: 1px 6px 0 2px;
+  box-sizing: border-box;
+}
+
+.column-back-button__icon {
+  font-size:11px;
+  margin-top:-3px;
+}
+
+.column-header__collapsible {
+  border-left:2px outset $win95-bg;
+  border-right:2px outset $win95-bg;
+}
+
+.column-header__collapsible-inner {
+  background:$win95-bg;
+  color:black;
+}
+
+.column-header__collapsible__extra {
+  color:black;
+}
+
+.column-header__collapsible__extra div[role="group"] {
+  border: 2px groove #eee;
+  margin-bottom: 11px;
+  padding: 4px;
+}
+
+.column-inline-form {
+  background-color: $win95-bg;
+  @include win95-border-outset();
+  border-bottom-width:0px;
+  border-top-width:0px;
+
+  align-items: baseline;
+}
+
+.column-inline-form .icon-button {
+    font-size: 14px!important;
+    line-height: 17px!important;
+}
+
+.column-inline-form .setting-text {
+    line-height: 17px;
+    padding-left: 4px;
+}
+
+.column-settings__section {
+  color:black;
+  font-weight:bold;
+  font-size:11px;
+}
+
+[role="group"] .column-settings__section {
+  display:inline-block;
+  background-color:$win95-bg;
+  position:relative;
+
+  top: -14px;
+  top: calc(-1em - 0.5ex);
+  left: 4px;
+
+  padding: 0px 4px;
+  margin-bottom: 0px;
+}
+
+.setting-meta__label, .setting-toggle__label {
+  color:black;
+  font-weight:normal;
+}
+
+.setting-meta__label span:before {
+  content:"(";
+}
+.setting-meta__label span:after {
+  content:")";
+}
+
+.setting-toggle {
+  line-height:13px;
+}
+
+.react-toggle .react-toggle-track {
+  border-radius:0px;
+  background-color:white;
+  @include win95-border-inset();
+
+  width:12px;
+  height:12px;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background-color:white;
+}
+
+.react-toggle .react-toggle-track-check {
+  left:2px;
+  transition:unset;
+}
+
+.react-toggle .react-toggle-track-check svg path {
+  fill: black;
+}
+
+.react-toggle .react-toggle-track-x {
+  display:none;
+}
+
+.react-toggle .react-toggle-thumb {
+  border-radius:0px;
+  display:none;
+}
+
+.text-btn {
+  background-color:$win95-bg;
+  @include win95-outset();
+  padding:4px;
+}
+
+.text-btn:hover {
+  text-decoration:none;
+  color:black;
+}
+
+.text-btn:active {
+  @include win95-inset();
+}
+
+.setting-text {
+  color:black;
+  background-color:white;
+  @include win95-inset();
+  font-size:13px;
+  padding:2px;
+}
+
+.setting-text:active, .setting-text:focus,
+.setting-text.light:active, .setting-text.light:focus {
+  color:black;
+  border-bottom:2px inset $win95-bg;
+}
+
+.column-header__setting-arrows .column-header__setting-btn {
+  padding:3px 10px;
+}
+
+.column-header__setting-arrows .column-header__setting-btn:last-child {
+  padding:3px 10px;
+}
+
+.missing-indicator {
+  background-color:$win95-bg;
+  color:black;
+  @include win95-outset();
+}
+
+.missing-indicator > div {
+  background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAARCAYAAAA7bUf6AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRUaXRsZQAACJnLyy9Jyy/NSwEAD5IDblIFOhoAAAAXelRYdEF1dGhvcgAACJlLzijKz0vMAQALmgLoDsFj8gAAAQpJREFUOMuVlD0OwjAMhd2oQl04Axfo0IGBgYELcAY6cqQuSO0ZOEAZGBg6VKg74gwsEaoESRVHjusI8aQqzY8/PbtOEz1qkFSn2YevlaNOpLMJh2DwvixhuXtOa6/LCh51DUMEFkAsgAZD207Doin8mQ562JpRE5CHBAAhmIqD1L8AqzUUUJkxc6kr3AgAJ+NuvIWRdk7WcrKl0AUqcIBBHOiEbpS4m27mIL5Onfg3k0rgggeQuS2sDOGSahKR+glgqaGLgUJs951NN1q9D72cQqQWR9cr3sm9YcEssEuz6eEuZh2bu0aSOhQ1MBezu2O/+TVSvEFII3qLsZWrSA2AAUQIh1HpyP/kC++zjVSMj6ntAAAAAElFTkSuQmCC') no-repeat;
+  background-position:center center;
+}
+
+.empty-column-indicator,
+.error-column {
+  background: $win95-bg;
+  color: black;
+}
+
+.notification__filter-bar {
+  background: $win95-bg;
+  @include win95-border-outset-sides-only();
+  padding-top: 10px;
+  padding-left: 10px;
+  padding-right: 10px;
+  border-bottom: 2px solid #efefef;
+  overflow-y: visible;
+
+  button {
+    background: transparent;
+    color: black;
+    padding: 8px 0;
+    align-self: end;
+    @include win95-tab();
+
+    &.active {
+      color: black;
+      top: 2px;
+      background-color: $win95-bg;
+
+      &::before, &::after {
+        display:none;
+      }
+    }
+  }
+}
+
+.status__wrapper {
+  border: 2px groove $win95-bg;
+  margin:4px;
+}
+
+.status {
+  @include win95-border-slight-inset();
+  background-color:white;
+  margin:4px;
+  padding-bottom:40px;
+  margin-bottom:8px;
+}
+
+.status.status-direct {
+  background:$win95-bg;
+  &:focus, &:active {
+    background:$win95-bg;
+  }
+
+  &:not(.read) {
+    background: white;
+  }
+}
+.focusable:focus .status.status-direct {
+  background:$win95-bg;
+}
+
+[data-whatinput="mouse"], [data-whatinput="touch"] {
+  .status__content:focus, .status:focus,
+  .status__wrapper:focus, .status__content__text:focus {
+    background-color: $win95-bg;
+  }
+
+  .status.status-direct, .detailed-status {
+    &:not(.read) {
+      .status__content:focus {
+        background-color: white;
+      }
+    }
+  }
+}
+
+.status__content, .reply-indicator__content {
+  font-size:13px;
+  color: black;
+}
+
+.status.light .status__relative-time,
+.status.light .display-name span {
+  color: #7f7f7f;
+}
+
+.status__action-bar {
+  box-sizing:border-box;
+  position:absolute;
+  bottom:-1px;
+  left:-1px;
+  background:$win95-bg;
+  width:calc(100% + 2px);
+  padding-left:10px;
+  padding: 4px 2px;
+  padding-bottom:4px;
+  border-bottom:2px groove $win95-bg;
+  border-top:1px outset $win95-bg;
+  text-align: right;
+}
+
+.status__wrapper .status__action-bar {
+  border-bottom-width:0px;
+}
+
+.status__action-bar-button {
+  float:right;
+}
+
+.status__action-bar-dropdown {
+  margin-left:auto;
+  margin-right:10px;
+
+  .icon-button {
+    min-width:28px;
+  }
+}
+.status.light .status__content a {
+  color:blue;
+}
+
+.focusable:focus {
+  background: $win95-bg;
+  .detailed-status__action-bar {
+    background: $win95-bg;
+  }
+
+  .status, .detailed-status {
+    background: white;
+    outline:2px dotted $win95-mid-grey;
+  }
+}
+
+.dropdown__trigger.icon-button {
+  padding-right:6px;
+}
+
+.detailed-status__action-bar-dropdown .icon-button {
+  min-width:28px;
+}
+
+.detailed-status {
+  background:white;
+  background-clip:padding-box;
+  margin:4px;
+  border: 2px groove $win95-bg;
+  padding:4px;
+}
+
+.detailed-status__display-name {
+  color:#7f7f7f;
+}
+
+.detailed-status__display-name strong {
+  color:black;
+  font-weight:bold;
+}
+.account__avatar,
+.account__avatar-overlay-base,
+.account__header__avatar,
+.account__avatar-overlay-overlay {
+  @include win95-border-slight-inset();
+  clip-path:none;
+  filter: saturate(1.8) brightness(1.1);
+}
+
+.detailed-status__action-bar {
+  background-color:$win95-bg;
+  border:0px;
+  border-bottom:2px groove $win95-bg;
+  margin-bottom:8px;
+  justify-items:left;
+  padding-left:4px;
+}
+
+.icon-button {
+  background:$win95-bg;
+  @include win95-border-outset();
+  padding:0px 0px 0px 0px;
+  margin-right:4px;
+
+  color:#3f3f3f;
+  &.inverted, &:hover, &.inverted:hover, &:active, &:focus {
+    color:#3f3f3f;
+  }
+}
+
+.icon-button:active {
+  @include win95-border-inset();
+}
+
+.status__action-bar > .icon-button {
+  padding:0px 15px 0px 0px;
+  min-width:25px;
+}
+
+.icon-button.star-icon,
+.icon-button.star-icon:active {
+  background:transparent;
+  border:none;
+}
+
+.icon-button.star-icon.active {
+  color: $gold-star;
+  &:active,  &:hover, &:focus {
+    color: $gold-star;
+  }
+}
+
+.icon-button.star-icon > i {
+  background:$win95-bg;
+  @include win95-border-outset();
+  padding-bottom:3px;
+}
+
+.icon-button.star-icon:active > i {
+  @include win95-border-inset();
+}
+
+.text-icon-button {
+  color:$win95-dark-grey;
+}
+
+.detailed-status__action-bar-dropdown {
+  margin-left:auto;
+  justify-content:right;
+  padding-right:16px;
+}
+
+.detailed-status__button {
+  flex:0 0 auto;
+}
+
+.detailed-status__button .icon-button {
+  padding-left:2px;
+  padding-right:25px;
+}
+
+.status-card, .status-card.compact, a.status-card, a.status-card.compact {
+  border-radius:0px;
+  background:white;
+  border: 1px solid black;
+  color:black;
+
+  &:hover {
+    background-color:white;
+  }
+}
+
+.status-card__title {
+  color:blue;
+  text-decoration:underline;
+  font-weight:bold;
+}
+
+.load-more {
+  width:auto;
+  margin:5px auto;
+  background: $win95-bg;
+  @include win95-outset();
+  color:black;
+  padding: 2px 5px;
+
+  &:hover {
+    background: $win95-bg;
+    color:black;
+  }
+}
+
+.status-card__description {
+ color:black;
+}
+
+.account__display-name strong, .status__display-name strong {
+  color:black;
+  font-weight:bold;
+}
+
+.account .account__display-name {
+  color:black;
+}
+
+.account {
+  border-bottom: none;
+}
+
+.reply-indicator__content .status__content__spoiler-link, .status__content .status__content__spoiler-link {
+  background:$win95-bg;
+  @include win95-outset();
+}
+
+.reply-indicator__content .status__content__spoiler-link:hover, .status__content .status__content__spoiler-link:hover {
+  background:$win95-bg;
+}
+
+.reply-indicator__content .status__content__spoiler-link:active, .status__content .status__content__spoiler-link:active {
+  @include win95-inset();
+}
+
+.reply-indicator__content a, .status__content a {
+  color:blue;
+}
+
+.notification {
+  border: 2px groove $win95-bg;
+  margin:4px;
+}
+
+.notification__message {
+  color:black;
+  font-size:13px;
+}
+
+.notification__display-name {
+  font-weight:bold;
+}
+
+
+.drawer__header {
+  background: $win95-bg;
+  @include win95-border-outset();
+  justify-content:left;
+  margin-bottom:0px;
+  padding-bottom:2px;
+  border-bottom:2px groove $win95-bg;
+}
+
+.drawer__tab {
+  color:black;
+  @include win95-outset();
+  padding:5px;
+  margin:2px;
+  flex: 0 0 auto;
+}
+
+.drawer__tab:first-child::before {
+  content:"Start";
+  color:black;
+  font-weight:bold;
+  font-size:15px;
+  width:80%;
+  display:block;
+  position:absolute;
+  right:0px;
+
+}
+
+.drawer__tab:first-child {
+  position:relative;
+  padding:5px 15px;
+  width:40px;
+  font-size:0px;
+  color:$win95-bg;
+
+  background-image: url("~images/start.png");
+  background-repeat:no-repeat;
+  background-position:8%;
+  background-clip:padding-box;
+  background-size:auto 50%;
+}
+
+.drawer__header a:hover {
+  background-color:transparent;
+}
+
+.drawer__header a:first-child:hover {
+  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=");
+  background-repeat:no-repeat;
+  background-position:8%;
+  background-clip:padding-box;
+  background-size:auto 50%;
+  transition:unset;
+}
+
+.drawer__tab:first-child {
+
+}
+
+.search {
+  background:$win95-bg;
+  padding-top:2px;
+  padding:2px;
+  border:2px outset $win95-bg;
+  border-top-width:0px;
+  border-bottom: 2px groove $win95-bg;
+  margin-bottom:0px;
+}
+
+.search input {
+  background-color:white;
+  color:black;
+  @include win95-border-slight-inset();
+}
+
+.search__input:focus {
+  background-color:white;
+}
+
+.search-popout {
+  box-shadow: unset;
+  color:black;
+  border-radius:0px;
+  background-color:$win95-tooltip-yellow;
+  border:1px solid black;
+
+  h4 {
+    color:black;
+    text-transform: none;
+    font-weight:bold;
+  }
+}
+
+.search-results__header {
+  background-color: $win95-bg;
+  color:black;
+  border-bottom:2px groove $win95-bg;
+}
+
+.search-results__hashtag {
+  color:blue;
+}
+
+.search-results__section h5:before {
+    display: none;
+}
+
+.search-results__section h5 {
+  background: #bfbfbf;
+  span {
+    color: black;
+    padding: 0px 2px;
+  }
+}
+
+.search-results__section {
+    border: 3px groove white;
+    margin: 11px 6px 9px 3px;
+}
+
+.search-results__section .account:hover,
+.search-results__section .account:hover .account__display-name,
+.search-results__section .account:hover .account__display-name strong,
+.search-results__section .search-results__hashtag:hover {
+  background-color:$win95-window-header;
+  color:white;
+}
+
+.search__icon .fa {
+  color:#808080;
+
+  &.active {
+    opacity:1.0;
+  }
+
+  &:hover {
+    color: #808080;
+  }
+}
+
+.trends__item__name a,
+.trends__item__current {
+  color: black;
+}
+
+.drawer__inner,
+.drawer__inner.darker {
+  background-color:$win95-bg;
+  border: 2px outset $win95-bg;
+  border-top-width:0px;
+}
+
+.navigation-bar {
+  color:black;
+}
+
+.navigation-bar strong {
+  color:black;
+  font-weight:bold;
+}
+
+.compose-form .autosuggest-textarea__textarea,
+.compose-form .spoiler-input__input {
+  border-radius:0px;
+  @include win95-border-slight-inset();
+}
+
+.compose-form .autosuggest-textarea__textarea {
+  border-bottom:0px;
+}
+
+.compose-form__uploads-wrapper {
+  border-radius:0px;
+  border-bottom:1px inset $win95-bg;
+  border-top-width:0px;
+}
+
+.compose-form__upload-wrapper {
+  border-left:1px inset $win95-bg;
+  border-right:1px inset $win95-bg;
+}
+
+.compose-form .compose-form__buttons-wrapper {
+  background-color: $win95-bg;
+  border:2px groove $win95-bg;
+  margin-top:4px;
+  padding:4px 8px;
+}
+
+.compose-form__buttons {
+  background-color:$win95-bg;
+  border-radius:0px;
+  box-shadow:unset;
+}
+
+.compose-form__buttons-separator {
+  border-left: 2px groove $win95-bg;
+}
+
+.compose-form__poll-wrapper .icon-button.disabled {
+  color: $win95-mid-grey;
+}
+
+.privacy-dropdown.active .privacy-dropdown__value.active,
+.advanced-options-dropdown.open .advanced-options-dropdown__value {
+  background: $win95-bg;
+}
+
+.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {
+  color: $win95-dark-grey;
+}
+
+.privacy-dropdown.active
+.privacy-dropdown__value {
+  background: $win95-bg;
+  box-shadow:unset;
+}
+
+.privacy-dropdown__option.active, .privacy-dropdown__option:hover,
+.privacy-dropdown__option.active:hover {
+  background:$win95-window-header;
+}
+
+.privacy-dropdown__dropdown,
+.privacy-dropdown.active .privacy-dropdown__dropdown,
+.advanced-options-dropdown__dropdown,
+.advanced-options-dropdown.open .advanced-options-dropdown__dropdown
+{
+  box-shadow:unset;
+  color:black;
+  @include win95-outset();
+  background: $win95-bg;
+}
+
+.privacy-dropdown__option__content {
+  color:black;
+}
+
+.privacy-dropdown__option__content strong {
+  font-weight:bold;
+}
+
+.compose-form {
+  .compose-form__warning::before {
+    content:"Tip:";
+    font-weight:bold;
+    display:block;
+    position:absolute;
+    top:-10px;
+    background-color:$win95-bg;
+    font-size:11px;
+    padding: 0px 5px;
+  }
+
+  .compose-form__warning {
+    position:relative;
+    box-shadow:unset;
+    border:2px groove $win95-bg;
+    background-color:$win95-bg;
+    color:black;
+  }
+
+  .compose-form__warning a {
+    color:blue;
+  }
+
+  .compose-form__warning strong {
+    color:black;
+    text-decoration:underline;
+  }
+}
+
+.compose-form__buttons button.active:last-child {
+  @include win95-border-inset();
+  background: #dfdfdf;
+  color:#7f7f7f;
+}
+
+.compose-form__upload-thumbnail {
+  border-radius:0px;
+  border:2px groove $win95-bg;
+  background-color:$win95-bg;
+  padding:2px;
+  box-sizing:border-box;
+}
+
+.compose-form__upload-thumbnail .icon-button {
+  max-width:20px;
+  max-height:20px;
+  line-height:10px !important;
+}
+
+.compose-form__upload-thumbnail .icon-button::before {
+  content:"X";
+  font-size:13px;
+  font-weight:bold;
+  color:black;
+}
+
+.compose-form__upload-thumbnail .icon-button i {
+  display:none;
+}
+
+.emoji-picker-dropdown__menu {
+  z-index:2;
+}
+
+.emoji-dialog.with-search {
+  box-shadow:unset;
+  border-radius:0px;
+  background-color:$win95-bg;
+  border:1px solid black;
+  box-sizing:content-box;
+
+}
+
+.emoji-dialog .emoji-search {
+  color:black;
+  background-color:white;
+  border-radius:0px;
+  @include win95-inset();
+}
+
+.emoji-dialog .emoji-search-wrapper {
+  border-bottom:2px groove $win95-bg;
+}
+
+.emoji-dialog .emoji-category-title {
+  color:black;
+  font-weight:bold;
+}
+
+.reply-indicator {
+  background-color:$win95-bg;
+  border-radius:3px;
+  border:2px groove $win95-bg;
+}
+
+.button {
+  background-color:$win95-bg;
+  @include win95-outset();
+  border-radius:0px;
+  color:black;
+  font-weight:bold;
+
+  &:hover, &:focus, &:disabled {
+    background-color:$win95-bg;
+  }
+
+  &:active {
+    @include win95-inset();
+  }
+
+  &:disabled {
+    color: #808080;
+    text-shadow: 1px 1px 0px #efefef;
+
+    &:active {
+      @include win95-outset();
+    }
+  }
+
+}
+
+.button.button-secondary {
+  background-color: $win95-bg;
+}
+
+.column-link {
+  background-color:transparent;
+  color:black;
+  &:hover {
+    background-color: $win95-window-header;
+    color:white;
+  }
+}
+
+.column-link__badge {
+  background-image: url('~images/alert_badge.png');
+  background-repeat: no-repeat;
+  background-size:contain;
+  background-color:transparent;
+  border-radius:0;
+  box-sizing: border-box;
+  width: 24px;
+  height:24px;
+  padding-top:4px;
+  padding-left:0px;
+  padding-right:1px;
+  text-align:center;
+  position:relative;
+  top:2px;
+}
+
+.column-link:hover .column-link__badge {
+  color:black;
+}
+
+.column-subheading {
+  background-color:$win95-bg;
+  color:black;
+  @include win95-border-outset-sides-only;
+}
+
+.column {
+  overflow-y:auto;
+}
+
+.getting-started {
+  background: none;
+  position:relative;
+  top:-30px;
+  padding-top:30px;
+  z-index:10;
+  overflow-y:auto;
+  background-color: $win95-cyan;
+}
+
+.getting-started__wrapper {
+  padding-top:0px;
+
+  box-shadow: inset -1px 0px 0px #000000,
+              inset 1px 1px 0px #ffffff,
+              inset -2px 0px 0px #808080,
+              inset 2px 2px 0px #dfdfdf;
+  border-radius:0px;
+
+  background-color:$win95-bg;
+  border-bottom: 2px groove $win95-bg;
+
+  height: unset !important;
+
+  .navigation-bar {
+    padding-left: 45px;
+  }
+
+  .column-subheading {
+    font-size:0px;
+    margin:0px;
+    padding:0px;
+    background-color: transparent;
+    color:black;
+    border-bottom: 2px groove $win95-bg;
+    text-transform: none;
+  }
+
+}
+
+.column-link {
+    background-size:32px 32px;
+    background-repeat:no-repeat;
+    background-position: 36px 50%;
+    padding-left:45px;
+
+    &:hover {
+      background-size:32px 32px;
+      background-repeat:no-repeat;
+      background-position: 36px 50%;
+    }
+
+    i {
+      font-size: 0px;
+      width:32px;
+    }
+  }
+
+.getting-started__wrapper::before {
+  content: "Start";
+  display:block;
+  color:black;
+  font-weight:bold;
+  font-size:15px;
+  position:absolute;
+  top:0px;
+  left:0px;
+  padding:5px 15px;
+  width:50px;
+  font-size:16px;
+  padding-left:25px;
+  background-color:$win95-bg;
+  z-index:12;
+
+  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=");
+  background-repeat:no-repeat;
+  background-position:8%;
+  background-clip:padding-box;
+  background-size:auto 50%;
+
+  @include win95-border-inset();
+}
+
+
+@media screen and (min-width: 360px) {
+  .getting-started__wrapper{
+    margin-bottom:0px;
+  }
+}
+
+@media screen and (max-width: 360px) {
+  .getting-started {
+    top:0px;
+    padding-top:0px;
+  }
+
+  .getting-started__wrapper::before {
+    display:none;
+  }
+}
+
+.getting-started__footer {
+  background-color: $win95-bg;
+  padding:0px;
+  padding-bottom:10px;
+  position:relative;
+  top:0px;
+
+  @include win95-outset-no-highlight();
+
+  p {
+    margin-left: 45px;
+  }
+
+  ul {
+    display:block;
+    li {
+      cursor:pointer;
+      display:block;
+      font-size:0px;
+      padding:0px;
+      line-height:0;
+      a {
+        padding:15px;
+        padding-left:77px;
+        line-height:1em;
+        font-size:16px;
+        display:block;
+        color:black;
+        background-size:32px 32px;
+        background-repeat:no-repeat;
+        background-position: 36px 50%;
+        &:hover {
+          text-decoration:none;
+        }
+      }
+
+      &:hover {
+        background-color: $win95-window-header;
+        a {
+          color:white;
+        }
+      }
+    }
+  }
+}
+
+.getting-started__footer::after {
+  content:"Mastodon 95";
+  font-weight:bold;
+  font-size:23px;
+  color:white;
+  line-height:30px;
+  padding-left:20px;
+  padding-right:40px;
+
+  left:0px;
+  box-sizing:border-box;
+  bottom:-32px;
+  display:block;
+  position:absolute;
+  background-color:#7f7f7f;
+  width:1000px;
+  height:32px;
+
+  z-index:11;
+
+  border-left: 2px solid #404040;
+  border-top: 2px solid #efefef;
+  border-right: 2px solid #efefef;
+  border-radius:0px;
+
+  -ms-transform: rotate(-90deg);
+
+  -webkit-transform: rotate(-90deg);
+  transform: rotate(-90deg);
+  transform-origin:top left;
+}
+
+.layout-single-column .getting-started__footer::after {
+  display: none;
+}
+
+.getting-started__wrapper + .flex-spacer {
+  display:none;
+}
+
+.column-link[href="/web/timelines/home"] {
+  background-image: url("~images/icon_home.png");
+  &:hover { background-image: url("~images/icon_home.png"); }
+}
+.column-link[href="/web/notifications"] {
+  background-image: url("~images/icon_notifications.png");
+  &:hover { background-image: url("~images/icon_notifications.png"); }
+}
+.column-link[href="/web/timelines/public"] {
+  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"); }
+}
+.column-link[href="/web/timelines/direct"] {
+  background-image: url("~images/icon_direct.png");
+  &:hover { background-image: url("~images/icon_direct.png"); }
+}
+.column-link[href="/web/pinned"] {
+  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"); }
+}
+.column-link[href="/web/lists"] {
+  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"); }
+}
+.column-link[href="/web/blocks"] {
+  background-image: url("~images/icon_blocks.png");
+  &:hover { background-image: url("~images/icon_blocks.png"); }
+}
+.column-link[href="/web/domain_blocks"] {
+  background-image: url("~images/icon_domain_blocks.png");
+  &:hover { background-image: url("~images/icon_domain_blocks.png"); }
+}
+.column-link[href="/web/mutes"] {
+  background-image: url("~images/icon_mutes.png");
+  &:hover { background-image: url("~images/icon_mutes.png"); }
+}
+.column-link[href="/web/directory"] {
+  background-image: url("~images/icon_profile_directory.png");
+  &:hover { background-image: url("~images/icon_profile_directory.png"); }
+}
+.column-link[href="/web/bookmarks"] {
+  background-image: url("~images/icon_bookmarks.png");
+  &:hover { background-image: url("~images/icon_bookmarks.png"); }
+}
+
+.getting-started__footer ul li a[href="/web/keyboard-shortcuts"] {
+  background-image: url("~images/icon_keyboard_shortcuts.png");
+  &:hover { background-image: url("~images/icon_keyboard_shortcuts.png"); }
+}
+.getting-started__footer ul li a[href="/invites"] {
+  background-image: url("~images/icon_invite.png");
+  &:hover { background-image: url("~images/icon_invite.png"); }
+}
+.getting-started__footer ul li a[href="/terms"] {
+  background-image: url("~images/icon_tos.png");
+  &:hover { background-image: url("~images/icon_tos.png"); }
+}
+.getting-started__footer ul li a[href="https://docs.joinmastodon.org"] {
+  background-image: url("~images/icon_docs.png");
+  &:hover { background-image: url("~images/icon_docs.png"); }
+}
+.getting-started__footer ul li a[href="/about/more"] {
+  background-image: url("~images/icon_about.png");
+  &:hover { background-image: url("~images/icon_about.png"); }
+}
+.getting-started__footer ul li a[href="/auth/sign_out"] {
+  background-image: url("~images/icon_logout.png");
+  &:hover { background-image: url("~images/icon_logout.png"); }
+}
+.getting-started__footer ul li a[href="https://joinmastodon.org/apps"] {
+  background-image: url("~images/icon_mobile_apps.png");
+  &:hover { background-image: url("~images/icon_mobile_apps.png"); }
+}
+.getting-started__footer ul li a[href="/settings/applications"] {
+  background-image: url("~images/icon_developers.png");
+  &:hover { background-image: url("~images/icon_developers.png"); }
+}
+.getting-started__footer ul li a[href="/auth/edit"] {
+  background-image: url("~images/icon_settings.png");
+  &:hover { background-image: url("~images/icon_settings.png"); }
+}
+
+.column .static-content.getting-started {
+  display:none;
+}
+
+.keyboard-shortcuts kbd {
+  background-color: $win95-bg;
+}
+
+.account__header {
+  background-color:#7f7f7f;
+}
+
+.account__header .account__header__content {
+  color:white;
+}
+
+.account__header__fields {
+  border-left: 1px solid black;
+  border-top: 1px solid black;
+
+  dt {
+    background-color:$win95-bg;
+    color:black;
+    border-top: 1px solid #ffffff;
+    border-bottom: 1px solid $win95-mid-grey;
+    border-right: 1px solid $win95-mid-grey;
+  }
+  dd {
+    background-color:white;
+    border: 1px solid $win95-bg;
+    color:black;
+  }
+  dd,dt {
+    padding: 5px 8px;
+  }
+}
+
+.account-authorize__wrapper {
+  border: 2px groove $win95-bg;
+  margin: 2px;
+  padding:2px;
+}
+
+.domain .domain__domain-name strong {
+  color: black;
+}
+
+.account--panel {
+  background-color: $win95-bg;
+  border:0px;
+  border-top: 2px groove $win95-bg;
+}
+
+.account-authorize .account__header__content {
+  color:black;
+  margin:10px;
+}
+
+.account__action-bar__tab > span {
+  color:black;
+  font-weight:bold;
+}
+
+.account__action-bar__tab strong {
+  color:black;
+}
+
+.account__action-bar {
+  border: unset;
+}
+
+.account__action-bar__tab {
+  border: 1px outset $win95-bg;
+}
+
+.account__action-bar__tab:active {
+  @include win95-inset();
+}
+
+.account__section-headline {
+  background: $win95-bg;
+  margin-top: 5px;
+
+  & > a {
+    @include win95-tab();
+    color: black;
+    padding: 5px;
+
+    &.active {
+      background: $win95-bg;
+      @include win95-inset();
+      color: black;
+
+      &:before, &:after {
+        display: none;
+      }
+    }
+  }
+}
+
+.dropdown--active .dropdown__content > ul,
+.dropdown-menu {
+  background:$win95-tooltip-yellow;
+  border-radius:0px;
+  border:1px solid black;
+  box-shadow:unset;
+  margin-top: 6px;
+}
+
+.dropdown-menu a {
+  background-color:transparent;
+}
+
+.dropdown-menu__arrow {
+  &.bottom {
+    border-bottom-color: $win95-tooltip-yellow;
+  }
+
+  &.top {
+    border-top-color: $win95-tooltip-yellow;
+  }
+
+  &:before {
+    position: relative;
+    border: 0 solid transparent;
+    display: block;
+    content: '';
+    left: -8px;
+    z-index: -1;
+  }
+
+  &.bottom::before {
+    border-bottom-color: black;
+    border-width: 0 8px 6px;
+    bottom: 1px;
+  }
+
+  &.top::before {
+    border-top-color: black;
+    border-width: 6px 8px 0;
+    top: -5px;
+  }
+}
+
+.dropdown-menu {
+  margin-top: 6px;
+}
+
+.dropdown--active::after {
+  display:none;
+}
+
+.dropdown--active .icon-button {
+  color:black;
+  @include win95-inset();
+}
+
+.dropdown--active .dropdown__content > ul > li > a {
+  background:transparent;
+}
+
+.dropdown--active .dropdown__content > ul > li > a:hover {
+  background:transparent;
+  color:black;
+  text-decoration:underline;
+}
+
+.dropdown__sep,
+.dropdown-menu__separator
+{
+  border-color:#7f7f7f;
+}
+
+.detailed-status__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__left {
+  left: unset;
+}
+
+.dropdown > .icon-button, .detailed-status__action-bar .icon-button,
+.status__action-bar .icon-button, .star-icon i {
+    /* i don't know what's going on with the inline
+       styles someone should look at the react code */
+    height: 25px !important;
+    width: 28px !important;
+    box-sizing: border-box;
+}
+
+.icon-button {
+  height: auto!important;
+  width: auto!important;
+}
+
+.status__action-bar-dropdown .icon-button {
+  position: relative;
+  top: -1px;
+}
+
+.fa-user-plus, .fa-user-times {
+  padding: 2px 0px 2px 1px;
+}
+
+.fa-ellipsis-h {
+  padding-top: 3px;
+}
+
+.status__action-bar-button .fa-floppy-o {
+  padding-top: 2px;
+}
+
+.notification .status__action-bar {
+  border-bottom: none;
+}
+
+.notification .status {
+  margin-bottom: 4px;
+}
+
+.status__wrapper .status {
+  margin-bottom: 3px;
+}
+
+.status__wrapper {
+  margin-bottom: 8px;
+}
+
+.status__prepend {
+  color: black;
+  font-size: 13px;
+}
+
+.icon-button .fa-retweet {
+  position: relative;
+  top: -1px;
+}
+
+.embed-modal, .error-modal, .onboarding-modal,
+.actions-modal, .boost-modal, .confirmation-modal, .report-modal {
+  @include win95-outset();
+  background:$win95-bg;
+}
+
+.actions-modal::before,
+.boost-modal::before,
+.confirmation-modal::before,
+.report-modal::before {
+  content: "Confirmation";
+  display:block;
+  background:$win95-window-header;
+  color:white;
+  font-weight:bold;
+  padding-left:2px;
+}
+
+.boost-modal::before {
+  content: "Boost confirmation";
+}
+
+.boost-modal__action-bar > div > span:before {
+  content: "Tip: ";
+  font-weight:bold;
+}
+
+.boost-modal__action-bar, .confirmation-modal__action-bar, .report-modal__action-bar {
+  background:$win95-bg;
+  margin-top:-15px;
+}
+
+.embed-modal h4, .error-modal h4, .onboarding-modal h4 {
+  background:$win95-window-header;
+  color:white;
+  font-weight:bold;
+  padding:2px;
+  font-size:13px;
+  text-align:left;
+}
+
+.media-modal .media-modal__close {
+    font-size: 14px!important;
+    line-height: 17px!important;
+    margin-right: 4vw;
+    margin-top: 4vh;
+}
+
+.confirmation-modal__action-bar {
+  .confirmation-modal__cancel-button {
+    color:black;
+
+    &:active,
+    &:focus,
+    &:hover {
+      color:black;
+    }
+
+    &:active {
+      @include win95-inset();
+    }
+  }
+}
+
+.embed-modal .embed-modal__container .embed-modal__html,
+.embed-modal .embed-modal__container .embed-modal__html:focus {
+  background:white;
+  color:black;
+  @include win95-inset();
+}
+
+.report-modal__target .media-modal__close {
+  top: 3px;
+  right: 0px;
+  font-size: 12px!important;
+  line-height: 13px!important;
+}
+
+.report-modal__comment p {
+    font-size: 12px;
+    margin-bottom: 1em;
+    padding-left: 3px;
+}
+
+.report-modal__comment .setting-text.light {
+    border-radius: 0;
+}
+
+.report-modal__container {
+    margin-right: 2px;
+}
+
+.report-modal::before {
+    height: 22px;
+    line-height: 23px;
+}
+
+.status-check-box__status .media-gallery {
+    margin: unset;
+}
+
+.modal-root__overlay,
+.account__header > div {
+  background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFnpUWHRUaXRsZQAACJnLzU9JzElKBwALgwLXaCRlPwAAABd6VFh0QXV0aG9yAAAImUvOKMrPS8wBAAuaAugOwWPyAAAAEUlEQVQImWNgYGD4z4AE/gMADwMB/414xEUAAAAASUVORK5CYII=');
+}
+
+
+.admin-wrapper::before {
+  position:absolute;
+  top:0px;
+  content:"Control Panel";
+  color:white;
+  background-color:$win95-window-header;
+  font-size:13px;
+  font-weight:bold;
+  width:calc(100%);
+  margin: 2px;
+  display:block;
+  padding:2px;
+  padding-left:22px;
+  box-sizing:border-box;
+}
+
+.admin-wrapper {
+  position:relative;
+  background: $win95-bg;
+  @include win95-outset();
+  width:70vw;
+  height:80vh;
+  min-height:80vh;
+  margin:10vh auto;
+  color: black;
+  padding-top:24px;
+  flex-direction:column;
+  overflow:hidden;
+}
+
+@media screen and (max-width: 1120px) {
+  .admin-wrapper {
+    width:90vw;
+    height:95vh;
+    margin:2.5vh auto;
+  }
+}
+
+@media screen and (max-width: 740px) {
+  .admin-wrapper {
+    width:100vw;
+    height:95vh;
+    height:calc(100vh - 24px);
+    margin:0px 0px 0px 0px;
+  }
+}
+
+.admin-wrapper .sidebar-wrapper {
+  position:static;
+  height:auto;
+  min-height:auto;
+  flex: 0 0 auto;
+  margin:2px;
+}
+
+.admin-wrapper .content-wrapper {
+  flex: 1 1 auto;
+  width:calc(100% - 20px);
+  max-width:calc(100% - 20px);
+  @include win95-border-outset();
+  position:relative;
+  margin-left:10px;
+  margin-right:10px;
+  margin-bottom:40px;
+  box-sizing:border-box;
+  overflow-y:scroll;
+  height: 100%;
+}
+
+.admin-wrapper .content {
+  background-color: $win95-bg;
+  width: 100%;
+  max-width:100%;
+  min-height:100%;
+  box-sizing:border-box;
+  position:relative;
+}
+.admin-wrapper .content h4 {
+  color: black;
+}
+
+.admin-wrapper .sidebar {
+  position:static;
+  background: $win95-bg;
+  color:black;
+  width: 100%;
+  height:auto;
+  padding-bottom: 20px;
+}
+
+.admin-wrapper .sidebar .logo {
+  position:absolute;
+  top:2px;
+  left:4px;
+  width:18px;
+  height:18px;
+  margin:0px;
+}
+
+.admin-wrapper .sidebar > ul {
+  background: $win95-bg;
+  margin:0px;
+  margin-left:8px;
+  color:black;
+
+  & > li {
+    display:inline-block;
+
+    &#settings,
+    &#admin {
+      padding:2px;
+      border: 0px solid transparent;
+    }
+
+    &#logout {
+      position:absolute;
+      @include win95-outset();
+      right:12px;
+      bottom:10px;
+    }
+
+    &#web {
+      display:inline-block;
+      @include win95-outset();
+      position:absolute;
+      left: 12px;
+      bottom: 10px;
+    }
+
+    & > a {
+      display:inline-block;
+      @include win95-tab();
+      padding:2px 5px;
+      margin:0px;
+      color:black;
+      vertical-align:baseline;
+
+      &.selected {
+        background: $win95-bg;
+        color:black;
+        padding-top: 4px;
+        padding-bottom:4px;
+      }
+
+      &:hover {
+        background: $win95-bg;
+        color:black;
+      }
+    }
+
+    & > ul {
+      width:calc(100% - 20px);
+      background: transparent;
+      position:absolute;
+      left: 10px;
+      top:54px;
+      z-index:3;
+
+      & > li {
+        background: $win95-bg;
+        display: inline-block;
+        vertical-align:baseline;
+
+        & > a {
+          background: $win95-bg;
+          @include win95-tab();
+          color:black;
+          padding:2px 5px;
+          position:relative;
+          z-index:3;
+
+          &.selected {
+            background: $win95-bg;
+            color:black;
+            padding-bottom:4px;
+            padding-top: 4px;
+            padding-right:7px;
+            margin-left:-2px;
+            margin-right:-2px;
+            position:relative;
+            z-index:4;
+
+            &:first-child {
+              margin-left:0px;
+            }
+
+            &:hover {
+              background: transparent;
+              color:black;
+            }
+          }
+
+          &:hover {
+            background: $win95-bg;
+            color:black;
+          }
+        }
+      }
+    }
+  }
+}
+
+.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover {
+  background: $win95-bg;
+}
+
+@media screen and (max-width: 1520px) {
+  .admin-wrapper .sidebar > ul > li > ul {
+    max-width:1000px;
+  }
+
+  .admin-wrapper .sidebar {
+    padding-bottom: 45px;
+  }
+}
+
+@media screen and (max-width: 600px) {
+  .admin-wrapper .sidebar > ul > li > ul {
+    max-width:500px;
+  }
+
+  .admin-wrapper {
+    .sidebar {
+      padding:0px;
+      padding-bottom: 70px;
+      width: 100%;
+      height: auto;
+    }
+    .content-wrapper {
+      overflow:auto;
+      height:80%;
+      height:calc(100% - 150px);
+    }
+  }
+}
+
+.flash-message {
+  background-color:$win95-tooltip-yellow;
+  color:black;
+  border:1px solid black;
+  border-radius:0px;
+  position:absolute;
+  top:0px;
+  left:0px;
+  width:100%;
+}
+
+.admin-wrapper table {
+  background-color: white;
+  @include win95-border-slight-inset();
+}
+
+.admin-wrapper .table th, .table td {
+  background-color:transparent;
+}
+
+.admin-wrapper button.table-action-link,
+.admin-wrapper a.table-action-link,
+.admin-wrapper button.table-action-link:hover,
+.admin-wrapper a.table-action-link:hover,
+.admin-wrapper a.name-tag,
+.admin-wrapper .name-tag,
+.admin-wrapper a.inline-name-tag,
+.admin-wrapper .inline-name-tag,
+.admin-wrapper .content h2,
+.admin-wrapper .content h3,
+.simple_form .input.with_label .label_input > label,
+.admin-wrapper .content h6,
+.admin-wrapper .content > p,
+.admin-wrapper .content .muted-hint,
+.simple_form span.hint,
+.simple_form h4,
+.simple_form .check_boxes .checkbox label,
+.simple_form .input.with_label.boolean .label_input > label,
+.filters .filter-subset a,
+.simple_form .input.radio_buttons .radio label,
+a.table-action-link,
+a.table-action-link:hover,
+.simple_form .input.with_block_label > label,
+.simple_form p.hint,
+.admin-wrapper .content > p strong,
+.simple_form .input.with_floating_label .label_input > label,
+.admin-wrapper .content .fields-group h6 {
+  color:black;
+}
+
+.report-card {
+  background: white;
+  border: 1px solid black;
+  border-radius: 0px;
+}
+
+.report-card__summary__item:hover {
+  background: white;
+}
+
+.report-card__summary__item__content a {
+  color: black;
+}
+
+.directory__tag > a, .directory__tag > div,
+.directory__tag > a:hover, .directory__tag > a:active, .directory__tag > a:focus {
+  background: white;
+  border: 1px solid black;
+  border-radius: 0px;
+}
+
+.admin-wrapper .content .directory__tag h4 {
+  color: black;
+}
+
+.simple_form .hint code {
+  background: $win95-bg;
+  border-radius: 0px;
+}
+
+.input-copy {
+  background: transparent;
+  border: 0px solid transparent;
+}
+
+.table > tbody > tr:nth-child(2n+1) > td,
+.table > tbody > tr:nth-child(2n+1) > th {
+  background-color:white;
+}
+
+.simple_form input[type=text],
+.simple_form input[type=number],
+.simple_form input[type=email],
+.simple_form input[type=password],
+.simple_form textarea {
+  color:black;
+  background-color:white;
+  @include win95-border-slight-inset();
+
+  &:active, &:focus {
+    background-color:white;
+  }
+}
+
+.simple_form button,
+.simple_form .button,
+.simple_form .block-button
+{
+  background: $win95-bg;
+  @include win95-outset();
+  color:black;
+  font-weight: normal;
+
+  &:hover {
+    background: $win95-bg;
+  }
+}
+
+.simple_form .warning, .table-form .warning
+{
+  background: $win95-tooltip-yellow;
+  color:black;
+  box-shadow: unset;
+  text-shadow:unset;
+  border:1px solid black;
+
+  a {
+    color: blue;
+    text-decoration:underline;
+  }
+}
+
+.simple_form button.negative,
+.simple_form .button.negative,
+.simple_form .block-button.negative
+{
+  background: $win95-bg;
+}
+
+.simple_form select {
+  background: white;
+  border-radius: 0px;
+  color: black;
+}
+
+.filters .filter-subset  {
+  border: 2px groove $win95-bg;
+  padding:2px;
+}
+
+.filters .filter-subset a::before {
+  content: "";
+  background-color:white;
+  border-radius:50%;
+  border:2px solid black;
+  border-top-color:#7f7f7f;
+  border-left-color:#7f7f7f;
+  border-bottom-color:#f5f5f5;
+  border-right-color:#f5f5f5;
+  width:12px;
+  height:12px;
+  display:inline-block;
+  vertical-align:middle;
+  margin-right:2px;
+}
+
+.filters .filter-subset a.selected::before {
+  background-color:black;
+  box-shadow: inset 0 0 0 3px white;
+}
+
+.filters .filter-subset a,
+.filters .filter-subset a:hover,
+.filters .filter-subset a.selected {
+  color:black;
+  border-bottom: 0px solid transparent;
+}
+
+.drawer__inner__mastodon {
+  display: none;
+}
+
+.list-editor h4 {
+  padding: 2px;
+  color: white;
+  font-size: 14px;
+  font-weight: bold;
+  background: #00007f;
+  border-radius: 0;
+}
+
+.list-editor {
+  @include win95-border-outset();
+  box-shadow: unset;
+}
+
+.list-editor .drawer__inner {
+  @include win95-inset();
+  border-radius: 0;
+}
+
+.batch-table__toolbar {
+  border-radius: 0px;
+  background-color:white;
+  border: 1px solid black;
+}
+
+.batch-table__row {
+  border: 1px solid black;
+  background-color: white;
+
+  &:hover {
+    background-color: white;
+  }
+}
+
+.batch-table__row:nth-child(2n) {
+  background-color: white;
+}
+
+.dashboard__counters > div > div,
+.dashboard__counters > div > a {
+    background-color: $win95-bg;
+    border: 1px solid black;
+    border-radius: 1px;
+    color:black;
+
+    &:hover {
+      background-color: $win95-bg;
+    }
+}
+
+.dashboard__counters__label,
+.dashboard__widgets a:not(.name-tag),
+.dashboard__counters__num {
+    color:black;
+}
+
+.card {
+  & > a, & > a:hover {
+    box-shadow: none;
+
+    .card__img {
+      border-radius: 0px;
+      border: 1px solid black;
+    }
+
+    .card__bar {
+      @include win95-outset();
+      background-color: $win95-bg;
+
+      .display-name {
+          strong, span {
+          color:black;
+        }
+      }
+    }
+  }
+}
+
+/* Public layout stuff */
+body {
+  background: $win95-cyan;
+}
+
+.public-layout {
+  max-width: 960px;
+  margin:10px auto;
+  background: $win95-bg;
+  padding:0px;
+  @include win95-outset();
+
+  .header {
+    background: $win95-window-header;
+    @include win95-border-outset();
+    height: 22px;
+    margin: 0px;
+    padding:0px;
+    border-radius: 0px;
+
+    .brand {
+      padding: 2px;
+    }
+
+    .nav-button {
+      @include win95-outset();
+      background: $win95-bg;
+      color:black;
+      margin: 2px;
+      margin-bottom: 0px;
+      &:hover {
+        background: $win95-bg;
+        color:black;
+      }
+    }
+  }
+  .footer {
+    background: none;
+    &, h4, ul a, .grid .column-2 h4 a {
+      color: black;
+    }
+  }
+
+  .button.logo-button {
+    @include win95-outset();
+    background: $win95-bg;
+    color:black;
+    &:hover {
+      background: $win95-bg;
+      color:black;
+    }
+    svg {
+       visibility:hidden;
+    }
+    &, &:hover {
+      background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=");
+      background-repeat:no-repeat;
+      background-position:8%;
+      background-clip:padding-box;
+      background-size:auto 50%;
+    }
+  }
+
+  .public-account-header {
+    @include win95-reset();
+    padding: 4px;
+    .public-account-header__image {
+      @include win95-border-slight-inset();
+      border-radius: 0px;
+    }
+  }
+
+  .public-account-header__bar {
+    &, &:before {
+      background: transparent;
+    }
+    .avatar img {
+      @include win95-border-slight-inset();
+      filter: saturate(1.8) brightness(1.1);
+      border-radius: 0px;
+      background: darken($win95-bg, 9.09%);
+    }
+  }
+  .public-account-header__extra__links {
+    margin-top: 0px;
+    a, a strong {
+      color: black;
+    }
+  }
+
+  .public-account-header__tabs {
+    position: relative;
+  }
+
+  .public-account-header__tabs__name {
+    display: inline-block;
+    position: relative;
+    background: $win95-tooltip-yellow;
+    border: 1px solid black;
+    padding: 4px;
+
+    h1, h1 small {
+      color:black;
+      text-shadow: unset;
+      text-overflow: unset;
+    }
+
+    margin-bottom: 24px;
+
+    &:after {
+      content: "";
+      display:block;
+      position:absolute;
+      left: 0px;
+      bottom: -20px;
+      width: 0px;
+      height: 0px;
+      border-left: 20px solid $win95-tooltip-yellow;
+      border-bottom: 20px solid transparent;
+    }
+    &:before {
+      content: "";
+      display:block;
+      position:absolute;
+      left: -1px;
+      bottom: -22px;
+      width: 0px;
+      height: 0px;
+      border-left: 22px solid black;
+      border-bottom: 22px solid transparent;
+    }
+  }
+
+  .public-account-header__tabs__tabs {
+    .details-counters {
+      @include win95-border-groove();
+      .counter {
+        .counter-number, .counter-label {
+          color: black;
+        }
+        &:after {
+          border-bottom-width: 0px;
+        }
+        &.active {
+         @include win95-border-slight-inset();
+        }
+      }
+    }
+  }
+
+  .public-account-bio {
+    @include win95-reset();
+    @include win95-border-groove();
+    background: $win95-bg;
+    margin-right: 10px;
+    .account__header__content, .roles {
+      color: black;
+    }
+  }
+  .public-account-bio__extra {
+    color: black;
+  }
+
+  .status__prepend {
+    padding-top:5px;
+  }
+  .status__content ,
+  .reply-indicator__content {
+    .status__content__spoiler-link {
+      color: $win95-dark-grey;
+    }
+  }
+  .account__section-headline {
+    margin-left: 10px;
+  }
+  .card-grid {
+    margin-left: 10px;
+  }
+  .status {
+    padding: 15px 15px 55px 78px;
+  }
+}
+
+@media screen and (max-width: 1255px) {
+  .container {
+    width: 100%;
+    padding: 0px;
+  }
+}
+
+.hero-widget {
+  box-shadow: none;
+  @include win95-border-groove();
+  background: $win95-bg;
+  padding: 8px;
+  margin-right: 10px;
+  margin-top: 10px;
+}
+.hero-widget__text {
+  background: none;
+  color: black;
+}
+.hero-widget__img {
+  background: none;
+  border-radius: 0px;
+  border: 1px solid black;
+  img {
+    border-radius: 0px;
+  }
+}
+
+.activity-stream {
+  @include win95-reset();
+  @include win95-border-groove();
+
+  background: $win95-bg;
+  margin-top: 10px;
+  margin-left: 10px;
+  .entry {
+    background: none;
+    &:first-child:last-child, &:first-child {
+      .detailed-status, .status, .load-more {
+        border-radius: 0px;
+      }
+    }
+  }
+}
+
+.nothing-here {
+  @include win95-reset();
+  background: transparent;
+  color: black;
+}
+
+.flash-message.notice {
+  border: 1px solid black;
+  background: $win95-tooltip-yellow;
+  color:black;
+}
+
+.layout-single-column .compose-panel {
+  background: $win95-bg;
+}
+
+.layout-single-column .status__wrapper .status {
+  padding-bottom: 50px;
+}
+
+::-webkit-scrollbar {
+  width: 14px;
+}
+
+::-webkit-scrollbar-track {
+  background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAAAAABX3VL4AAAADklEQVQIW2P4f4DhwH8ACoADf0PlskQAAAAASUVORK5CYII=');
+
+  &:hover {
+    background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAAAAABX3VL4AAAADklEQVQIW2P4f4DhwH8ACoADf0PlskQAAAAASUVORK5CYII=');
+  }
+}
+
+::-webkit-scrollbar-thumb {
+  background: #bfbfbf;
+  border-color: #efefef #404040 #404040 #efefef;
+  border-style: solid;
+  border-width: 2px;
+
+  &:hover {
+    background: #bfbfbf;
+    border-color: #efefef #404040 #404040 #efefef;
+    border-style: solid;
+    border-width: 2px;
+  }
+
+  &:active {
+    background: #bfbfbf;
+    border-color: #404040 #efefef #efefef #404040;
+  }
+}
+
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index e2355bfbc..eca446243 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -152,7 +152,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       # If there is at least one silent mention, then the status can be considered
       # as a limited-audience status, and not strictly a direct message, but only
       # if we considered a direct message in the first place
-      @params[:visibility] = :limited if @params[:visibility] == :direct
+      @params[:visibility] = :limited if @params[:visibility] == :direct && !@object['directMessage']
     end
 
     # Accounts that are tagged but are not in the audience are not
@@ -164,7 +164,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id])
 
     @status.mentions.create(account: delivered_to_account, silent: true)
-    @status.update(visibility: :limited) if @status.direct_visibility?
+    @status.update(visibility: :limited) if @status.direct_visibility? && !@object['directMessage']
 
     return unless delivered_to_account.following?(@account)
 
diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb
index 3ba154d01..75b8f3d5c 100644
--- a/app/lib/activitypub/parser/status_parser.rb
+++ b/app/lib/activitypub/parser/status_parser.rb
@@ -79,6 +79,8 @@ class ActivityPub::Parser::StatusParser
       :unlisted
     elsif audience_to.include?(@magic_values[:followers_collection])
       :private
+    elsif direct_message == false
+      :limited
     else
       :direct
     end
@@ -94,6 +96,10 @@ class ActivityPub::Parser::StatusParser
     end
   end
 
+  def direct_message
+    @object['directMessage']
+  end
+
   private
 
   def audience_to
diff --git a/app/lib/advanced_text_formatter.rb b/app/lib/advanced_text_formatter.rb
new file mode 100644
index 000000000..cdf1e2d9c
--- /dev/null
+++ b/app/lib/advanced_text_formatter.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+class AdvancedTextFormatter < TextFormatter
+  class HTMLRenderer < Redcarpet::Render::HTML
+    def initialize(options, &block)
+      super(options)
+      @format_link = block
+    end
+
+    def block_code(code, _language)
+      <<~HTML
+        <pre><code>#{ERB::Util.h(code).gsub("\n", '<br/>')}</code></pre>
+      HTML
+    end
+
+    def autolink(link, link_type)
+      return link if link_type == :email
+
+      @format_link.call(link)
+    end
+  end
+
+  attr_reader :content_type
+
+  # @param [String] text
+  # @param [Hash] options
+  # @option options [Boolean] :multiline
+  # @option options [Boolean] :with_domains
+  # @option options [Boolean] :with_rel_me
+  # @option options [Array<Account>] :preloaded_accounts
+  # @option options [String] :content_type
+  def initialize(text, options = {})
+    @content_type = options.delete(:content_type)
+    super(text, options)
+
+    @text = format_markdown(text) if content_type == 'text/markdown'
+  end
+
+  # Differs from TextFormatter by not messing with newline after parsing
+  def to_s
+    return ''.html_safe if text.blank?
+
+    html = rewrite do |entity|
+      if entity[:url]
+        link_to_url(entity)
+      elsif entity[:hashtag]
+        link_to_hashtag(entity)
+      elsif entity[:screen_name]
+        link_to_mention(entity)
+      end
+    end
+
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  # Differs from TextFormatter by operating on the parsed HTML tree
+  def rewrite
+    if @tree.nil?
+      src = text.gsub(Sanitize::REGEX_UNSUITABLE_CHARS, '')
+      @tree = Nokogiri::HTML5.fragment(src)
+      document = @tree.document
+
+      @tree.xpath('.//text()[not(ancestor::a | ancestor::code)]').each do |text_node|
+        # Iterate over text elements and build up their replacements.
+        content = text_node.content
+        replacement = Nokogiri::XML::NodeSet.new(document)
+        processed_index = 0
+        Extractor.extract_entities_with_indices(
+          content,
+          extract_url_without_protocol: false
+        ) do |entity|
+          # Iterate over entities in this text node.
+          advance = entity[:indices].first - processed_index
+          if advance.positive?
+            # Text node for content which precedes entity.
+            replacement << Nokogiri::XML::Text.new(
+              content[processed_index, advance],
+              document
+            )
+          end
+          replacement << Nokogiri::HTML5.fragment(yield(entity))
+          processed_index = entity[:indices].last
+        end
+        if processed_index < content.size
+          # Text node for remaining content.
+          replacement << Nokogiri::XML::Text.new(
+            content[processed_index, content.size - processed_index],
+            document
+          )
+        end
+        text_node.replace(replacement)
+      end
+    end
+
+    Sanitize.node!(@tree, Sanitize::Config::MASTODON_OUTGOING).to_html
+  end
+
+  private
+
+  def format_markdown(html)
+    html = markdown_formatter.render(html)
+    html.delete("\r").delete("\n")
+  end
+
+  def markdown_formatter
+    extensions = {
+      autolink: true,
+      no_intra_emphasis: true,
+      fenced_code_blocks: true,
+      disable_indented_code_blocks: true,
+      strikethrough: true,
+      lax_spacing: true,
+      space_after_headers: true,
+      superscript: true,
+      underline: true,
+      highlight: true,
+      footnotes: false,
+    }
+
+    renderer = HTMLRenderer.new({
+      filter_html: false,
+      escape_html: false,
+      no_images: true,
+      no_styles: true,
+      safe_links_only: true,
+      hard_wrap: true,
+      link_attributes: { target: '_blank', rel: 'nofollow noopener' },
+    }) do |url|
+      link_to_url({ url: url })
+    end
+
+    Redcarpet::Markdown.new(renderer, extensions)
+  end
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 7dda6b185..15ff6d15f 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -45,6 +45,8 @@ class FeedManager
       filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
     when :mentions
       filter_from_mentions?(status, receiver.id)
+    when :direct
+      filter_from_direct?(status, receiver.id)
     when :tags
       filter_from_tags?(status, receiver.id, build_crutches(receiver.id, [status]))
     else
@@ -102,6 +104,29 @@ class FeedManager
     true
   end
 
+  # Add a status to a linear direct message feed and send a streaming API update
+  # @param [Account] account
+  # @param [Status] status
+  # @return [Boolean]
+  def push_to_direct(account, status, update: false)
+    return false unless add_to_feed(:direct, account.id, status)
+
+    trim(:direct, account.id)
+    PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}") unless update
+    true
+  end
+
+  # Remove a status from a linear direct message feed and send a streaming API update
+  # @param [List] list
+  # @param [Status] status
+  # @return [Boolean]
+  def unpush_from_direct(account, status, update: false)
+    return false unless remove_from_feed(:direct, account.id, status)
+
+    redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update
+    true
+  end
+
   # Fill a home feed with an account's statuses
   # @param [Account] from_account
   # @param [Account] into_account
@@ -266,6 +291,31 @@ class FeedManager
     end
   end
 
+  # Populate direct feed of account from scratch
+  # @param [Account] account
+  # @return [void]
+  def populate_direct_feed(account)
+    added  = 0
+    limit  = FeedManager::MAX_ITEMS / 2
+    max_id = nil
+
+    loop do
+      statuses = Status.as_direct_timeline(account, limit, max_id)
+
+      break if statuses.empty?
+
+      statuses.each do |status|
+        next if filter_from_direct?(status, account)
+
+        added += 1 if add_to_feed(:direct, account.id, status)
+      end
+
+      break unless added.zero?
+
+      max_id = statuses.last.id
+    end
+  end
+
   # Completely clear multiple feeds at once
   # @param [Symbol] type
   # @param [Array<Integer>] ids
@@ -404,6 +454,16 @@ class FeedManager
     should_filter
   end
 
+  # Check if status should not be added to the linear direct message feed
+  # @param [Status] status
+  # @param [Integer] receiver_id
+  # @return [Boolean]
+  def filter_from_direct?(status, receiver_id)
+    return false if receiver_id == status.account_id
+
+    filter_from_mentions?(status, receiver_id)
+  end
+
   # Check if status should not be added to the list feed
   # @param [Status] status
   # @param [List] list
diff --git a/app/lib/html_aware_formatter.rb b/app/lib/html_aware_formatter.rb
index 64edba09b..8766c5ee0 100644
--- a/app/lib/html_aware_formatter.rb
+++ b/app/lib/html_aware_formatter.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class HtmlAwareFormatter
+  STATUS_MIME_TYPES = %w(text/plain text/markdown text/html).freeze
+
   attr_reader :text, :local, :options
 
   alias local? local
@@ -33,6 +35,10 @@ class HtmlAwareFormatter
   end
 
   def linkify
-    TextFormatter.new(text, options).to_s
+    if %w(text/markdown text/html).include?(@options[:content_type])
+      AdvancedTextFormatter.new(text, options).to_s
+    else
+      TextFormatter.new(text, options).to_s
+    end
   end
 end
diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb
index 3ad57cc1e..983865a68 100644
--- a/app/lib/settings/scoped_settings.rb
+++ b/app/lib/settings/scoped_settings.rb
@@ -3,7 +3,8 @@
 module Settings
   class ScopedSettings
     DEFAULTING_TO_UNSCOPED = %w(
-      theme
+      flavour
+      skin
       noindex
     ).freeze
 
diff --git a/app/lib/themes.rb b/app/lib/themes.rb
index 243ffb9ab..45ba47780 100644
--- a/app/lib/themes.rb
+++ b/app/lib/themes.rb
@@ -7,10 +7,81 @@ 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'] = {} unless core['pack']
+
+    result = {}
+    Rails.root.glob('app/javascript/flavours/*/theme.yml') do |pathname|
+      data = YAML.load_file(pathname)
+      next unless data['pack']
+
+      dir = pathname.dirname
+      name = dir.basename.to_s
+      locales = []
+      screenshots = []
+
+      if data['locales']
+        Dir.glob(File.join(dir, data['locales'], '*.{js,json}')) do |locale|
+          locale_name = File.basename(locale, File.extname(locale))
+          locales.push(locale_name) unless /defaultMessages|whitelist|index/.match?(locale_name)
+        end
+      end
+
+      if data['screenshot']
+        if data['screenshot'].is_a? Array
+          screenshots = data['screenshot']
+        else
+          screenshots.push(data['screenshot'])
+        end
+      end
+
+      data['name'] = name
+      data['locales'] = locales
+      data['screenshot'] = screenshots
+      data['skin'] = { 'default' => [] }
+      result[name] = data
+    end
+
+    Rails.root.glob('app/javascript/skins/*/*') do |pathname|
+      ext = pathname.extname.to_s
+      skin = pathname.basename.to_s
+      name = pathname.dirname.basename.to_s
+      next unless result[name]
+
+      if pathname.directory?
+        pack = []
+        pathname.glob('*.{css,scss}') do |sheet|
+          pack.push(sheet.basename(sheet.extname).to_s)
+        end
+      elsif /^\.s?css$/i.match?(ext)
+        skin = pathname.basename(ext).to_s
+        pack = ['common']
+      end
+
+      result[name]['skin'][skin] = pack if skin != 'default'
+    end
+
+    @core = core
+    @conf = result
+  end
+
+  attr_reader :core
+
+  def flavour(name)
+    @conf[name]
   end
 
-  def names
+  def flavours
     @conf.keys
   end
+
+  def skins_for(name)
+    @conf[name]['skin'].keys
+  end
+
+  def flavours_and_skins
+    flavours.map do |flavour|
+      [flavour, skins_for(flavour).map { |skin| [flavour, skin] }]
+    end
+  end
 end
diff --git a/app/lib/vacuum/feeds_vacuum.rb b/app/lib/vacuum/feeds_vacuum.rb
index fb0b8a847..b0246bc0d 100644
--- a/app/lib/vacuum/feeds_vacuum.rb
+++ b/app/lib/vacuum/feeds_vacuum.rb
@@ -4,6 +4,7 @@ class Vacuum::FeedsVacuum
   def perform
     vacuum_inactive_home_feeds!
     vacuum_inactive_list_feeds!
+    vacuum_inactive_direct_feeds!
   end
 
   private
@@ -20,6 +21,12 @@ class Vacuum::FeedsVacuum
     end
   end
 
+  def vacuum_inactive_direct_feeds!
+    inactive_users_lists.select(:id).find_in_batches do |lists|
+      feed_manager.clean_feeds!(:direct, lists.map(&:id))
+    end
+  end
+
   def inactive_users
     User.confirmed.inactive
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index f49cae901..4fc7b9d08 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -79,6 +79,10 @@ class Account < ApplicationRecord
   include DomainMaterializable
   include AccountMerging
 
+  MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
+  MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
+  DEFAULT_FIELDS_SIZE = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i
+
   enum protocol: { ostatus: 0, activitypub: 1 }
   enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
 
@@ -91,9 +95,9 @@ class Account < ApplicationRecord
   # Local user validations
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
-  validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
-  validates :note, note_length: { maximum: 500 }, if: -> { local? && will_save_change_to_note? }
-  validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
+  validates :display_name, length: { maximum: MAX_DISPLAY_NAME_LENGTH }, if: -> { local? && will_save_change_to_display_name? }
+  validates :note, note_length: { maximum: MAX_NOTE_LENGTH }, if: -> { local? && will_save_change_to_note? }
+  validates :fields, length: { maximum: DEFAULT_FIELDS_SIZE }, if: -> { local? && will_save_change_to_fields? }
 
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
@@ -259,10 +263,6 @@ class Account < ApplicationRecord
     update!(memorial: true)
   end
 
-  def trendable?
-    boolean_with_default('trendable', Setting.trendable_by_default)
-  end
-
   def sign?
     true
   end
@@ -325,8 +325,6 @@ class Account < ApplicationRecord
     self[:fields] = fields
   end
 
-  DEFAULT_FIELDS_SIZE = 4
-
   def build_fields
     return if fields.size >= DEFAULT_FIELDS_SIZE
 
diff --git a/app/models/account_statuses_filter.rb b/app/models/account_statuses_filter.rb
index 211f41478..556aee032 100644
--- a/app/models/account_statuses_filter.rb
+++ b/app/models/account_statuses_filter.rb
@@ -35,7 +35,7 @@ class AccountStatusesFilter
     if suspended?
       Status.none
     elsif anonymous?
-      account.statuses.where(visibility: %i(public unlisted))
+      account.statuses.not_local_only.where(visibility: %i(public unlisted))
     elsif author?
       account.statuses.all # NOTE: #merge! does not work without the #all
     elsif blocked?
diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb
index b3fa1f683..0e9d4e1cd 100644
--- a/app/models/concerns/has_user_settings.rb
+++ b/app/models/concerns/has_user_settings.rb
@@ -39,6 +39,10 @@ module HasUserSettings
     settings['web.delete_modal']
   end
 
+  def setting_favourite_modal
+    settings['web.favourite_modal']
+  end
+
   def setting_reduce_motion
     settings['web.reduce_motion']
   end
@@ -47,12 +51,20 @@ module HasUserSettings
     settings['web.use_system_font']
   end
 
+  def setting_system_emoji_font
+    settings['web.use_system_emoji_font']
+  end
+
   def setting_noindex
     settings['noindex']
   end
 
-  def setting_theme
-    settings['theme']
+  def setting_flavour
+    settings['flavour']
+  end
+
+  def setting_skin
+    settings['skin']
   end
 
   def setting_display_media
@@ -107,6 +119,14 @@ module HasUserSettings
     settings['default_privacy'] || (account.locked? ? 'private' : 'public')
   end
 
+  def setting_default_content_type
+    settings['default_content_type']
+  end
+
+  def setting_hide_followers_count
+    settings['hide_followers_count']
+  end
+
   def allows_report_emails?
     settings['notification_emails.report']
   end
@@ -123,6 +143,18 @@ module HasUserSettings
     settings['notification_emails.trends']
   end
 
+  def allows_trending_tags_review_emails?
+    settings['notification_emails.trends']
+  end
+
+  def allows_trending_links_review_emails?
+    settings['notification_emails.link_trends']
+  end
+
+  def allows_trending_statuses_review_emails?
+    settings['notification_emails.status_trends']
+  end
+
   def aggregates_reblogs?
     settings['aggregate_reblogs']
   end
diff --git a/app/models/concerns/status_snapshot_concern.rb b/app/models/concerns/status_snapshot_concern.rb
index 9741b9aeb..c728db7c3 100644
--- a/app/models/concerns/status_snapshot_concern.rb
+++ b/app/models/concerns/status_snapshot_concern.rb
@@ -24,6 +24,7 @@ module StatusSnapshotConcern
       media_descriptions: ordered_media_attachments.map(&:description),
       poll_options: preloadable_poll&.options&.dup,
       account_id: account_id || self.account_id,
+      content_type: content_type,
       created_at: at_time || edited_at,
       rate_limit: rate_limit
     )
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 3d7900226..b5a07a5a0 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -24,7 +24,8 @@
 class CustomEmoji < ApplicationRecord
   include Attachmentable
 
-  LIMIT = 256.kilobytes
+  LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 256.kilobytes).to_i
+  LIMIT       = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 256.kilobytes).to_i].max
 
   SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
 
@@ -42,7 +43,9 @@ class CustomEmoji < ApplicationRecord
 
   before_validation :downcase_domain
 
-  validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
+  validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true
+  validates_attachment_size :image, less_than: LIMIT, unless: :local?
+  validates_attachment_size :image, less_than: LOCAL_LIMIT, if: :local?
   validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: 2 }
 
   scope :local, -> { where(domain: nil) }
diff --git a/app/models/direct_feed.rb b/app/models/direct_feed.rb
new file mode 100644
index 000000000..689a735b3
--- /dev/null
+++ b/app/models/direct_feed.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class DirectFeed < Feed
+  include Redisable
+
+  def initialize(account)
+    @account = account
+    super(:direct, account.id)
+  end
+
+  def get(limit, max_id = nil, since_id = nil, min_id = nil)
+    unless redis.exists("account:#{@account.id}:regeneration")
+      statuses = super
+      return statuses unless statuses.empty?
+    end
+    from_database(limit, max_id, since_id, min_id)
+  end
+
+  private
+
+  # TODO: _min_id is not actually handled by `as_direct_timeline`
+  def from_database(limit, max_id, since_id, _min_id)
+    loop do
+      statuses = Status.as_direct_timeline(@account, limit, max_id, since_id)
+      return statuses if statuses.empty?
+
+      max_id = statuses.last.id
+      statuses = statuses.reject { |status| FeedManager.instance.filter?(:direct, status, @account) }
+      return statuses unless statuses.empty?
+    end
+  end
+end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index de965cb0b..eaee142fa 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -14,21 +14,29 @@ class Form::AdminSettings
     closed_registrations_message
     timeline_preview
     bootstrap_timeline_accounts
-    theme
+    flavour
+    skin
     activity_api_enabled
     peers_api_enabled
     preview_sensitive_media
     custom_css
     profile_directory
+    hide_followers_count
+    flavour_and_skin
     thumbnail
     mascot
+    show_reblogs_in_public_timelines
+    show_replies_in_public_timelines
     trends
     trends_as_landing_page
     trendable_by_default
+    trending_status_cw
     show_domain_blocks
     show_domain_blocks_rationale
     noindex
+    outgoing_spoilers
     require_invite_text
+    captcha_enabled
     media_cache_retention_period
     content_cache_retention_period
     backups_retention_period
@@ -47,11 +55,16 @@ class Form::AdminSettings
     peers_api_enabled
     preview_sensitive_media
     profile_directory
+    hide_followers_count
+    show_reblogs_in_public_timelines
+    show_replies_in_public_timelines
     trends
     trends_as_landing_page
     trendable_by_default
+    trending_status_cw
     noindex
     require_invite_text
+    captcha_enabled
   ).freeze
 
   UPLOAD_KEYS = %i(
@@ -59,6 +72,10 @@ class Form::AdminSettings
     mascot
   ).freeze
 
+  PSEUDO_KEYS = %i(
+    flavour_and_skin
+  ).freeze
+
   attr_accessor(*KEYS)
 
   validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) }
@@ -102,7 +119,7 @@ class Form::AdminSettings
     return false unless errors.empty? && valid?
 
     KEYS.each do |key|
-      next unless instance_variable_defined?("@#{key}")
+      next if PSEUDO_KEYS.include?(key) || !instance_variable_defined?("@#{key}")
 
       if UPLOAD_KEYS.include?(key)
         public_send(key).save
@@ -113,6 +130,14 @@ class Form::AdminSettings
     end
   end
 
+  def flavour_and_skin
+    "#{Setting.flavour}/#{Setting.skin}"
+  end
+
+  def flavour_and_skin=(value)
+    @flavour, @skin = value.split('/', 2)
+  end
+
   private
 
   def typecast_value(key, value)
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index e51e13b95..0367b4af7 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -39,8 +39,8 @@ class MediaAttachment < ApplicationRecord
 
   MAX_DESCRIPTION_LENGTH = 1_500
 
-  IMAGE_LIMIT = 16.megabytes
-  VIDEO_LIMIT = 99.megabytes
+  IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 16.megabytes).to_i
+  VIDEO_LIMIT = (ENV['MAX_VIDEO_SIZE'] || 99.megabytes).to_i
 
   MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px
   MAX_VIDEO_FRAME_RATE   = 120
diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb
index 1cfd9a500..a987bb72c 100644
--- a/app/models/public_feed.rb
+++ b/app/models/public_feed.rb
@@ -8,6 +8,7 @@ class PublicFeed
   # @option [Boolean] :local
   # @option [Boolean] :remote
   # @option [Boolean] :only_media
+  # @option [Boolean] :allow_local_only
   def initialize(account, options = {})
     @account = account
     @options = options
@@ -21,6 +22,7 @@ class PublicFeed
   def get(limit, max_id = nil, since_id = nil, min_id = nil)
     scope = public_scope
 
+    scope.merge!(without_local_only_scope) unless allow_local_only?
     scope.merge!(without_replies_scope) unless with_replies?
     scope.merge!(without_reblogs_scope) unless with_reblogs?
     scope.merge!(local_only_scope) if local_only?
@@ -36,6 +38,10 @@ class PublicFeed
 
   attr_reader :account, :options
 
+  def allow_local_only?
+    local_account? && (local_only? || options[:allow_local_only])
+  end
+
   def with_reblogs?
     options[:with_reblogs]
   end
@@ -56,6 +62,10 @@ class PublicFeed
     account.present?
   end
 
+  def local_account?
+    account&.local?
+  end
+
   def media_only?
     options[:only_media]
   end
@@ -84,6 +94,10 @@ class PublicFeed
     Status.joins(:media_attachments).group(:id)
   end
 
+  def without_local_only_scope
+    Status.not_local_only
+  end
+
   def language_scope
     Status.where(language: account.chosen_languages)
   end
diff --git a/app/models/status.rb b/app/models/status.rb
index 2757497db..8a58e5d68 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -22,7 +22,9 @@
 #  account_id                   :bigint(8)        not null
 #  application_id               :bigint(8)
 #  in_reply_to_account_id       :bigint(8)
+#  local_only                   :boolean
 #  poll_id                      :bigint(8)
+#  content_type                 :string
 #  deleted_at                   :datetime
 #  edited_at                    :datetime
 #  trendable                    :boolean
@@ -85,6 +87,7 @@ class Status < ApplicationRecord
   validates_with DisallowedHashtagsValidator
   validates :reblog, uniqueness: { scope: :account }, if: :reblog?
   validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
+  validates :content_type, inclusion: { in: %w(text/plain text/markdown text/html) }, allow_nil: true
 
   accepts_nested_attributes_for :poll
 
@@ -111,6 +114,8 @@ class Status < ApplicationRecord
     where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
   }
 
+  scope :not_local_only, -> { where(local_only: [false, nil]) }
+
   after_create_commit :trigger_create_webhooks
   after_update_commit :trigger_update_webhooks
 
@@ -322,6 +327,7 @@ class Status < ApplicationRecord
   before_validation :set_visibility
   before_validation :set_conversation
   before_validation :set_local
+  before_create :set_locality
 
   around_create Mastodon::Snowflake::Callbacks
 
@@ -332,6 +338,42 @@ class Status < ApplicationRecord
       visibilities.keys - %w(direct limited)
     end
 
+    def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil)
+      # direct timeline is mix of direct message from_me and to_me.
+      # 2 queries are executed with pagination.
+      # constant expression using arel_table is required for partial index
+
+      # _from_me part does not require any timeline filters
+      query_from_me = where(account_id: account.id)
+                      .where(Status.arel_table[:visibility].eq(3))
+                      .limit(limit)
+                      .order('statuses.id DESC')
+
+      # _to_me part requires mute and block filter.
+      # FIXME: may we check mutes.hide_notifications?
+      query_to_me = Status
+                    .joins(:mentions)
+                    .merge(Mention.where(account_id: account.id))
+                    .where(Status.arel_table[:visibility].eq(3))
+                    .limit(limit)
+                    .order('mentions.status_id DESC')
+                    .not_excluded_by_account(account)
+
+      if max_id.present?
+        query_from_me = query_from_me.where('statuses.id < ?', max_id)
+        query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
+      end
+
+      if since_id.present?
+        query_from_me = query_from_me.where('statuses.id > ?', since_id)
+        query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
+      end
+
+      # returns ActiveRecord.Relation
+      items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+      Status.where(id: items.map(&:id))
+    end
+
     def favourites_map(status_ids, account_id)
       Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
     end
@@ -387,6 +429,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
+
   def status_stat
     super || build_status_stat
   end
@@ -496,6 +547,12 @@ class Status < ApplicationRecord
     self.sensitive  = false if sensitive.nil?
   end
 
+  def set_locality
+    return unless account.domain.nil? && !attribute_changed?(:local_only)
+
+    self.local_only = marked_local_only?
+  end
+
   def set_conversation
     self.thread = thread.reblog if thread&.reblog?
 
diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb
index 2b3248bb2..fa35e38ac 100644
--- a/app/models/status_edit.rb
+++ b/app/models/status_edit.rb
@@ -11,6 +11,7 @@
 #  spoiler_text                 :text             default(""), not null
 #  created_at                   :datetime         not null
 #  updated_at                   :datetime         not null
+#  content_type                 :string
 #  ordered_media_attachment_ids :bigint(8)        is an Array
 #  media_descriptions           :text             is an Array
 #  poll_options                 :string           is an Array
diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb
index b8cd63557..fbbdbaae2 100644
--- a/app/models/tag_feed.rb
+++ b/app/models/tag_feed.rb
@@ -25,6 +25,7 @@ class TagFeed < PublicFeed
   def get(limit, max_id = nil, since_id = nil, min_id = nil)
     scope = public_scope
 
+    scope.merge!(without_local_only_scope) unless local_account?
     scope.merge!(tagged_with_any_scope)
     scope.merge!(tagged_with_all_scope)
     scope.merge!(tagged_with_none_scope)
diff --git a/app/models/trends.rb b/app/models/trends.rb
index d07d62b71..b09db940e 100644
--- a/app/models/trends.rb
+++ b/app/models/trends.rb
@@ -32,10 +32,13 @@ module Trends
     tags_requiring_review     = tags.request_review
     statuses_requiring_review = statuses.request_review
 
-    return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
-
     User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
-      AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
+      links    = user.allows_trending_links_review_emails? ? links_requiring_review : []
+      tags     = user.allows_trending_tags_review_emails? ? tags_requiring_review : []
+      statuses = user.allows_trending_statuses_review_emails? ? statuses_requiring_review : []
+      next if links.empty? && tags.empty? && statuses.empty?
+
+      AdminMailer.new_trends(user.account, links, tags, statuses).deliver_later!
     end
   end
 
diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb
index 84bff9c02..d4d5b1c24 100644
--- a/app/models/trends/statuses.rb
+++ b/app/models/trends/statuses.rb
@@ -91,7 +91,7 @@ class Trends::Statuses < Trends::Base
   private
 
   def eligible?(status)
-    status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
+    status.public_visibility? && status.account.discoverable? && !status.account.silenced? && (status.spoiler_text.blank? || Setting.trending_status_cw) && !status.sensitive? && !status.reply? && valid_locale?(status.language)
   end
 
   def calculate_scores(statuses, at_time)
diff --git a/app/models/user.rb b/app/models/user.rb
index 9b225d75f..daf8768e8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -244,7 +244,7 @@ class User < ApplicationRecord
   end
 
   def functional?
-    functional_or_moved? && account.moved_to_account_id.nil?
+    functional_or_moved?
   end
 
   def functional_or_moved?
diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb
index 2c025d6c5..0be8c5fbc 100644
--- a/app/models/user_settings.rb
+++ b/app/models/user_settings.rb
@@ -9,12 +9,15 @@ class UserSettings
 
   setting :always_send_emails, default: false
   setting :aggregate_reblogs, default: true
-  setting :theme, default: -> { ::Setting.theme }
+  setting :flavour, default: -> { ::Setting.flavour }
+  setting :skin, default: -> { ::Setting.skin }
   setting :noindex, default: -> { ::Setting.noindex }
   setting :show_application, default: true
   setting :default_language, default: nil
   setting :default_sensitive, default: false
   setting :default_privacy, default: nil
+  setting :default_content_type, default: 'text/plain'
+  setting :hide_followers_count, default: false
 
   namespace :web do
     setting :crop_images, default: true
@@ -27,10 +30,12 @@ class UserSettings
     setting :delete_modal, default: true
     setting :reblog_modal, default: false
     setting :unfollow_modal, default: true
+    setting :favourite_modal, default: false
     setting :reduce_motion, default: false
     setting :expand_content_warnings, default: false
     setting :display_media, default: 'default', in: %w(default show_all hide_all)
     setting :auto_play, default: false
+    setting :use_system_emoji_font, default: false
   end
 
   namespace :notification_emails do
@@ -42,6 +47,8 @@ class UserSettings
     setting :report, default: true
     setting :pending_account, default: true
     setting :trends, default: true
+    setting :link_trends, default: false
+    setting :status_trends, default: false
     setting :appeal, default: true
   end
 
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index f3d0ffdba..52cfd5050 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -9,6 +9,7 @@ class StatusPolicy < ApplicationPolicy
 
   def show?
     return false if author.suspended?
+    return false if local_only? && (current_account.nil? || !current_account.local?)
 
     if requires_mention?
       owned? || mention_exists?
@@ -88,4 +89,8 @@ class StatusPolicy < ApplicationPolicy
   def author
     record.account
   end
+
+  def local_only?
+    record.local_only?
+  end
 end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 27e058199..52ffaf717 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,7 +3,7 @@
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
   include FormattingHelper
 
-  context_extensions :atom_uri, :conversation, :sensitive, :voters_count
+  context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message
 
   attributes :id, :type, :summary,
              :in_reply_to, :published, :url,
@@ -15,6 +15,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   attribute :content_map, if: :language?
   attribute :updated, if: :edited?
 
+  attribute :direct_message, if: :non_public?
+
   has_many :virtual_attachments, key: :attachment
   has_many :virtual_tags, key: :tag
 
@@ -29,6 +31,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   attribute :voters_count, if: :poll_and_voters_count?
 
   def id
+    raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only]
+
     ActivityPub::TagManager.instance.uri_for(object)
   end
 
@@ -37,7 +41,15 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def summary
-    object.spoiler_text.presence
+    object.spoiler_text.presence || (instance_options[:allow_local_only] ? nil : Setting.outgoing_spoilers.presence)
+  end
+
+  def direct_message
+    object.direct_visibility?
+  end
+
+  def non_public?
+    !object.distributable?
   end
 
   def content
@@ -105,7 +117,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def sensitive
-    object.account.sensitized? || object.sensitive
+    object.account.sensitized? || object.sensitive || (!instance_options[:allow_local_only] && Setting.outgoing_spoilers.present?)
   end
 
   def virtual_attachments
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 769ba653e..45ee06e12 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -5,11 +5,25 @@ class InitialStateSerializer < ActiveModel::Serializer
 
   attributes :meta, :compose, :accounts,
              :media_attachments, :settings,
+             :max_toot_chars, :poll_limits,
              :languages
 
   has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
   has_one :role, serializer: REST::RoleSerializer
 
+  def max_toot_chars
+    StatusLengthValidator::MAX_CHARS
+  end
+
+  def poll_limits
+    {
+      max_options: PollValidator::MAX_OPTIONS,
+      max_option_chars: PollValidator::MAX_OPTION_CHARS,
+      min_expiration: PollValidator::MIN_EXPIRATION,
+      max_expiration: PollValidator::MAX_EXPIRATION,
+    }
+  end
+
   def meta
     store = {
       streaming_api_base_url: Rails.configuration.x.streaming_api_base_url,
@@ -38,6 +52,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[:display_media]     = object.current_account.user.setting_display_media
@@ -48,6 +63,8 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:use_blurhash]      = object.current_account.user.setting_use_blurhash
       store[:use_pending_items] = object.current_account.user.setting_use_pending_items
       store[:trends]            = Setting.trends && object.current_account.user.setting_trends
+      store[:default_content_type] = object.current_account.user.setting_default_content_type
+      store[:system_emoji_font] = object.current_account.user.setting_system_emoji_font
       store[:crop_images]       = object.current_account.user.setting_crop_images
     else
       store[:auto_play_gif] = Setting.auto_play_gif
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 2845470be..d4e7ac974 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -90,6 +90,10 @@ class REST::AccountSerializer < ActiveModel::Serializer
     object.last_status_at&.to_date&.iso8601
   end
 
+  def followers_count
+    Setting.hide_followers_count || object.user&.setting_hide_followers_count ? -1 : object.followers_count
+  end
+
   def display_name
     object.suspended? ? '' : object.display_name
   end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index a07840f0c..41adaed80 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -56,6 +56,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
         max_characters: StatusLengthValidator::MAX_CHARS,
         max_media_attachments: 4,
         characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
+        supported_mime_types: HtmlAwareFormatter::STATUS_MIME_TYPES,
       },
 
       media_attachments: {
diff --git a/app/serializers/rest/mute_serializer.rb b/app/serializers/rest/mute_serializer.rb
new file mode 100644
index 000000000..c9b55ff16
--- /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
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index e0b8f32a6..eb5f3c3ea 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -13,10 +13,12 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :muted, if: :current_user?
   attribute :bookmarked, if: :current_user?
   attribute :pinned, if: :pinnable?
+  attribute :local_only, if: :local?
   has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user?
 
   attribute :content, unless: :source_requested?
   attribute :text, if: :source_requested?
+  attribute :content_type, if: :source_requested?
 
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application, if: :show_application?
@@ -30,6 +32,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
   has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
   has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
 
+  delegate :local?, to: :object
+
   def id
     object.id.to_s
   end
diff --git a/app/serializers/rest/status_source_serializer.rb b/app/serializers/rest/status_source_serializer.rb
index cd3c74084..c03cbd20d 100644
--- a/app/serializers/rest/status_source_serializer.rb
+++ b/app/serializers/rest/status_source_serializer.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class REST::StatusSourceSerializer < ActiveModel::Serializer
-  attributes :id, :text, :spoiler_text
+  attributes :id, :text, :spoiler_text, :content_type
 
   def id
     object.id.to_s
diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb
index 99d1b2bd6..0c2101404 100644
--- a/app/serializers/rest/v1/instance_serializer.rb
+++ b/app/serializers/rest/v1/instance_serializer.rb
@@ -4,7 +4,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
   include RoutingHelper
 
   attributes :uri, :title, :short_description, :description, :email,
-             :version, :urls, :stats, :thumbnail,
+             :version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits,
              :languages, :registrations, :approval_required, :invites_enabled,
              :configuration
 
@@ -36,6 +36,19 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
     instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url(:'@1x')) : full_pack_url('media/images/preview.png')
   end
 
+  def max_toot_chars
+    StatusLengthValidator::MAX_CHARS
+  end
+
+  def poll_limits
+    {
+      max_options: PollValidator::MAX_OPTIONS,
+      max_option_chars: PollValidator::MAX_OPTION_CHARS,
+      min_expiration: PollValidator::MIN_EXPIRATION,
+      max_expiration: PollValidator::MAX_EXPIRATION,
+    }
+  end
+
   def stats
     {
       user_count: instance_presenter.user_count,
@@ -66,6 +79,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
         max_characters: StatusLengthValidator::MAX_CHARS,
         max_media_attachments: 4,
         characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
+        supported_mime_types: HtmlAwareFormatter::STATUS_MIME_TYPES,
       },
 
       media_attachments: {
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
index 5498cdd45..c5e7a8e58 100644
--- a/app/services/backup_service.rb
+++ b/app/services/backup_service.rb
@@ -22,7 +22,7 @@ class BackupService < BaseService
 
     account.statuses.with_includes.reorder(nil).find_in_batches do |statuses|
       statuses.each do |status|
-        item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account)
+        item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true)
         item.delete(:@context)
 
         unless item[:type] == 'Announce' || item[:object][:attachment].blank?
@@ -153,7 +153,8 @@ class BackupService < BaseService
     ActiveModelSerializers::SerializableResource.new(
       object,
       serializer: serializer,
-      adapter: ActivityPub::Adapter
+      adapter: ActivityPub::Adapter,
+      allow_local_only: true
     ).as_json
   end
 
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 7e9b67126..a48386ba2 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -19,7 +19,10 @@ class BatchedRemoveStatusService < BaseService
 
     ActiveRecord::Associations::Preloader.new.preload(statuses_with_account_conversations, [mentions: :account])
 
-    statuses_with_account_conversations.each(&:unlink_from_conversations!)
+    statuses_with_account_conversations.each do |status|
+      status.unlink_from_conversations!
+      unpush_from_direct_timelines(status)
+    end
 
     # We do not batch all deletes into one to avoid having a long-running
     # transaction lock the database, but we use the delete method instead
@@ -88,4 +91,10 @@ class BatchedRemoveStatusService < BaseService
       pipeline.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
     end
   end
+
+  def unpush_from_direct_timelines(status)
+    status.mentions.each do |mention|
+      FeedManager.instance.unpush_from_direct(mention.account, status) if mention.account.local?
+    end
+  end
 end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 2554756a5..3b14a6748 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -49,6 +49,7 @@ class FanOutOnWriteService < BaseService
     else
       deliver_to_mentioned_followers!
       deliver_to_conversation!
+      deliver_to_direct_timelines!
     end
   end
 
@@ -63,6 +64,7 @@ class FanOutOnWriteService < BaseService
 
   def deliver_to_self!
     FeedManager.instance.push_to_home(@account, @status, update: update?) if @account.local?
+    FeedManager.instance.push_to_direct(@account, @status, update: update?) if @account.local? && @status.direct_visibility?
   end
 
   def notify_mentioned_accounts!
@@ -113,6 +115,12 @@ class FanOutOnWriteService < BaseService
     end
   end
 
+  def deliver_to_direct_timelines!
+    FeedInsertWorker.push_bulk(@status.mentions.includes(:account).map(&:account).select(&:local?)) do |account|
+      [@status.id, account.id, 'direct', { 'update' => update? }]
+    end
+  end
+
   def broadcast_to_hashtag_streams!
     @status.tags.map(&:name).each do |hashtag|
       redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload)
@@ -121,7 +129,7 @@ class FanOutOnWriteService < BaseService
   end
 
   def broadcast_to_public_streams!
-    return if @status.reply? && @status.in_reply_to_account_id != @account.id
+    return if @status.reply? && @status.in_reply_to_account_id != @account.id && !Setting.show_replies_in_public_timelines
 
     redis.publish('timeline:public', anonymous_payload)
     redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', anonymous_payload)
@@ -156,6 +164,6 @@ class FanOutOnWriteService < BaseService
   end
 
   def broadcastable?
-    @status.public_visibility? && !@status.reblog? && !@account.silenced?
+    @status.public_visibility? && !@account.silenced? && (!@status.reblog? || Setting.show_reblogs_in_public_timelines)
   end
 end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index ea27f374e..74ec47a33 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -61,9 +61,23 @@ class PostStatusService < BaseService
 
   private
 
+  def fill_blank_text!
+    return unless @text.blank? && @options[:spoiler_text].present?
+
+    if @media&.any?(&:video?) || @media&.any?(&:gifv?)
+      @text = '📹'
+    elsif @media&.any?(&:audio?)
+      @text = '🎵'
+    elsif @media&.any?(&:image?)
+      @text = '🖼'
+    else
+      @text = '.'
+    end
+  end
+
   def preprocess_attributes!
+    fill_blank_text!
     @sensitive    = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
-    @text         = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
     @visibility   = :unlisted if @visibility&.to_sym == :public && @account.silenced?
     @scheduled_at = @options[:scheduled_at]&.to_datetime
@@ -120,7 +134,7 @@ class PostStatusService < BaseService
     Trends.tags.register(@status)
     LinkCrawlWorker.perform_async(@status.id)
     DistributionWorker.perform_async(@status.id)
-    ActivityPub::DistributionWorker.perform_async(@status.id)
+    ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only?
     PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
   end
 
@@ -195,6 +209,7 @@ class PostStatusService < BaseService
       visibility: @visibility,
       language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale),
       application: @options[:application],
+      content_type: @options[:content_type] || @account.user&.setting_default_content_type,
       rate_limit: @options[:with_rate_limit],
     }.compact
   end
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index f813f06b2..4212e4389 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -5,6 +5,7 @@ class PrecomputeFeedService < BaseService
 
   def call(account)
     FeedManager.instance.populate_home(account)
+    FeedManager.instance.populate_direct_feed(account)
   ensure
     redis.del("account:#{account.id}:regeneration")
   end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 6ec094474..b73669f9d 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -30,7 +30,7 @@ class ReblogService < BaseService
 
     Trends.register!(reblog)
     DistributionWorker.perform_async(reblog.id)
-    ActivityPub::DistributionWorker.perform_async(reblog.id)
+    ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
 
     create_notification(reblog)
     bump_potential_friendship(account, reblog)
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index ea799db57..4f98ccea7 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -43,6 +43,7 @@ class RemoveStatusService < BaseService
         remove_from_hashtags
         remove_from_public
         remove_from_media if @status.with_media?
+        remove_from_direct if status.direct_visibility?
         remove_media
       end
 
@@ -54,6 +55,7 @@ class RemoveStatusService < BaseService
 
   def remove_from_self
     FeedManager.instance.unpush_from_home(@account, @status)
+    FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility?
   end
 
   def remove_from_followers
@@ -134,6 +136,12 @@ class RemoveStatusService < BaseService
     redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
   end
 
+  def remove_from_direct
+    @status.active_mentions.each do |mention|
+      FeedManager.instance.unpush_from_direct(mention.account, @status) if mention.account.local?
+    end
+  end
+
   def remove_media
     return if @options[:redraft] || !permanently?
 
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
index d1c2b990f..de6f1e6d1 100644
--- a/app/services/update_status_service.rb
+++ b/app/services/update_status_service.rb
@@ -16,6 +16,7 @@ class UpdateStatusService < BaseService
   # @option options [String] :spoiler_text
   # @option options [Boolean] :sensitive
   # @option options [String] :language
+  # @option options [String] :content_type
   def call(status, account_id, options = {})
     @status                    = status
     @options                   = options
@@ -112,6 +113,7 @@ class UpdateStatusService < BaseService
     @status.spoiler_text = @options[:spoiler_text] || '' if @options.key?(:spoiler_text)
     @status.sensitive    = @options[:sensitive] || @options[:spoiler_text].present? if @options.key?(:sensitive) || @options.key?(:spoiler_text)
     @status.language     = valid_locale_cascade(@options[:language], @status.language, @status.account.user&.preferred_posting_language, I18n.default_locale)
+    @status.content_type = @options[:content_type] || @status.content_type
 
     # We raise here to rollback the entire transaction
     raise NoChangesSubmittedError unless significant_changes?
@@ -134,7 +136,7 @@ class UpdateStatusService < BaseService
 
   def broadcast_updates!
     DistributionWorker.perform_async(@status.id, { 'update' => true })
-    ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id)
+    ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id) unless @status.local_only?
   end
 
   def queue_poll_notifications!
diff --git a/app/validators/poll_validator.rb b/app/validators/poll_validator.rb
index a32727796..1aaf5a5d0 100644
--- a/app/validators/poll_validator.rb
+++ b/app/validators/poll_validator.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 class PollValidator < ActiveModel::Validator
-  MAX_OPTIONS      = 4
-  MAX_OPTION_CHARS = 50
+  MAX_OPTIONS      = (ENV['MAX_POLL_OPTIONS'] || 5).to_i
+  MAX_OPTION_CHARS = (ENV['MAX_POLL_OPTION_CHARS'] || 100).to_i
   MAX_EXPIRATION   = 1.month.freeze
   MIN_EXPIRATION   = 5.minutes.freeze
 
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
index e107912b7..f93450ba6 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
   URL_PLACEHOLDER_CHARS = 23
   URL_PLACEHOLDER = 'x' * 23
 
diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb
index 2fdd5b34f..4af7bd295 100644
--- a/app/validators/status_pin_validator.rb
+++ b/app/validators/status_pin_validator.rb
@@ -1,10 +1,12 @@
 # frozen_string_literal: true
 
 class StatusPinValidator < ActiveModel::Validator
+  MAX_PINNED = (ENV['MAX_PINNED_TOOTS'] || 5).to_i
+
   def validate(pin)
     pin.errors.add(:base, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog?
     pin.errors.add(:base, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id
     pin.errors.add(:base, I18n.t('statuses.pin_errors.direct')) if pin.status.direct_visibility?
-    pin.errors.add(:base, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count > 4 && pin.account.local?
+    pin.errors.add(:base, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count >= MAX_PINNED && pin.account.local?
   end
 end
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index d0897221d..63a88ded2 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.accounts.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
   .filters
     .filter-subset.filter-subset--with-select
diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml
index 4c78797c1..c4929cc42 100644
--- a/app/views/admin/action_logs/index.html.haml
+++ b/app/views/admin/action_logs/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.action_logs.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 = form_tag admin_action_logs_url, method: 'GET', class: 'simple_form' do
   = hidden_field_tag :target_account_id, params[:target_account_id] if params[:target_account_id].present?
 
diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml
index c6c47586a..df1ac455f 100644
--- a/app/views/admin/announcements/edit.html.haml
+++ b/app/views/admin/announcements/edit.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 = simple_form_for @announcement, url: admin_announcement_path(@announcement), html: { novalidate: false } do |f|
   = render 'shared/error_messages', object: @announcement
 
diff --git a/app/views/admin/announcements/new.html.haml b/app/views/admin/announcements/new.html.haml
index 57b7d5e0c..cb39672e1 100644
--- a/app/views/admin/announcements/new.html.haml
+++ b/app/views/admin/announcements/new.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 = simple_form_for @announcement, url: admin_announcements_path, html: { novalidate: false } do |f|
   = render 'shared/error_messages', object: @announcement
 
diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml
index 6ded4b433..89eb653e3 100644
--- a/app/views/admin/custom_emojis/index.html.haml
+++ b/app/views/admin/custom_emojis/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.custom_emojis.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - if can?(:create, :custom_emoji)
   - content_for :heading_actions do
     = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/admin/custom_emojis/new.html.haml
index 95996dec8..1ea931a2f 100644
--- a/app/views/admin/custom_emojis/new.html.haml
+++ b/app/views/admin/custom_emojis/new.html.haml
@@ -7,7 +7,7 @@
   .fields-group
     = f.input :shortcode, wrapper: :with_label, label: t('admin.custom_emojis.shortcode'), hint: t('admin.custom_emojis.shortcode_hint')
   .fields-group
-    = f.input :image, wrapper: :with_label, input_html: { accept: CustomEmoji::IMAGE_MIME_TYPES.join(' ') }, hint: t('admin.custom_emojis.image_hint', size: number_to_human_size(CustomEmoji::LIMIT))
+    = f.input :image, wrapper: :with_label, input_html: { accept: CustomEmoji::IMAGE_MIME_TYPES.join(' ') }, hint: t('admin.custom_emojis.image_hint', size: number_to_human_size(CustomEmoji::LOCAL_LIMIT))
 
   .actions
     = f.button :button, t('admin.custom_emojis.upload'), type: :submit
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index ab7cb9de6..3597152e0 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.dashboard.title')
 
diff --git a/app/views/admin/disputes/appeals/index.html.haml b/app/views/admin/disputes/appeals/index.html.haml
index 602414550..7f04dd40f 100644
--- a/app/views/admin/disputes/appeals/index.html.haml
+++ b/app/views/admin/disputes/appeals/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.disputes.appeals.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 .filters
   .filter-subset
     %strong= t('admin.tags.review')
diff --git a/app/views/admin/domain_allows/new.html.haml b/app/views/admin/domain_allows/new.html.haml
index 249a961ce..85ab7e464 100644
--- a/app/views/admin/domain_allows/new.html.haml
+++ b/app/views/admin/domain_allows/new.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.domain_allows.add_new')
 
diff --git a/app/views/admin/domain_blocks/edit.html.haml b/app/views/admin/domain_blocks/edit.html.haml
index 39c6d108a..15d70a39e 100644
--- a/app/views/admin/domain_blocks/edit.html.haml
+++ b/app/views/admin/domain_blocks/edit.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.domain_blocks.edit')
 
diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml
index bcaa331b5..0944573bf 100644
--- a/app/views/admin/domain_blocks/new.html.haml
+++ b/app/views/admin/domain_blocks/new.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('.title')
 
diff --git a/app/views/admin/email_domain_blocks/index.html.haml b/app/views/admin/email_domain_blocks/index.html.haml
index b073e8716..9f16e0d5c 100644
--- a/app/views/admin/email_domain_blocks/index.html.haml
+++ b/app/views/admin/email_domain_blocks/index.html.haml
@@ -4,9 +4,6 @@
 - content_for :heading_actions do
   = link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button'
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 = form_for(@form, url: batch_admin_email_domain_blocks_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
 
diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml
index 524b69968..fa1d950ad 100644
--- a/app/views/admin/email_domain_blocks/new.html.haml
+++ b/app/views/admin/email_domain_blocks/new.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 = simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f|
   = render 'shared/error_messages', object: @email_domain_block
 
diff --git a/app/views/admin/export_domain_blocks/import.html.haml b/app/views/admin/export_domain_blocks/import.html.haml
index 804e61199..01add232d 100644
--- a/app/views/admin/export_domain_blocks/import.html.haml
+++ b/app/views/admin/export_domain_blocks/import.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.export_domain_blocks.import.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 %p= t('admin.export_domain_blocks.import.description_html')
 
 - if defined?(@global_private_comment) && @global_private_comment.present?
diff --git a/app/views/admin/follow_recommendations/show.html.haml b/app/views/admin/follow_recommendations/show.html.haml
index ebc4a2c6b..dc65a7213 100644
--- a/app/views/admin/follow_recommendations/show.html.haml
+++ b/app/views/admin/follow_recommendations/show.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.follow_recommendations.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 %p= t('admin.follow_recommendations.description_html')
 
 %hr.spacer/
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index 0bae70e31..3e70a51ee 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.instances.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :heading_actions do
   - if whitelist_mode?
     = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button', id: 'add-instance-button'
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index ab290912e..00c1927df 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = @instance.domain
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - if current_user.can?(:view_dashboard)
   - content_for :heading_actions do
     = l(@time_period.first)
diff --git a/app/views/admin/ip_blocks/index.html.haml b/app/views/admin/ip_blocks/index.html.haml
index d5b983de9..00593840c 100644
--- a/app/views/admin/ip_blocks/index.html.haml
+++ b/app/views/admin/ip_blocks/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.ip_blocks.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - if can?(:create, :ip_block)
   - content_for :heading_actions do
     = link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button'
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index a286aaec3..9d1c561d7 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -1,7 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-  = javascript_pack_tag 'public', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.reports.report', id: @report.id)
 
diff --git a/app/views/admin/settings/about/show.html.haml b/app/views/admin/settings/about/show.html.haml
index 2aaa64abe..cbba20faa 100644
--- a/app/views/admin/settings/about/show.html.haml
+++ b/app/views/admin/settings/about/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.settings.about.title')
 
diff --git a/app/views/admin/settings/appearance/show.html.haml b/app/views/admin/settings/appearance/show.html.haml
index d321c4b04..f02ecc105 100644
--- a/app/views/admin/settings/appearance/show.html.haml
+++ b/app/views/admin/settings/appearance/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.settings.appearance.title')
 
@@ -14,7 +11,7 @@
   %p.lead= t('admin.settings.appearance.preamble')
 
   .fields-group
-    = f.input :theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false
+    = f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: lambda { |(flavour, _)| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, label: t('admin.settings.flavour_and_skin.title'), include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last
 
   .fields-group
     = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }
diff --git a/app/views/admin/settings/branding/show.html.haml b/app/views/admin/settings/branding/show.html.haml
index 74a6fadf9..aee730689 100644
--- a/app/views/admin/settings/branding/show.html.haml
+++ b/app/views/admin/settings/branding/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.settings.branding.title')
 
diff --git a/app/views/admin/settings/content_retention/show.html.haml b/app/views/admin/settings/content_retention/show.html.haml
index 36856127f..b9467572a 100644
--- a/app/views/admin/settings/content_retention/show.html.haml
+++ b/app/views/admin/settings/content_retention/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.settings.content_retention.title')
 
diff --git a/app/views/admin/settings/discovery/show.html.haml b/app/views/admin/settings/discovery/show.html.haml
index 759bbdceb..460bb5709 100644
--- a/app/views/admin/settings/discovery/show.html.haml
+++ b/app/views/admin/settings/discovery/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.settings.discovery.title')
 
@@ -24,6 +21,9 @@
   .fields-group
     = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, recommended: :not_recommended
 
+  .fields-group
+    = f.input :trending_status_cw, as: :boolean, wrapper: :with_label, label: t('admin.settings.trending_status_cw.title'), hint: t('admin.settings.trending_status_cw.desc_html'), glitch_only: true
+
   %h4= t('admin.settings.discovery.public_timelines')
 
   .fields-group
diff --git a/app/views/admin/settings/other/show.html.haml b/app/views/admin/settings/other/show.html.haml
new file mode 100644
index 000000000..1a7a4e46e
--- /dev/null
+++ b/app/views/admin/settings/other/show.html.haml
@@ -0,0 +1,26 @@
+- content_for :page_title do
+  = t('admin.settings.other.title')
+
+- content_for :heading do
+  %h2= t('admin.settings.title')
+  = render partial: 'admin/settings/shared/links'
+
+= simple_form_for @admin_settings, url: admin_settings_other_path, html: { method: :patch } do |f|
+  = render 'shared/error_messages', object: @admin_settings
+
+  %p.lead= t('admin.settings.other.preamble')
+
+  .fields-group
+    = f.input :hide_followers_count, as: :boolean, wrapper: :with_label, label: t('admin.settings.hide_followers_count.title'), hint: t('admin.settings.hide_followers_count.desc_html'), glitch_only: true
+
+  .fields-group
+    = f.input :show_reblogs_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_reblogs_in_public_timelines.title'), hint: t('admin.settings.show_reblogs_in_public_timelines.desc_html'), glitch_only: true
+
+  .fields-group
+    = f.input :show_replies_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_replies_in_public_timelines.title'), hint: t('admin.settings.show_replies_in_public_timelines.desc_html'), glitch_only: true
+
+  .fields-group
+    = f.input :outgoing_spoilers, wrapper: :with_label, label: t('admin.settings.outgoing_spoilers.title'), hint: t('admin.settings.outgoing_spoilers.desc_html'), glitch_only: true
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/settings/registrations/show.html.haml b/app/views/admin/settings/registrations/show.html.haml
index 0db9f3536..455fa5eca 100644
--- a/app/views/admin/settings/registrations/show.html.haml
+++ b/app/views/admin/settings/registrations/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.settings.registrations.title')
 
@@ -20,6 +17,10 @@
     .fields-row__column.fields-row__column-6.fields-group
       = f.input :require_invite_text, as: :boolean, wrapper: :with_label, disabled: !approved_registrations?
 
+  - if captcha_available?
+    .fields-group
+      = f.input :captcha_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.captcha_enabled.title'), hint: t('admin.settings.captcha_enabled.desc_html'), glitch_only: true
+
   .fields-group
     = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, input_html: { rows: 2 }
 
diff --git a/app/views/admin/settings/shared/_links.html.haml b/app/views/admin/settings/shared/_links.html.haml
index 1294c26ce..9f2cdd3f3 100644
--- a/app/views/admin/settings/shared/_links.html.haml
+++ b/app/views/admin/settings/shared/_links.html.haml
@@ -6,3 +6,4 @@
     - primary.item :discovery, safe_join([fa_icon('search fw'), t('admin.settings.discovery.title')]), admin_settings_discovery_path
     - primary.item :content_retention, safe_join([fa_icon('history fw'), t('admin.settings.content_retention.title')]), admin_settings_content_retention_path
     - primary.item :appearance, safe_join([fa_icon('desktop fw'), t('admin.settings.appearance.title')]), admin_settings_appearance_path
+    - primary.item :other, safe_join([fa_icon('ellipsis-h fw'), t('admin.settings.other.title')]), admin_settings_other_path
diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml
index d3d7cc160..9163dee79 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', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('admin.statuses.title')
   \-
diff --git a/app/views/admin/statuses/show.html.haml b/app/views/admin/statuses/show.html.haml
index 4631e97f1..e070e5872 100644
--- a/app/views/admin/statuses/show.html.haml
+++ b/app/views/admin/statuses/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false))
 
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index 104190b58..71bce0c0c 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = "##{@tag.display_name}"
 
diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml
index 7b5122cf1..e6ed9d95f 100644
--- a/app/views/admin/trends/links/index.html.haml
+++ b/app/views/admin/trends/links/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.trends.links.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 %p= t('admin.trends.links.description_html')
 
 %hr.spacer/
diff --git a/app/views/admin/trends/links/preview_card_providers/index.html.haml b/app/views/admin/trends/links/preview_card_providers/index.html.haml
index c3648c35e..222ff6bda 100644
--- a/app/views/admin/trends/links/preview_card_providers/index.html.haml
+++ b/app/views/admin/trends/links/preview_card_providers/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.trends.preview_card_providers.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 %p= t('admin.trends.preview_card_providers.description_html')
 
 %hr.spacer/
diff --git a/app/views/admin/trends/statuses/index.html.haml b/app/views/admin/trends/statuses/index.html.haml
index a42d60b00..bf04772f2 100644
--- a/app/views/admin/trends/statuses/index.html.haml
+++ b/app/views/admin/trends/statuses/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.trends.statuses.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 %p= t('admin.trends.statuses.description_html')
 
 %hr.spacer/
diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml
index 1ea34c9e3..4730d20c1 100644
--- a/app/views/admin/trends/tags/index.html.haml
+++ b/app/views/admin/trends/tags/index.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('admin.trends.tags.title')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 %p= t('admin.trends.tags.description_html')
 
 %hr.spacer/
diff --git a/app/views/auth/confirmations/captcha.html.haml b/app/views/auth/confirmations/captcha.html.haml
new file mode 100644
index 000000000..642f19062
--- /dev/null
+++ b/app/views/auth/confirmations/captcha.html.haml
@@ -0,0 +1,14 @@
+- content_for :page_title do
+  = t('auth.captcha_confirmation.title')
+
+= form_tag auth_captcha_confirmation_url, method: 'POST', class: 'simple_form' do
+  = hidden_field_tag :confirmation_token, params[:confirmation_token]
+
+  .field-group
+    %p.hint= t('auth.captcha_confirmation.hint_html')
+
+  .field-group
+    = render_captcha
+
+  .actions
+    %button.button= t('challenge.confirm')
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index 20232d8dc..1867ec7f8 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -1,8 +1,6 @@
 - content_for :page_title do
   = t('auth.login')
 
-= javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous'
-
 - if @webauthn_enabled
   = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }
 
diff --git a/app/views/filters/statuses/index.html.haml b/app/views/filters/statuses/index.html.haml
index 886de58fa..eaa39e170 100644
--- a/app/views/filters/statuses/index.html.haml
+++ b/app/views/filters/statuses/index.html.haml
@@ -1,6 +1,3 @@
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = t('filters.statuses.index.title')
   \-
diff --git a/app/views/layouts/_theme.html.haml b/app/views/layouts/_theme.html.haml
new file mode 100644
index 000000000..71e661de6
--- /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]
+    - pack_path = theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}"
+    = javascript_pack_tag pack_path, crossorigin: 'anonymous'
+    - if theme[:skin]
+      - if !theme[:flavour] || theme[:skin] == 'default'
+        = stylesheet_pack_tag pack_path, media: 'all', crossorigin: 'anonymous'
+      - else
+        = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}", media: 'all', crossorigin: 'anonymous'
+    - 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 e7a163c92..3048e0e6a 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -1,6 +1,5 @@
 - content_for :header_tags do
   = render_initial_state
-  = javascript_pack_tag 'public', crossorigin: 'anonymous'
 
 - content_for :content do
   .admin-wrapper
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 7b9434d6f..f4f8744e9 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -26,18 +26,25 @@
 
     %title= content_for?(:page_title) ? safe_join([yield(:page_title).chomp.html_safe, title], ' - ') : title
 
-    = stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous'
-    = stylesheet_pack_tag current_theme, media: 'all', crossorigin: 'anonymous'
-    = javascript_pack_tag 'common', crossorigin: 'anonymous'
-    = javascript_pack_tag "locale_#{I18n.locale}", crossorigin: 'anonymous'
+    = javascript_pack_tag "locales", crossorigin: 'anonymous'
+    - if @theme
+      - if @theme[:supported_locales].include? I18n.locale.to_s
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous'
+      - elsif @theme[:supported_locales].include? 'en'
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous'
     = csrf_meta_tags
     %meta{ name: 'style-nonce', content: request.content_security_policy_nonce }
 
     = stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style'
-    = stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all'
 
     = 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
+
+    = stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all'
+
   %body{ class: body_classes }
     = content_for?(:content) ? yield(:content) : yield
 
diff --git a/app/views/layouts/auth.html.haml b/app/views/layouts/auth.html.haml
index 6096eada4..34f6b38ec 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', crossorigin: 'anonymous'
-
 - content_for :content do
   .container-alt
     .logo-container
diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml
index e74bff9cc..210ac101d 100644
--- a/app/views/layouts/embedded.html.haml
+++ b/app/views/layouts/embedded.html.haml
@@ -11,12 +11,16 @@
     - if storage_host?
       %link{ rel: 'dns-prefetch', href: storage_host }/
 
-    = stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous'
-    = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all', crossorigin: 'anonymous'
-    = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
-    = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
     = render_initial_state
-    = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
+    = javascript_pack_tag 'locales', crossorigin: 'anonymous'
+    - if @theme
+      - if @theme[:supported_locales].include? I18n.locale.to_s
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous'
+      - elsif @theme[:supported_locales].include? 'en'
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous'
+    = render partial: 'layouts/theme', object: @core
+    = render partial: 'layouts/theme', object: @theme
+
   %body.embed
     = yield
 
diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml
index 852a0c69b..55da5de3f 100644
--- a/app/views/layouts/error.html.haml
+++ b/app/views/layouts/error.html.haml
@@ -5,10 +5,9 @@
     %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', crossorigin: 'anonymous'
-    = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all', crossorigin: 'anonymous'
-    = javascript_pack_tag 'common', crossorigin: 'anonymous'
-    = javascript_pack_tag 'error', crossorigin: 'anonymous'
+    = javascript_pack_tag "locales", crossorigin: 'anonymous'
+    = render partial: 'layouts/theme', object: (@core || { pack: 'common' })
+    = render partial: 'layouts/theme', object: (@theme || { pack: 'error', flavour: 'glitch', common: { pack: 'common', flavour: 'glitch', skin: 'default' } })
   %body.error
     .dialog
       .dialog__illustration
diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml
index 43c855927..288c473d2 100644
--- a/app/views/layouts/mailer.html.haml
+++ b/app/views/layouts/mailer.html.haml
@@ -4,7 +4,7 @@
     %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' }
 
-    = stylesheet_pack_tag 'mailer'
+    = stylesheet_pack_tag 'core/mailer'
   %body{ dir: locale_direction }
     %table.email-table{ cellspacing: 0, cellpadding: 0 }
       %tbody
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
index bd2dcc132..5d08d7848 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', crossorigin: 'anonymous'
-
 - content_for :content do
   - if user_signed_in? && !@hide_header
     .account-header
diff --git a/app/views/media/player.html.haml b/app/views/media/player.html.haml
index f00c8f040..c907d5c60 100644
--- a/app/views/media/player.html.haml
+++ b/app/views/media/player.html.haml
@@ -1,6 +1,13 @@
 - content_for :header_tags do
   = render_initial_state
-  = javascript_pack_tag 'public', crossorigin: 'anonymous'
+  = javascript_pack_tag 'locales', crossorigin: 'anonymous'
+  - if @theme
+    - if @theme[:supported_locales].include? I18n.locale.to_s
+      = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous'
+    - elsif @theme[:supported_locales].include? 'en'
+      = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous'
+  = render partial: 'layouts/theme', object: @core
+  = render partial: 'layouts/theme', object: @theme
 
 :ruby
   meta = @media_attachment.file.meta || {}
diff --git a/app/views/relationships/show.html.haml b/app/views/relationships/show.html.haml
index f08e9c1df..fcda6317e 100644
--- a/app/views/relationships/show.html.haml
+++ b/app/views/relationships/show.html.haml
@@ -1,9 +1,6 @@
 - content_for :page_title do
   = t('settings.relationships')
 
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
 .filters
   .filter-subset
     %strong= t 'relationships.relationship'
diff --git a/app/views/settings/flavours/show.html.haml b/app/views/settings/flavours/show.html.haml
new file mode 100644
index 000000000..ea2540858
--- /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: full_pack_url("media/#{screen}"), alt: '' }
+
+  .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: ->(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/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index 5358310e5..dc82ce5b6 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -6,15 +6,14 @@
 
 = simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f|
   .fields-row
-    .fields-group.fields-row__column.fields-row__column-6
+    .fields-group
       = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, selected: I18n.locale, hint: false
-    .fields-group.fields-row__column.fields-row__column-6
-      = f.simple_fields_for :settings, current_user.settings do |ff|
-        = ff.input :theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false, hint: false
 
   - unless I18n.locale == :en
     .flash-message.translation-prompt
       #{t 'appearance.localization.body'} #{content_tag(:a, t('appearance.localization.guide_link_text'), href: t('appearance.localization.guide_link'), target: '_blank', rel: 'noopener')}
+      = link_to t('appearance.localization.glitch_guide_link'), target: '_blank', rel: 'noopener noreferrer' do
+        = t('appearance.localization.glitch_guide_link_text')
 
   = f.simple_fields_for :settings, current_user.settings do |ff|
     %h4= t 'appearance.advanced_web_interface'
@@ -33,6 +32,7 @@
       = ff.input :'web.reduce_motion', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reduce_motion')
       = ff.input :'web.disable_swiping', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_disable_swiping')
       = ff.input :'web.use_system_font', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_system_font_ui')
+      = ff.input :'web.use_system_emoji_font', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_system_emoji_font'), glitch_only: true
 
     %h4= t 'appearance.toot_layout'
 
@@ -49,6 +49,7 @@
     .fields-group
       = ff.input :'web.unfollow_modal', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_unfollow_modal')
       = ff.input :'web.reblog_modal', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_boost_modal')
+      = ff.input :'web.favourite_modal', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_favourite_modal'), glitch_only: true
       = ff.input :'web.delete_modal', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_delete_modal')
 
     %h4= t 'appearance.sensitive_content'
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index cb1ad0886..cfc468eef 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -22,6 +22,8 @@
       = ff.input :'notification_emails.appeal', as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.appeal') if current_user.can?(:manage_appeals)
       = ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users)
       = ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies)
+      = ff.input :'notification_emails.link_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_link') if current_user.can?(:manage_taxonomies)
+      = ff.input :'notification_emails.status_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_status') if current_user.can?(:manage_taxonomies)
 
     .fields-group
       = ff.input :always_send_emails, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_always_send_emails'), hint: I18n.t('simple_form.hints.defaults.setting_always_send_emails')
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 6590ec7c2..ebb89f44b 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -14,6 +14,10 @@
     .fields-group
       = ff.input :aggregate_reblogs, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_aggregate_reblogs'), hint: I18n.t('simple_form.hints.defaults.setting_aggregate_reblogs')
 
+    - unless Setting.hide_followers_count
+      .fields-group
+        = ff.input :hide_followers_count, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_hide_followers_count'), glitch_only: true
+
     %h4= t 'preferences.posting_defaults'
 
     .fields-row
@@ -29,6 +33,9 @@
     .fields-group
       = ff.input :show_application, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_show_application'), hint: I18n.t('simple_form.hints.defaults.setting_show_application')
 
+  .fields-group
+    = f.input :setting_default_content_type, collection: ['text/plain', 'text/markdown', 'text/html'], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.defaults.setting_default_content_type_#{item.split('/')[1]}"), content_tag(:span, t("simple_form.hints.defaults.setting_default_content_type_#{item.split('/')[1]}"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', glitch_only: true
+
   %h4= t 'preferences.public_timelines'
 
   .fields-group
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 3067b3737..430d1f339 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -9,8 +9,8 @@
 
   .fields-row
     .fields-row__column.fields-group.fields-row__column-6
-      = f.input :display_name, wrapper: :with_label, input_html: { maxlength: 30, data: { default: @account.username } }, hint: false
-      = f.input :note, wrapper: :with_label, input_html: { maxlength: 500 }, hint: false
+      = f.input :display_name, wrapper: :with_label, input_html: { maxlength: Account::MAX_DISPLAY_NAME_LENGTH, data: { default: @account.username } }, hint: false
+      = f.input :note, wrapper: :with_label, input_html: { maxlength: Account::MAX_NOTE_LENGTH }, hint: false
 
   .fields-row
     .fields-row__column.fields-row__column-6
@@ -41,7 +41,7 @@
     .fields-row__column.fields-group.fields-row__column-6
       .input.with_block_label
         %label= t('simple_form.labels.defaults.fields')
-        %span.hint= t('simple_form.hints.defaults.fields')
+        %span.hint= t('simple_form.hints.defaults.fields', count: Account::DEFAULT_FIELDS_SIZE)
 
         = f.simple_fields_for :fields do |fields_f|
           .row
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
index 5e9d22571..5ec024757 100644
--- a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
@@ -12,5 +12,3 @@
 
   .actions
     = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
-
-= javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous'
diff --git a/app/views/shared/_web_app.html.haml b/app/views/shared/_web_app.html.haml
index 998cee9fa..b9a0ce1fc 100644
--- a/app/views/shared/_web_app.html.haml
+++ b/app/views/shared/_web_app.html.haml
@@ -7,7 +7,6 @@
   %meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key }
 
   = render_initial_state
-  = javascript_pack_tag 'application', crossorigin: 'anonymous'
 
 .notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } }
   %noscript
diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml
index 1c0bbf676..28910d3ab 100644
--- a/app/views/shares/show.html.haml
+++ b/app/views/shares/show.html.haml
@@ -1,5 +1,4 @@
 - content_for :header_tags do
   = render_initial_state
-  = javascript_pack_tag 'share', crossorigin: 'anonymous'
 
 #mastodon-compose{ data: { props: Oj.dump(default_props) } }
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 32584c92a..ecbabf34c 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -32,7 +32,7 @@
       %p<
         %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
         %button.status__content__spoiler-link= t('statuses.show_more')
-    .e-content{ lang: status.language }
+    .e-content{ lang: status.language }<
       = prerender_custom_emojis(status_content_format(status), status.emojis)
 
       - if status.preloadable_poll
diff --git a/app/workers/activitypub/distribute_poll_update_worker.rb b/app/workers/activitypub/distribute_poll_update_worker.rb
index 8c1eefd93..ebdb78bb3 100644
--- a/app/workers/activitypub/distribute_poll_update_worker.rb
+++ b/app/workers/activitypub/distribute_poll_update_worker.rb
@@ -10,7 +10,7 @@ class ActivityPub::DistributePollUpdateWorker
     @status  = Status.find(status_id)
     @account = @status.account
 
-    return unless @status.preloadable_poll
+    return if @status.preloadable_poll.nil? || @status.local_only?
 
     ActivityPub::DeliveryWorker.push_bulk(inboxes, limit: 1_000) do |inbox_url|
       [payload, @account.id, inbox_url]
diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb
index 758cebd4b..ee9a3cadc 100644
--- a/app/workers/feed_insert_worker.rb
+++ b/app/workers/feed_insert_worker.rb
@@ -14,6 +14,8 @@ class FeedInsertWorker
     when :list
       @list     = List.find(id)
       @follower = @list.account
+    when :direct
+      @account  = Account.find(id)
     end
 
     check_and_insert
@@ -40,6 +42,8 @@ class FeedInsertWorker
       FeedManager.instance.filter?(:tags, @status, @follower)
     when :list
       FeedManager.instance.filter?(:list, @status, @list)
+    when :direct
+      FeedManager.instance.filter?(:direct, @status, @account)
     end
   end
 
@@ -55,6 +59,8 @@ class FeedInsertWorker
       FeedManager.instance.push_to_home(@follower, @status, update: update?)
     when :list
       FeedManager.instance.push_to_list(@list, @status, update: update?)
+    when :direct
+      FeedManager.instance.push_to_direct(@account, @status, update: update?)
     end
   end
 
@@ -64,6 +70,8 @@ class FeedInsertWorker
       FeedManager.instance.unpush_from_home(@follower, @status, update: true)
     when :list
       FeedManager.instance.unpush_from_list(@list, @status, update: true)
+    when :direct
+      FeedManager.instance.unpush_from_direct(@account, @status, update: true)
     end
   end
 
diff --git a/config/environments/production.rb b/config/environments/production.rb
index ef52228a0..95c6e790f 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -127,6 +127,7 @@ Rails.application.configure do
     'X-Frame-Options'        => 'DENY',
     'X-Content-Type-Options' => 'nosniff',
     'X-XSS-Protection'       => '0',
+    'X-Clacks-Overhead'      => 'GNU Natalie Nguyen',
     'Referrer-Policy'        => 'same-origin',
   }
 
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index 46dd3124b..a3b5e5c72 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -4,12 +4,19 @@
 base_locale: en
 data:
   read:
-    - config/locales/%{locale}.yml
-    - config/locales/**/*.%{locale}.yml
+    - config/locales-glitch/%{locale}.yml
+    - config/locales-glitch/*.%{locale}.yml
 
   write:
-    - ['{devise, simple_form, doorkeeper}.*', 'config/locales/\1.%{locale}.yml']
+    - [
+        '{devise, simple_form, doorkeeper}.*',
+        'config/locales-glitch/\1.%{locale}.yml',
+      ]
+    - config/locales-glitch/%{locale}.yml
+
+  external:
     - config/locales/%{locale}.yml
+    - config/locales/**/*.%{locale}.yml
 
   yaml:
     write:
@@ -50,7 +57,7 @@ ignore_unused:
   - 'activerecord.errors.*'
   - '{devise,pagination,doorkeeper}.*'
   - '{date,datetime,time,number}.*'
-  - 'simple_form.{yes,no,recommended,not_recommended}'
+  - 'simple_form.{yes,no,recommended,not_recommended,glitch_only}'
   - 'simple_form.{placeholders,hints,labels}.*'
   - 'simple_form.{error_notification,required}.:'
   - 'errors.messages.*'
@@ -62,6 +69,7 @@ ignore_unused:
   - 'admin.reports.summary.actions.*'
   - 'admin_mailer.new_appeal.actions.*'
   - 'statuses.attached.*'
+  - 'themes.*'
   - 'move_handler.carry_{mutes,blocks}_over_text'
   - 'notification_mailer.*'
 
diff --git a/config/initializers/0_duplicate_migrations.rb b/config/initializers/0_duplicate_migrations.rb
new file mode 100644
index 000000000..1b8b59025
--- /dev/null
+++ b/config/initializers/0_duplicate_migrations.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+# Some migrations have been present in glitch-soc for a long time and have then
+# been merged in upstream Mastodon, under a different version number.
+#
+# This puts us in an uneasy situation in which if we remove upstream's
+# migration file, people migrating from upstream will end up having a conflict
+# with their already-ran migration.
+#
+# On the other hand, if we keep upstream's migration and remove our own,
+# any current glitch-soc user will have a conflict during migration.
+#
+# For lack of a better solution, as those migrations are indeed identical,
+# we decided monkey-patching Rails' Migrator to completely ignore the duplicate,
+# keeping only the one that has run, or an arbitrary one.
+
+ALLOWED_DUPLICATES = [2018_04_10_220657, 2018_08_31_171112].freeze
+
+module ActiveRecord
+  class Migrator
+    def self.new(direction, migrations, schema_migration, target_version = nil)
+      migrated = Set.new(Base.connection.migration_context.get_all_versions)
+
+      migrations.group_by(&:name).each do |_name, duplicates|
+        next unless duplicates.length > 1 && duplicates.all? { |m| ALLOWED_DUPLICATES.include?(m.version) }
+
+        # We have a set of allowed duplicates. Keep the migrated one, if any.
+        non_migrated = duplicates.reject { |m| migrated.include?(m.version.to_i) }
+
+        migrations = begin
+          if duplicates.length == non_migrated.length || non_migrated.empty?
+            # There weren't any migrated one, so we have to pick one “canonical” migration
+            migrations - duplicates[1..]
+          else
+            # Just reject every duplicate which hasn't been migrated yet
+            migrations - non_migrated
+          end
+        end
+      end
+
+      super(direction, migrations, schema_migration, target_version)
+    end
+  end
+
+  class MigrationContext
+    def needs_migration?
+      # A set of duplicated migrations is considered migrated if at least one of
+      # them is migrated.
+      migrated = get_all_versions
+      migrations.group_by(&:name).each do |_name, duplicates|
+        return true unless duplicates.any? { |m| migrated.include?(m.version.to_i) }
+      end
+      false
+    end
+  end
+end
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index 96026ce3b..ce8aa7af2 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -2,42 +2,46 @@
 # For further information see the following documentation
 # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
 
-def host_to_url(str)
-  "http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}" unless str.blank?
-end
+if Rails.env.production?
+  assets_host = Rails.configuration.action_controller.asset_host || "https://#{ENV['WEB_DOMAIN'] || ENV['LOCAL_DOMAIN']}"
+  data_hosts = [assets_host]
 
-base_host = Rails.configuration.x.web_domain
+  if ENV['S3_ENABLED'] == 'true'
+    attachments_host = "https://#{ENV['S3_ALIAS_HOST'] || ENV['S3_CLOUDFRONT_HOST'] || ENV['S3_HOSTNAME'] || "s3-#{ENV['S3_REGION'] || 'us-east-1'}.amazonaws.com"}"
+    attachments_host = "https://#{Addressable::URI.parse(attachments_host).host}"
+  elsif ENV['SWIFT_ENABLED'] == 'true'
+    attachments_host = ENV['SWIFT_OBJECT_URL']
+    attachments_host = "https://#{Addressable::URI.parse(attachments_host).host}"
+  else
+    attachments_host = nil
+  end
 
-assets_host   = Rails.configuration.action_controller.asset_host
-assets_host ||= host_to_url(base_host)
+  data_hosts << attachments_host unless attachments_host.nil?
 
-media_host   = host_to_url(ENV['S3_ALIAS_HOST'])
-media_host ||= host_to_url(ENV['S3_CLOUDFRONT_HOST'])
-media_host ||= host_to_url(ENV['S3_HOSTNAME']) if ENV['S3_ENABLED'] == 'true'
-media_host ||= assets_host
+  if ENV['PAPERCLIP_ROOT_URL']
+    url = Addressable::URI.parse(assets_host) + ENV['PAPERCLIP_ROOT_URL']
+    data_hosts << "https://#{url.host}"
+  end
 
-Rails.application.config.content_security_policy do |p|
-  p.base_uri        :none
-  p.default_src     :none
-  p.frame_ancestors :none
-  p.font_src        :self, assets_host
-  p.img_src         :self, :https, :data, :blob, assets_host
-  p.style_src       :self, assets_host
-  p.media_src       :self, :https, :data, assets_host
-  p.frame_src       :self, :https
-  p.manifest_src    :self, assets_host
-  p.form_action     :self
-  p.child_src       :self, :blob, assets_host
-  p.worker_src      :self, :blob, assets_host
+  data_hosts.concat(ENV['EXTRA_DATA_HOSTS'].split('|')) if ENV['EXTRA_DATA_HOSTS']
 
-  if Rails.env.development?
-    webpacker_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{Webpacker.dev_server.host_with_port}" }
+  data_hosts.uniq!
 
-    p.connect_src :self, :data, :blob, assets_host, media_host, Rails.configuration.x.streaming_api_base_url, *webpacker_urls
-    p.script_src  :self, :unsafe_inline, :unsafe_eval, assets_host
-  else
-    p.connect_src :self, :data, :blob, assets_host, media_host, Rails.configuration.x.streaming_api_base_url
-    p.script_src  :self, assets_host, "'wasm-unsafe-eval'"
+  Rails.application.config.content_security_policy do |p|
+    p.base_uri        :none
+    p.default_src     :none
+    p.frame_ancestors :none
+    p.script_src      :self, assets_host, "'wasm-unsafe-eval'"
+    p.font_src        :self, assets_host
+    p.img_src         :self, :data, :blob, *data_hosts
+    p.style_src       :self, assets_host
+    p.media_src       :self, :data, *data_hosts
+    p.frame_src       :self, :https
+    p.child_src       :self, :blob, assets_host
+    p.worker_src      :self, :blob, assets_host
+    p.connect_src     :self, :blob, :data, Rails.configuration.x.streaming_api_base_url, *data_hosts
+    p.manifest_src    :self, assets_host
+    p.form_action     :self
   end
 end
 
diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb
index 55f8c9c91..bc782bc76 100644
--- a/config/initializers/cors.rb
+++ b/config/initializers/cors.rb
@@ -30,5 +30,9 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do
       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/initializers/locale.rb b/config/initializers/locale.rb
new file mode 100644
index 000000000..4bcb1854c
--- /dev/null
+++ b/config/initializers/locale.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Rails.application.configure do
+  config.i18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names.{rb,yml}').to_s]
+  config.i18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names', '*.{rb,yml}').to_s]
+  config.i18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names.{rb,yml}').to_s]
+  config.i18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names', '*.{rb,yml}').to_s]
+  config.i18n.load_path += Dir[Rails.root.join('config', 'locales-glitch', '*.{rb,yml}').to_s]
+end
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
index 92cffc5a2..fff4f538e 100644
--- a/config/initializers/simple_form.rb
+++ b/config/initializers/simple_form.rb
@@ -19,8 +19,18 @@ module RecommendedComponent
   end
 end
 
+module GlitchOnlyComponent
+  def glitch_only(_wrapper_options = nil)
+    return unless options[:glitch_only]
+
+    options[:label_text] = ->(raw_label_text, _required_label_text, _label_present) { safe_join([raw_label_text, ' ', content_tag(:span, I18n.t('simple_form.glitch_only'), class: 'glitch_only')]) }
+    nil
+  end
+end
+
 SimpleForm.include_component(AppendComponent)
 SimpleForm.include_component(RecommendedComponent)
+SimpleForm.include_component(GlitchOnlyComponent)
 
 SimpleForm.setup do |config|
   # Wrappers are used by the form builder to generate a
@@ -78,6 +88,7 @@ SimpleForm.setup do |config|
 
     b.wrapper tag: :div, class: :label_input do |ba|
       ba.optional :recommended
+      ba.optional :glitch_only
       ba.use :label
 
       ba.wrapper tag: :div, class: :label_input__wrapper do |bb|
diff --git a/config/locales-glitch/af.yml b/config/locales-glitch/af.yml
new file mode 100644
index 000000000..252f9fd5a
--- /dev/null
+++ b/config/locales-glitch/af.yml
@@ -0,0 +1 @@
+af:
diff --git a/config/locales-glitch/an.yml b/config/locales-glitch/an.yml
new file mode 100644
index 000000000..76cc0689b
--- /dev/null
+++ b/config/locales-glitch/an.yml
@@ -0,0 +1 @@
+an:
diff --git a/config/locales-glitch/ar.yml b/config/locales-glitch/ar.yml
new file mode 100644
index 000000000..c257bc08a
--- /dev/null
+++ b/config/locales-glitch/ar.yml
@@ -0,0 +1 @@
+ar:
diff --git a/config/locales-glitch/ast.yml b/config/locales-glitch/ast.yml
new file mode 100644
index 000000000..d762c9399
--- /dev/null
+++ b/config/locales-glitch/ast.yml
@@ -0,0 +1 @@
+ast:
diff --git a/config/locales-glitch/be.yml b/config/locales-glitch/be.yml
new file mode 100644
index 000000000..91ccc2d7e
--- /dev/null
+++ b/config/locales-glitch/be.yml
@@ -0,0 +1 @@
+be:
diff --git a/config/locales-glitch/bg.yml b/config/locales-glitch/bg.yml
new file mode 100644
index 000000000..d0e375da9
--- /dev/null
+++ b/config/locales-glitch/bg.yml
@@ -0,0 +1 @@
+bg:
diff --git a/config/locales-glitch/bn.yml b/config/locales-glitch/bn.yml
new file mode 100644
index 000000000..152c69829
--- /dev/null
+++ b/config/locales-glitch/bn.yml
@@ -0,0 +1 @@
+bn:
diff --git a/config/locales-glitch/br.yml b/config/locales-glitch/br.yml
new file mode 100644
index 000000000..c7677c850
--- /dev/null
+++ b/config/locales-glitch/br.yml
@@ -0,0 +1 @@
+br:
diff --git a/config/locales-glitch/bs.yml b/config/locales-glitch/bs.yml
new file mode 100644
index 000000000..e9e174462
--- /dev/null
+++ b/config/locales-glitch/bs.yml
@@ -0,0 +1 @@
+bs:
diff --git a/config/locales-glitch/ca.yml b/config/locales-glitch/ca.yml
new file mode 100644
index 000000000..f0c487273
--- /dev/null
+++ b/config/locales-glitch/ca.yml
@@ -0,0 +1 @@
+ca:
diff --git a/config/locales-glitch/ckb.yml b/config/locales-glitch/ckb.yml
new file mode 100644
index 000000000..77d538af7
--- /dev/null
+++ b/config/locales-glitch/ckb.yml
@@ -0,0 +1 @@
+ckb:
diff --git a/config/locales-glitch/co.yml b/config/locales-glitch/co.yml
new file mode 100644
index 000000000..5330938e0
--- /dev/null
+++ b/config/locales-glitch/co.yml
@@ -0,0 +1 @@
+co:
diff --git a/config/locales-glitch/cs.yml b/config/locales-glitch/cs.yml
new file mode 100644
index 000000000..940df937b
--- /dev/null
+++ b/config/locales-glitch/cs.yml
@@ -0,0 +1,38 @@
+---
+cs:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'Při kopírování některých emoji došlo k chybě: %{message}'
+      batch_error: 'Došlo k chybě: %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: Tato funkce používá externí skripty služby hCaptcha, což může být problém z hlediska bezpečí a ochrany dat. Také to může <strong>některým (hlavně postiženým) lidem registrační proces výrazně zkomplikovat</strong>. Z tohoto důvodu prosím raději zvažte jiné možnosti, jako je schvalování registrací nebo registrace pouze pro zvané.<br>Uživatelům pozvaným skrze omezenou pozvánku se CAPTCHA nezobrazí.
+        title: Vyžadovat po nových uživatelích opsání textu z obrázku (CAPTCHA)
+      flavour_and_skin:
+        title: Rozhraní a styl
+      hide_followers_count:
+        desc_html: Nezobrazovat na uživatelských profilech počet sledujících
+        title: Schovat počet sledujících
+      other:
+        preamble: Různá nastavení glitch-soc, která se nevešla do jiných kategorií.
+        title: Jiné
+      outgoing_spoilers:
+        desc_html: Při federování příspěvků se přidá toto varování o obsahu příspěvkům, které žádné nemají. To může být užitečné, pokud je váš server zaměřen na specifický obsah, pro který by jiné servery mohly varování o obsahu vyžadovat. Připojená média budou označena jako citlivá.
+        title: Varování o obsahu pro odesílané příspěvky
+      show_reblogs_in_public_timelines:
+        desc_html: Veřejné boosty veřejných příspěvků se zobrazí na místní a federované časové ose.
+        title: Zobrazovat ve veřejných časových osách boosty
+      show_replies_in_public_timelines:
+        desc_html: Na místní a federované časové ose se kromě odpovědí autora na vlastní příspěvky (vláken) zobrazí i ostatní veřejné odpovědi.
+        title: Zobrazovat ve veřejných časových osách odpovědi
+      trending_status_cw:
+        desc_html: Zobrazovat v rámci trendů (pokud jsou zapnuté) i příspěvky s varováním o obsahu. Změny tohoto nastavení se neprojeví retroaktivně.
+        title: Povolit v trendech příspěvky s varováním o obsahu
+  auth:
+    captcha_confirmation:
+      hint_html: Už jen poslední krok! Pro potvrzení svého účtu opište prosím text z obrázku (CAPTCHA). Pokud máte dotazy nebo potřebujete s potvrzením pomoct, můžete <a href="/about/more">kontaktovat administrátora</a>.
+      title: Ověření uživatele
+  generic:
+    use_this: Použít
+  settings:
+    flavours: Rozhraní
diff --git a/config/locales-glitch/cy.yml b/config/locales-glitch/cy.yml
new file mode 100644
index 000000000..deefc9438
--- /dev/null
+++ b/config/locales-glitch/cy.yml
@@ -0,0 +1 @@
+cy:
diff --git a/config/locales-glitch/da.yml b/config/locales-glitch/da.yml
new file mode 100644
index 000000000..347c94d5e
--- /dev/null
+++ b/config/locales-glitch/da.yml
@@ -0,0 +1 @@
+da:
diff --git a/config/locales-glitch/de.yml b/config/locales-glitch/de.yml
new file mode 100644
index 000000000..a2ea8248c
--- /dev/null
+++ b/config/locales-glitch/de.yml
@@ -0,0 +1,42 @@
+---
+de:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'Fehler ist beim Kopieren einiger der ausgewählten Emoji aufgetreten: %{message}'
+      batch_error: 'Ein Fehler ist aufgetreten: %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: Dies beruht auf externen Skripts von hCaptcha, was Sicherheits- und Datenschutz-Bedenken auslösen kann. Zusätzlich <strong>kann das den Registrierungsprozess für manche (besonders behinderte) Leute signifikant weniger zugänglich machen</strong>. Aus diesen Gründen, bitte ziehe alternative Maßnahmen, wie Zulassungs- oder Einladungs-basierte Registrierung, in Erwägung.<br>Nutzer, die durch eine Einladung mit eingeschränkter Verwendungsanzahl eingeladen wurden, werden kein CAPTCHA lösen müssen
+        title: Neue Benutzer sollen ein CAPTCHA lösen müssen, um ihr Konto zu bestätigen
+      flavour_and_skin:
+        title: Variante und Skin
+      hide_followers_count:
+        desc_html: Follower nicht auf Nutzerprofilen anzeigen
+        title: Anzahl der Follower verbergen
+      other:
+        preamble: Verschiedene glitch-soc-Einstellungen, die nicht in andere Kategorien passen.
+        title: Sonstiges
+      outgoing_spoilers:
+        desc_html: Füge diese Inhaltswarnung bei föderierten Toots hinzu, wenn sie noch keine haben. Nützlich, wenn dein Server auf bestimmte Inhalte spezialisiert ist, die andere Server hinter einer Inhaltswarnung haben wollen. Medien werden auch als empfindlich markiert werden.
+        title: Inhaltswarnung für ausgehende Toots
+      show_reblogs_in_public_timelines:
+        desc_html: Zeige öffentlich geteilte Toots in lokalen und öffentlichen Timelines.
+        title: Zeige geteilte Toots in öffentlichen Timelines
+      show_replies_in_public_timelines:
+        desc_html: Neben öffentlichen Selbst-Antworten (Threads), öffentliche Antworten in lokalen und öffentlichen Timelines anzeigen.
+        title: Antworten in öffentlichen Timelines anzeigen
+      trending_status_cw:
+        desc_html: Erlaube Posts mit Inhaltswarnungen zu trenden, wenn angesagte Beiträge aktiviert sind. Änderungen an dieser Einstellung sind nicht rückwirkend.
+        title: Erlaube Posts mit Inhaltswarnungen zu trenden
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Und auch für glitch-soc!
+  auth:
+    captcha_confirmation:
+      hint_html: Nur noch ein weiterer Schritt! Um dein Konto zu bestätigen, erfordert dieser Server das Lösen eines CAPTCHA von dir. Bei Fragen oder Problemen mit der Bestätigung kannst du <a href="/about/more">den Server-Administrator kontaktieren</a>.
+      title: Benutzer-Verifizierung
+  generic:
+    use_this: Benutze das
+  settings:
+    flavours: Varianten
diff --git a/config/locales-glitch/el.yml b/config/locales-glitch/el.yml
new file mode 100644
index 000000000..419ec705c
--- /dev/null
+++ b/config/locales-glitch/el.yml
@@ -0,0 +1 @@
+el:
diff --git a/config/locales-glitch/en-GB.yml b/config/locales-glitch/en-GB.yml
new file mode 100644
index 000000000..ef03d1810
--- /dev/null
+++ b/config/locales-glitch/en-GB.yml
@@ -0,0 +1 @@
+en-GB:
diff --git a/config/locales-glitch/en.yml b/config/locales-glitch/en.yml
new file mode 100644
index 000000000..e60a89e18
--- /dev/null
+++ b/config/locales-glitch/en.yml
@@ -0,0 +1,42 @@
+---
+en:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'An error occurred when copying some of the selected emoji: %{message}'
+      batch_error: 'An error occurred: %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: This relies on external scripts from hCaptcha, which may be a security and privacy concern. In addition, <strong>this can make the registration process significantly less accessible to some (especially disabled) people</strong>. For these reasons, please consider alternative measures such as approval-based or invite-based registration.<br>Users that have been invited through a limited-use invite will not need to solve a CAPTCHA
+        title: Require new users to solve a CAPTCHA to confirm their account
+      flavour_and_skin:
+        title: Flavour and skin
+      hide_followers_count:
+        desc_html: Do not show followers count on user profiles
+        title: Hide followers count
+      other:
+        preamble: Various glitch-soc settings not fitting in other categories.
+        title: Other
+      outgoing_spoilers:
+        desc_html: When federating toots, add this content warning to toots that do not have one. It is useful if your server is specialized in content other servers might want to have under a Content Warning. Media will also be marked as sensitive.
+        title: Content warning for outgoing toots
+      show_reblogs_in_public_timelines:
+        desc_html: Show public boosts of public toots in local and public timelines.
+        title: Show boosts in public timelines
+      show_replies_in_public_timelines:
+        desc_html: In addition to public self-replies (threads), show public replies in local and public timelines.
+        title: Show replies in public timelines
+      trending_status_cw:
+        desc_html: When trending posts are enabled, allow posts with Content Warnings to be eligible. Changes to this setting are not retroactive.
+        title: Allow posts with Content Warnings to trend
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: And likewise for glitch-soc!
+  auth:
+    captcha_confirmation:
+      hint_html: Just one more step! To confirm your account, this server requires you to solve a CAPTCHA. You can <a href="/about/more">contact the server administrator</a> if you have questions or need assistance with confirming your account.
+      title: User verification
+  generic:
+    use_this: Use this
+  settings:
+    flavours: Flavours
diff --git a/config/locales-glitch/eo.yml b/config/locales-glitch/eo.yml
new file mode 100644
index 000000000..759981404
--- /dev/null
+++ b/config/locales-glitch/eo.yml
@@ -0,0 +1 @@
+eo:
diff --git a/config/locales-glitch/es-AR.yml b/config/locales-glitch/es-AR.yml
new file mode 100644
index 000000000..93e3d0540
--- /dev/null
+++ b/config/locales-glitch/es-AR.yml
@@ -0,0 +1,42 @@
+---
+es-AR:
+  admin:
+    custom_emojis:
+      batch_copy_error: Se produjo un error cuando se copian algunos emojis seleccionados %{message}
+      batch_error: Ocurrió un error %{message}
+    settings:
+      captcha_enabled:
+        desc_html: Esto depende de scripts externos de hCaptcha, que pueden ser una preocupación de seguridad y privacidad. Además, <strong>esto puede hacer el proceso de registro significativamente menos accesible para algunas personas (especialmente minusválidos)</strong>. Por estas razones, por favor considera medidas alternativas como el registro basado en la aprobación o la invitación.<br>Los usuarios que han sido invitados a través de una invitación de uso limitado no necesitarán resolver un CAPTCHA
+        title: Pedir a los usuarios nuevos resolver un CAPTCHA para confirmar su cuenta
+      flavour_and_skin:
+        title: Sabor y apariencia
+      hide_followers_count:
+        desc_html: No mostrar el conteo de seguidorxs en perfiles de usuarix
+        title: Ocultar conteo de seguidorxs
+      other:
+        preamble: Varias configuraciones de glitch-soc que no encajan en otras categorías.
+        title: Otro
+      outgoing_spoilers:
+        desc_html: Cuando los toots federen, agrega esta etiqueta de contenido a los toots que no tengan. Es útil si tu servidor se especializa en contenido que otros servidores desearían tener con una advertencia de contenido. Los medios también se marcarán como sensibles.
+        title: Advertencia de contenido para los toots salientes
+      show_reblogs_in_public_timelines:
+        desc_html: Mostrar retoots públicos en las línea de tiempo local y pública.
+        title: Mostrar retoots en líneas de tiempo públicas
+      show_replies_in_public_timelines:
+        desc_html: Además de auto-respuestas públicas (hilos), mostrar respuestas públicas en las línea de tiempo local y pública.
+        title: Mostrar respuestas en líneas de tiempo públicas
+      trending_status_cw:
+        desc_html: Cuando las publicaciones en tendencia están habilitadas, permitir que la que contienen Advertencias de Contenido sean elegibles. Los cambios en esta configuración no son retroactivos.
+        title: Permitir que publicaciones con advertencias de contenido sean tendencia
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Igual para glitch-soc!
+  auth:
+    captcha_confirmation:
+      hint_html: ¡Solo un paso más! Para confirmar tu cuenta, este servidor requiere que resuelvas un CAPTCHA. Puedes contactar <a href="/about/more"> con el administrador del servidor</a> si tienes preguntas o necesitas ayuda para confirmar tu cuenta.
+      title: Verificación de usuario
+  generic:
+    use_this: Usar
+  settings:
+    flavours: Ediciones
diff --git a/config/locales-glitch/es-MX.yml b/config/locales-glitch/es-MX.yml
new file mode 100644
index 000000000..1319dd3e3
--- /dev/null
+++ b/config/locales-glitch/es-MX.yml
@@ -0,0 +1,42 @@
+---
+es-MX:
+  admin:
+    custom_emojis:
+      batch_copy_error: Se produjo un error cuando se copian algunos emojis seleccionados %{message}
+      batch_error: Ocurrió un error %{message}
+    settings:
+      captcha_enabled:
+        desc_html: Esto depende de scripts externos de hCaptcha, que pueden ser una preocupación de seguridad y privacidad. Además, <strong>esto puede hacer el proceso de registro significativamente menos accesible para algunas personas (especialmente minusválidos)</strong>. Por estas razones, por favor considera medidas alternativas como el registro basado en la aprobación o la invitación.<br>Los usuarios que han sido invitados a través de una invitación de uso limitado no necesitarán resolver un CAPTCHA
+        title: Pedir a los usuarios nuevos resolver un CAPTCHA para confirmar su cuenta
+      flavour_and_skin:
+        title: Sabor y apariencia
+      hide_followers_count:
+        desc_html: No mostrar el conteo de seguidorxs en perfiles de usuarix
+        title: Ocultar conteo de seguidorxs
+      other:
+        preamble: Varias configuraciones de glitch-soc que no encajan en otras categorías.
+        title: Otro
+      outgoing_spoilers:
+        desc_html: Cuando los toots federen, agrega esta etiqueta de contenido a los toots que no tengan. Es útil si tu servidor se especializa en contenido que otros servidores desearían tener con una advertencia de contenido. Los medios también se marcarán como sensibles.
+        title: Advertencia de contenido para los toots salientes
+      show_reblogs_in_public_timelines:
+        desc_html: Mostrar retoots públicos en las línea de tiempo local y pública.
+        title: Mostrar retoots en líneas de tiempo públicas
+      show_replies_in_public_timelines:
+        desc_html: Además de auto-respuestas públicas (hilos), mostrar respuestas públicas en las línea de tiempo local y pública.
+        title: Mostrar respuestas en líneas de tiempo públicas
+      trending_status_cw:
+        desc_html: Cuando las publicaciones en tendencia están habilitadas, permitir que la que contienen Advertencias de Contenido sean elegibles. Los cambios en esta configuración no son retroactivos.
+        title: Permitir que publicaciones con advertencias de contenido sean tendencia
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Igual para glitch-soc!
+  auth:
+    captcha_confirmation:
+      hint_html: ¡Solo un paso más! Para confirmar tu cuenta, este servidor requiere que resuelvas un CAPTCHA. Puedes contactar <a href="/about/more"> con el administrador del servidor</a> si tienes preguntas o necesitas ayuda para confirmar tu cuenta.
+      title: Verificación de usuario
+  generic:
+    use_this: Usar
+  settings:
+    flavours: Ediciones
diff --git a/config/locales-glitch/es.yml b/config/locales-glitch/es.yml
new file mode 100644
index 000000000..4e054b056
--- /dev/null
+++ b/config/locales-glitch/es.yml
@@ -0,0 +1,42 @@
+---
+es:
+  admin:
+    custom_emojis:
+      batch_copy_error: Se produjo un error cuando se copian algunos emojis seleccionados %{message}
+      batch_error: Ocurrió un error %{message}
+    settings:
+      captcha_enabled:
+        desc_html: Esto depende de scripts externos de hCaptcha, que pueden ser una preocupación de seguridad y privacidad. Además, <strong>esto puede hacer el proceso de registro significativamente menos accesible para algunas personas (especialmente minusválidos)</strong>. Por estas razones, por favor considera medidas alternativas como el registro basado en la aprobación o la invitación.<br>Los usuarios que han sido invitados a través de una invitación de uso limitado no necesitarán resolver un CAPTCHA
+        title: Pedir a los usuarios nuevos resolver un CAPTCHA para confirmar su cuenta
+      flavour_and_skin:
+        title: Sabor y apariencia
+      hide_followers_count:
+        desc_html: No mostrar el conteo de seguidores en los perfiles de usuario
+        title: Ocultar conteo de seguidorxs
+      other:
+        preamble: Varias configuraciones de glitch-soc que no encajan en otras categorías.
+        title: Otro
+      outgoing_spoilers:
+        desc_html: Cuando los toots federen, agrega esta etiqueta de contenido a los toots que no tengan. Es útil si tu servidor se especializa en contenido que otros servidores desearían tener con una advertencia de contenido. Los medios también se marcarán como sensibles.
+        title: Advertencia de contenido para publicaciones salientes
+      show_reblogs_in_public_timelines:
+        desc_html: Mostrar impulsos públicos en las líneas de tiempo local y pública.
+        title: Mostrar impulsos en líneas de tiempo públicas
+      show_replies_in_public_timelines:
+        desc_html: Además de auto-respuestas públicas (hilos), mostrar respuestas públicas en las líneas de tiempo local y pública.
+        title: Mostrar respuestas en líneas de tiempo públicas
+      trending_status_cw:
+        desc_html: Cuando las publicaciones en tendencia están habilitadas, permitir que la que contienen Advertencias de Contenido sean elegibles. Los cambios en esta configuración no son retroactivos.
+        title: Permitir que publicaciones con advertencias de contenido sean tendencia
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Igual para glitch-soc!
+  auth:
+    captcha_confirmation:
+      hint_html: ¡Solo un paso más! Para confirmar tu cuenta, este servidor requiere que resuelvas un CAPTCHA. Puedes contactar <a href="/about/more"> con el administrador del servidor</a> si tienes preguntas o necesitas ayuda para confirmar tu cuenta.
+      title: Verificación de usuario
+  generic:
+    use_this: Usar
+  settings:
+    flavours: Ediciones
diff --git a/config/locales-glitch/et.yml b/config/locales-glitch/et.yml
new file mode 100644
index 000000000..e020c4ffc
--- /dev/null
+++ b/config/locales-glitch/et.yml
@@ -0,0 +1 @@
+et:
diff --git a/config/locales-glitch/eu.yml b/config/locales-glitch/eu.yml
new file mode 100644
index 000000000..566e176fc
--- /dev/null
+++ b/config/locales-glitch/eu.yml
@@ -0,0 +1 @@
+eu:
diff --git a/config/locales-glitch/fa.yml b/config/locales-glitch/fa.yml
new file mode 100644
index 000000000..88215f82c
--- /dev/null
+++ b/config/locales-glitch/fa.yml
@@ -0,0 +1 @@
+fa:
diff --git a/config/locales-glitch/fi.yml b/config/locales-glitch/fi.yml
new file mode 100644
index 000000000..23c538b19
--- /dev/null
+++ b/config/locales-glitch/fi.yml
@@ -0,0 +1 @@
+fi:
diff --git a/config/locales-glitch/fo.yml b/config/locales-glitch/fo.yml
new file mode 100644
index 000000000..69f792cca
--- /dev/null
+++ b/config/locales-glitch/fo.yml
@@ -0,0 +1 @@
+fo:
diff --git a/config/locales-glitch/fr-QC.yml b/config/locales-glitch/fr-QC.yml
new file mode 100644
index 000000000..0cba194f5
--- /dev/null
+++ b/config/locales-glitch/fr-QC.yml
@@ -0,0 +1,42 @@
+---
+fr-QC:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'Une erreur est survenue lors de la copie de certains des émojis sélectionnés : %{message}'
+      batch_error: 'Une erreur est survenue : %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: Ceci se base sur des scripts externes venant de hCaptcha, ce qui peut engendrer des soucis de sécurité et de confidentialité. De plus, <strong>cela peut rendre l'inscription beaucoup moins accessible pour certaines personnes (comme les personnes handicapées)</strong>. Pour ces raisons, veuillez préférer des mesures alternatives telles que l'inscription sur acceptation ou invitation. <br>Les utilisateurs qui ont été invités via une invitation à usage limité n'auront pas à résoudre un CAPTCHA
+        title: Obliger les nouveaux utilisateurs à résoudre un CAPTCHA pour vérifier leur compte
+      flavour_and_skin:
+        title: Apparence et thèmes
+      hide_followers_count:
+        desc_html: Ne pas afficher le nombre d'abonné·e·s sur les profils des utilisateurs
+        title: Cacher le nombre d'abonné·e·s
+      other:
+        preamble: Divers autres paramètres de glitch-soc.
+        title: Autres
+      outgoing_spoilers:
+        desc_html: Ajouter un avertissement de contenu à tous les messages lorsqu'ils sont fédérés s'ils n'en possèdent pas déjà. Cela peut être utile si votre serveur est spécialisé dans un type de contenu sur lequel les autres serveurs pourraient vouloir un Avertissement de Contenu. Les médias seront également marqués comme sensibles.
+        title: Avertissement de contenu pour les messages sortants
+      show_reblogs_in_public_timelines:
+        desc_html: Afficher les partages publics de posts publics dans le fil local et global.
+        title: Afficher les partages dans les fils publics
+      show_replies_in_public_timelines:
+        desc_html: En plus des réponses à soi-même (threads), afficher les réponses publiques dans le fil local et global.
+        title: Afficher les réponses dans les fils publics
+      trending_status_cw:
+        desc_html: Quand les posts en tendance sont activés, permettre aux posts avec des avertissements de contenu (CW) d'être éligibles. Les changements effectués sur ce paramètre ne sont pas rétroactifs.
+        title: Autoriser les posts avec des avertissements de contenu à être en tendances
+  appearance:
+    localization:
+      glitch_guide_link: https://fr.crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Et c'est pareil avec glitch-soc !
+  auth:
+    captcha_confirmation:
+      hint_html: Plus qu'une étape ! Pour vérifier votre compte sur ce serveur, vous devez résoudre un CAPTCHA. Vous pouvez <a href="/about/more">contacter l'administrateur·ice du serveur</a> si vous avez des questions ou besoin d'assistance dans la vérification de votre compte.
+      title: Vérification de l'utilisateur
+  generic:
+    use_this: Utiliser ceci
+  settings:
+    flavours: Thèmes
diff --git a/config/locales-glitch/fr.yml b/config/locales-glitch/fr.yml
new file mode 100644
index 000000000..15c3f8ce5
--- /dev/null
+++ b/config/locales-glitch/fr.yml
@@ -0,0 +1,42 @@
+---
+fr:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'Une erreur est survenue lors de la copie de certains des émojis sélectionnés : %{message}'
+      batch_error: 'Une erreur est survenue : %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: Ceci se base sur des scripts externes venant de hCaptcha, ce qui peut engendrer des soucis de sécurité et de confidentialité. De plus, <strong>cela peut rendre l'inscription beaucoup moins accessible pour certaines personnes (comme les personnes handicapées)</strong>. Pour ces raisons, veuillez préférer des mesures alternatives telles que l'inscription sur acceptation ou invitation. <br>Les utilisateurs qui ont été invités via une invitation à usage limité n'auront pas à résoudre un CAPTCHA
+        title: Obliger les nouveaux utilisateurs à résoudre un CAPTCHA pour vérifier leur compte
+      flavour_and_skin:
+        title: Apparence et thèmes
+      hide_followers_count:
+        desc_html: Ne pas afficher le nombre d'abonné·e·s sur les profils des utilisateurs
+        title: Cacher le nombre d'abonné·e·s
+      other:
+        preamble: Divers autres paramètres de glitch-soc.
+        title: Autres
+      outgoing_spoilers:
+        desc_html: Ajouter un avertissement de contenu à tous les messages lorsqu'ils sont fédérés s'ils n'en possèdent pas déjà. Cela peut être utile si votre serveur est spécialisé dans un type de contenu sur lequel les autres serveurs pourraient vouloir un Avertissement de Contenu. Les médias seront également marqués comme sensibles.
+        title: Avertissement de contenu pour les messages sortants
+      show_reblogs_in_public_timelines:
+        desc_html: Afficher les partages publics de posts publics dans le fil local et global.
+        title: Afficher les partages dans les fils publics
+      show_replies_in_public_timelines:
+        desc_html: En plus des réponses à soi-même (threads), afficher les réponses publiques dans le fil local et global.
+        title: Afficher les réponses dans les fils publics
+      trending_status_cw:
+        desc_html: Quand les posts en tendance sont activés, permettre aux posts avec des avertissements de contenu (CW) d'être éligibles. Les changements effectués sur ce paramètre ne sont pas rétroactifs.
+        title: Autoriser les posts avec des avertissements de contenu à être en tendances
+  appearance:
+    localization:
+      glitch_guide_link: https://fr.crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Et c'est pareil avec glitch-soc !
+  auth:
+    captcha_confirmation:
+      hint_html: Plus qu'une étape ! Pour vérifier votre compte sur ce serveur, vous devez résoudre un CAPTCHA. Vous pouvez <a href="/about/more">contacter l'administrateur·ice du serveur</a> si vous avez des questions ou besoin d'assistance dans la vérification de votre compte.
+      title: Vérification de l'utilisateur
+  generic:
+    use_this: Utiliser ceci
+  settings:
+    flavours: Thèmes
diff --git a/config/locales-glitch/fy.yml b/config/locales-glitch/fy.yml
new file mode 100644
index 000000000..c05849f20
--- /dev/null
+++ b/config/locales-glitch/fy.yml
@@ -0,0 +1 @@
+fy:
diff --git a/config/locales-glitch/ga.yml b/config/locales-glitch/ga.yml
new file mode 100644
index 000000000..20a9da24e
--- /dev/null
+++ b/config/locales-glitch/ga.yml
@@ -0,0 +1 @@
+ga:
diff --git a/config/locales-glitch/gd.yml b/config/locales-glitch/gd.yml
new file mode 100644
index 000000000..1912f6c6a
--- /dev/null
+++ b/config/locales-glitch/gd.yml
@@ -0,0 +1 @@
+gd:
diff --git a/config/locales-glitch/gl.yml b/config/locales-glitch/gl.yml
new file mode 100644
index 000000000..8ec5fc81c
--- /dev/null
+++ b/config/locales-glitch/gl.yml
@@ -0,0 +1 @@
+gl:
diff --git a/config/locales-glitch/he.yml b/config/locales-glitch/he.yml
new file mode 100644
index 000000000..af6fa60a7
--- /dev/null
+++ b/config/locales-glitch/he.yml
@@ -0,0 +1 @@
+he:
diff --git a/config/locales-glitch/hi.yml b/config/locales-glitch/hi.yml
new file mode 100644
index 000000000..d758a5b53
--- /dev/null
+++ b/config/locales-glitch/hi.yml
@@ -0,0 +1 @@
+hi:
diff --git a/config/locales-glitch/hr.yml b/config/locales-glitch/hr.yml
new file mode 100644
index 000000000..f67f33c7e
--- /dev/null
+++ b/config/locales-glitch/hr.yml
@@ -0,0 +1 @@
+hr:
diff --git a/config/locales-glitch/hu.yml b/config/locales-glitch/hu.yml
new file mode 100644
index 000000000..52314c50c
--- /dev/null
+++ b/config/locales-glitch/hu.yml
@@ -0,0 +1 @@
+hu:
diff --git a/config/locales-glitch/hy.yml b/config/locales-glitch/hy.yml
new file mode 100644
index 000000000..c40654016
--- /dev/null
+++ b/config/locales-glitch/hy.yml
@@ -0,0 +1 @@
+hy:
diff --git a/config/locales-glitch/id.yml b/config/locales-glitch/id.yml
new file mode 100644
index 000000000..8446cbad9
--- /dev/null
+++ b/config/locales-glitch/id.yml
@@ -0,0 +1 @@
+id:
diff --git a/config/locales-glitch/ig.yml b/config/locales-glitch/ig.yml
new file mode 100644
index 000000000..7c264f0d7
--- /dev/null
+++ b/config/locales-glitch/ig.yml
@@ -0,0 +1 @@
+ig:
diff --git a/config/locales-glitch/io.yml b/config/locales-glitch/io.yml
new file mode 100644
index 000000000..c63dc0e8d
--- /dev/null
+++ b/config/locales-glitch/io.yml
@@ -0,0 +1 @@
+io:
diff --git a/config/locales-glitch/is.yml b/config/locales-glitch/is.yml
new file mode 100644
index 000000000..337c106df
--- /dev/null
+++ b/config/locales-glitch/is.yml
@@ -0,0 +1 @@
+is:
diff --git a/config/locales-glitch/it.yml b/config/locales-glitch/it.yml
new file mode 100644
index 000000000..85830635a
--- /dev/null
+++ b/config/locales-glitch/it.yml
@@ -0,0 +1 @@
+it:
diff --git a/config/locales-glitch/ja.yml b/config/locales-glitch/ja.yml
new file mode 100644
index 000000000..54ebfaeca
--- /dev/null
+++ b/config/locales-glitch/ja.yml
@@ -0,0 +1,20 @@
+---
+ja:
+  admin:
+    settings:
+      hide_followers_count:
+        desc_html: プロフィールページのフォロワー数を見られないようにします
+        title: フォロワー数を隠す
+      outgoing_spoilers:
+        desc_html: トゥートが連合される際、閲覧注意としてマークされていないトゥートにこの警告が追加されます。これはあなたのインスタンスが他のインスタンスに警告をして欲しいとされる投稿に特化している場合に便利です。 メディアは閲覧注意にマークされます。
+        title: 発信するトゥートへの警告
+      show_reblogs_in_public_timelines:
+        desc_html: ローカルタイムラインと連合タイムラインに公開投稿のブーストを表示します
+        title: 公開タイムラインにブーストを表示
+      show_replies_in_public_timelines:
+        desc_html: 自分への公開投稿の返信に加えて、すべての公開投稿の返信をローカルタイムラインと連合タイムラインに表示します。
+        title: 公開タイムラインに返信を表示
+  generic:
+    use_this: これを使う
+  settings:
+    flavours: フレーバー
diff --git a/config/locales-glitch/ka.yml b/config/locales-glitch/ka.yml
new file mode 100644
index 000000000..57a95cb04
--- /dev/null
+++ b/config/locales-glitch/ka.yml
@@ -0,0 +1 @@
+ka:
diff --git a/config/locales-glitch/kab.yml b/config/locales-glitch/kab.yml
new file mode 100644
index 000000000..2109c04b3
--- /dev/null
+++ b/config/locales-glitch/kab.yml
@@ -0,0 +1 @@
+kab:
diff --git a/config/locales-glitch/kk.yml b/config/locales-glitch/kk.yml
new file mode 100644
index 000000000..1dcc9b127
--- /dev/null
+++ b/config/locales-glitch/kk.yml
@@ -0,0 +1 @@
+kk:
diff --git a/config/locales-glitch/kn.yml b/config/locales-glitch/kn.yml
new file mode 100644
index 000000000..d094088d8
--- /dev/null
+++ b/config/locales-glitch/kn.yml
@@ -0,0 +1 @@
+kn:
diff --git a/config/locales-glitch/ko.yml b/config/locales-glitch/ko.yml
new file mode 100644
index 000000000..dd8da3792
--- /dev/null
+++ b/config/locales-glitch/ko.yml
@@ -0,0 +1,42 @@
+---
+ko:
+  admin:
+    custom_emojis:
+      batch_copy_error: '선택된 에모지를 복사하던 중 오류가 발생했습니다: %{message}'
+      batch_error: '에러가 발생했습니다: %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: 이것은 hCaptcha의 외부 스크립트에 의존합니다, 이것은 개인정보 보호에 위협을 가할 수도 있습니다. 추가적으로, <strong>이것은 몇몇 사람들(특히나 장애인들)에게 가입 절차의 접근성을 심각하게 떨어트릴 수 있습니다</strong>. 이러한 이유로, 대체제로 승인 전용이나 초대제를 통한 가입을 고려해보세요.<br>한정된 사용만 가능한 초대장을 통한 가입자들은 CAPTCHA를 풀지 않아도 됩니다
+        title: 새로운 사용자가 계정 확인을 위해서는 CAPTCHA를 풀어야 하도록 합니다
+      flavour_and_skin:
+        title: 풍미와 스킨
+      hide_followers_count:
+        desc_html: 사용자 프로필에 팔로워 수를 표시하지 않습니다
+        title: 팔로워 수 숨기기
+      other:
+        preamble: 다른 곳에 맞지 않는 다양한 글리치 전용 설정들.
+        title: 기타
+      outgoing_spoilers:
+        desc_html: 툿을 연합할 때 열람주의가 없다면 여기 적힌 열람주의를 추가합니다. 다른 서버에서 열람주의를 설정하기를 요하는 주제에 특화된 서버라면 유용합니다. 미디어 또한 민감함으로 설정됩니다.
+        title: 나가는 툿에 대한 열람주의
+      show_reblogs_in_public_timelines:
+        desc_html: 공개글의 공개적인 부스트를 로컬과 공개 타임라인에 표시합니다.
+        title: 부스트를 공개 타임라인에 표시
+      show_replies_in_public_timelines:
+        desc_html: 자기자신에 대한 답글(글타래)와 마찬가지로, 공개적인 답글을 로컬과 공개 타임라인에 표시합니다.
+        title: 답글을 공개 타임라인에 표시
+      trending_status_cw:
+        desc_html: 유행하는 게시물이 활성화 되었을 때, 열람주의가 설정된 글도 허용합니다. 이 설정의 변경은 소급 적용되지 않습니다.
+        title: 열람주의를 가진 글이 유행에 오를 수 있도록 허용
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: 글리치도 마찬가지입니다!
+  auth:
+    captcha_confirmation:
+      hint_html: 한가지 단계가 남았습니다! 계정을 확인하기 위해서는, CAPTCHA를 풀어야 합니다. 질문이 있거나 계정 확인 과정에서 도움을 받고 싶은 경우 <a href="/about/more">서버의 관리자에게 연락해서</a> 해결할 수 있습니다.
+      title: 사용자 확인
+  generic:
+    use_this: 사용하기
+  settings:
+    flavours: 풍미
diff --git a/config/locales-glitch/ku.yml b/config/locales-glitch/ku.yml
new file mode 100644
index 000000000..b36f7c988
--- /dev/null
+++ b/config/locales-glitch/ku.yml
@@ -0,0 +1 @@
+ku:
diff --git a/config/locales-glitch/kw.yml b/config/locales-glitch/kw.yml
new file mode 100644
index 000000000..b2cfc12ff
--- /dev/null
+++ b/config/locales-glitch/kw.yml
@@ -0,0 +1 @@
+kw:
diff --git a/config/locales-glitch/la.yml b/config/locales-glitch/la.yml
new file mode 100644
index 000000000..3a7ba0d44
--- /dev/null
+++ b/config/locales-glitch/la.yml
@@ -0,0 +1 @@
+la:
diff --git a/config/locales-glitch/lt.yml b/config/locales-glitch/lt.yml
new file mode 100644
index 000000000..6c5cb837a
--- /dev/null
+++ b/config/locales-glitch/lt.yml
@@ -0,0 +1 @@
+lt:
diff --git a/config/locales-glitch/lv.yml b/config/locales-glitch/lv.yml
new file mode 100644
index 000000000..1be0eabc0
--- /dev/null
+++ b/config/locales-glitch/lv.yml
@@ -0,0 +1 @@
+lv:
diff --git a/config/locales-glitch/mk.yml b/config/locales-glitch/mk.yml
new file mode 100644
index 000000000..8b9144a98
--- /dev/null
+++ b/config/locales-glitch/mk.yml
@@ -0,0 +1 @@
+mk:
diff --git a/config/locales-glitch/ml.yml b/config/locales-glitch/ml.yml
new file mode 100644
index 000000000..6931a683d
--- /dev/null
+++ b/config/locales-glitch/ml.yml
@@ -0,0 +1 @@
+ml:
diff --git a/config/locales-glitch/mr.yml b/config/locales-glitch/mr.yml
new file mode 100644
index 000000000..fe1639c6a
--- /dev/null
+++ b/config/locales-glitch/mr.yml
@@ -0,0 +1 @@
+mr:
diff --git a/config/locales-glitch/ms.yml b/config/locales-glitch/ms.yml
new file mode 100644
index 000000000..2925688a0
--- /dev/null
+++ b/config/locales-glitch/ms.yml
@@ -0,0 +1 @@
+ms:
diff --git a/config/locales-glitch/my.yml b/config/locales-glitch/my.yml
new file mode 100644
index 000000000..5e1fc6bee
--- /dev/null
+++ b/config/locales-glitch/my.yml
@@ -0,0 +1 @@
+my:
diff --git a/config/locales-glitch/nl.yml b/config/locales-glitch/nl.yml
new file mode 100644
index 000000000..f009eadee
--- /dev/null
+++ b/config/locales-glitch/nl.yml
@@ -0,0 +1 @@
+nl:
diff --git a/config/locales-glitch/nn.yml b/config/locales-glitch/nn.yml
new file mode 100644
index 000000000..777f4e600
--- /dev/null
+++ b/config/locales-glitch/nn.yml
@@ -0,0 +1 @@
+nn:
diff --git a/config/locales-glitch/no.yml b/config/locales-glitch/no.yml
new file mode 100644
index 000000000..1dcec34b6
--- /dev/null
+++ b/config/locales-glitch/no.yml
@@ -0,0 +1,2 @@
+---
+'no':
diff --git a/config/locales-glitch/oc.yml b/config/locales-glitch/oc.yml
new file mode 100644
index 000000000..325b34889
--- /dev/null
+++ b/config/locales-glitch/oc.yml
@@ -0,0 +1 @@
+oc:
diff --git a/config/locales-glitch/pa.yml b/config/locales-glitch/pa.yml
new file mode 100644
index 000000000..bb8a6c834
--- /dev/null
+++ b/config/locales-glitch/pa.yml
@@ -0,0 +1 @@
+pa:
diff --git a/config/locales-glitch/pl.yml b/config/locales-glitch/pl.yml
new file mode 100644
index 000000000..33f947b89
--- /dev/null
+++ b/config/locales-glitch/pl.yml
@@ -0,0 +1,42 @@
+---
+pl:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'Podczas kopiowania niektórych zaznaczonych emotikon wystąpił następujący problem: %{message}'
+      batch_error: 'Wystąpił problem: %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: Wymaga użycia zewnętrznych skryptów hCaptcha, co może negatywnie wpływać na bezpieczeństwo i prywatność. <strong>Może również przyczynić się do znaczącego utrudnienia procesu rejestracji niektórym, np. niepełnosprawnym, osobom.</strong> Dlatego sugeruje się używanie zaproszeń bądź ręcznie potwierdzanie kont.<br>Użytkownicy rejestrujący się za pomocą limitowanych zaproszeń nie będą musieli rozwiązywać zadania CHAPTCHA
+        title: W celu potwierdzenia ich kont wymagaj rozwiązania zadania CAPTCHA przez nowych użytkowników
+      flavour_and_skin:
+        title: Odmiana i motyw
+      hide_followers_count:
+        desc_html: Nie pokazuj liczby obserwujących w profilach użytkowników.
+        title: Ukryj liczbę obserwujących
+      other:
+        preamble: Ustawienia glitch-soc niepasujące do innych kategorii.
+        title: Inne
+      outgoing_spoilers:
+        desc_html: Podczas wysyłania wpisów do innych serwerów ukryj ich treść za tym ostrzeżeniem a załączniki oznacz jako wrażliwe. Ta opcja jest dedykowana serwerom specjalizującym się w tematyce, która przez innych może traktowana jako wymagająca ostrzeżenia.
+        title: Ostrzeżenie o zawartości dla wpisów wysyłanych do innych serwerów
+      show_reblogs_in_public_timelines:
+        desc_html: Pokaż publiczne podbicia publicznych wpisów w lokalnych i publicznych osiach czasu.
+        title: Pokaż podbicia w publicznych osiach czasu
+      show_replies_in_public_timelines:
+        desc_html: Oprócz odpowiedzi tworzących wątki pokazuj publiczne odpowiedzi w lokalnych i publicznych osiach czasu.
+        title: Pokaż odpowiedzi w publicznych osiach czasu
+      trending_status_cw:
+        desc_html: Pozwól ukrytym za ostrzeżeniem wpisom na włączenie do sekcji „Popularne teraz”, jeżeli jest ona włączona. Zmiana tego ustawienia nie wpłynie na już opublikowane wpisy.
+        title: Pozwól ukrytym za ostrzeżeniem wpisom na włączenie do sekcji „Popularne teraz”
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Również w wypadku glitch-soc.
+  auth:
+    captcha_confirmation:
+      hint_html: Aby potwierdzić Twoje konto, ten serwer wymaga rozwiązania przez Ciebie zadania CAPTCHA. Możesz <a href="/about/more">skontaktować się z administratorem</a>, jeśli masz jakieś pytania lub potrzebujesz pomocy.
+      title: Weryfikacja użytkownika
+  generic:
+    use_this: Użyj tego
+  settings:
+    flavours: Odmiany
diff --git a/config/locales-glitch/pt-BR.yml b/config/locales-glitch/pt-BR.yml
new file mode 100644
index 000000000..95c1579ba
--- /dev/null
+++ b/config/locales-glitch/pt-BR.yml
@@ -0,0 +1,42 @@
+---
+pt-BR:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'Ocorreu um erro ao copiar alguns dos emojis selecionados: %{message}'
+      batch_error: 'Ocorreu um erro: %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: Isto se baseia em scripts externos de hCaptcha, o que pode ser uma preocupação de segurança e privacidade. Além disso, <strong>isto pode tornar o processo de registro significativamente menos acessível para algumas pessoas (especialmente deficientes)</strong>. Por estas razões, favor considerar medidas alternativas como o registro baseado em aprovação ou em convite.<br>Os usuários que tiverem sido convidados através de um convite de uso limitado não precisarão resolver um CAPTCHA
+        title: Exigir que novos usuários resolvam um CAPTCHA para confirmar sua conta
+      flavour_and_skin:
+        title: Sabor e tema
+      hide_followers_count:
+        desc_html: Não mostrar contagem de seguidores em perfis de usuário
+        title: Ocultar número de seguidores
+      other:
+        preamble: Várias configurações de glitch-soc que não se ajustam em outras categorias.
+        title: Outros
+      outgoing_spoilers:
+        desc_html: Ao federar toots, adicione este aviso de conteúdo aos toots que não possuem um. É útil se seu servidor for especializado em conteúdo que outros servidores podem querer ter sob um Aviso de Conteúdo. Os meios de comunicação também serão marcados como sensíveis.
+        title: Aviso de conteúdo para toots enviados
+      show_reblogs_in_public_timelines:
+        desc_html: Mostrar impulsos públicos de toots públicos nas linhas de tempo locais e públicas.
+        title: Mostrar impulsos em timelines públicas
+      show_replies_in_public_timelines:
+        desc_html: Além das auto-respostas públicas (tópicos), mostrar respostas públicas em linhas do tempo locais e públicas.
+        title: Mostrar respostas em linhas do tempo públicas
+      trending_status_cw:
+        desc_html: Quando as mensagens de tendência estiverem habilitadas, permitir que as mensagens com Avisos de Conteúdo sejam elegíveis. As alterações a esta configuração não são retroativas.
+        title: Permitir que mensagens com Avisos de Conteúdo tornem-se tendência
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: E também para glitch-soc!
+  auth:
+    captcha_confirmation:
+      hint_html: Só mais um passo! Para confirmar a sua conta, este servidor requer que você resolva um CAPTCHA. Você pode <a href="/about/more">entrar em contato com o administrador do servidor</a> se você tiver dúvidas ou precisa de ajuda para confirmar sua conta.
+      title: Verificação de usuário
+  generic:
+    use_this: Use isto
+  settings:
+    flavours: Sabores
diff --git a/config/locales-glitch/pt-PT.yml b/config/locales-glitch/pt-PT.yml
new file mode 100644
index 000000000..18e41a056
--- /dev/null
+++ b/config/locales-glitch/pt-PT.yml
@@ -0,0 +1,42 @@
+---
+pt-PT:
+  admin:
+    custom_emojis:
+      batch_copy_error: 'Houve um erro ao copiar alguns dos emoji selecionados: %{message}'
+      batch_error: 'Houve um erro: %{message}'
+    settings:
+      captcha_enabled:
+        desc_html: Isto depende de scripts externos da hCaptcha, o que pode ser uma preocupação de segurança e privacidade. Além disso, <strong>isto pode tornar o processo de registo menos acessível para algumas pessoas (especialmente as com limitações físicas)</strong>. Por isto, considera medidas alternativas tais como registo mediante aprovação ou sob convite.<br>Pessoas que se registam com um convite não precisam de resolver um CAPTCHA
+        title: Exigir que novas contas resolvam um CAPTCHA para validar o registo
+      flavour_and_skin:
+        title: Sabor e tema
+      hide_followers_count:
+        desc_html: Não mostrar o número de seguidores nos perfis
+        title: Esconder o número de seguidores
+      other:
+        preamble: Várias opções do glitch-soc que não cabem noutras categorias.
+        title: Outras opções
+      outgoing_spoilers:
+        desc_html: Ao federar toots, juntar este aviso de conteúdo aos toots que não têm aviso. Isto é útil se o teu servidor for especializado em conteúdos que outros servidores podem querer ter sob um Aviso de Conteúdo. Os media também vão ser marcados como sensíveis.
+        title: Aviso de conteúdo para toots enviados
+      show_reblogs_in_public_timelines:
+        desc_html: Mostrar boosts públicos de toots públicos nas linhas de tempo locais e públicas.
+        title: Mostrar boosts nas timelines públicas
+      show_replies_in_public_timelines:
+        desc_html: Além de auto-respostas públicas (fios), mostrar as respostas públicas em linhas de tempo locais e públicas.
+        title: Mostrar respostas nas linhas de tempo públicas
+      trending_status_cw:
+        desc_html: Quando os posts em tendência estão ativados, permitir que posts com Avisos de Conteúdo também possam aparecer. As alterações nesta opção não são retroativas.
+        title: Permitir que posts com Avisos de Conteúdo possam aparecer nos posts em tendência
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: E também para o glitch-soc!
+  auth:
+    captcha_confirmation:
+      hint_html: Só mais um passo! Para confirmares a tua conta, este servidor exige que resolvas um CAPTCHA. Podes <a href="/about/more">entrar em contacto com o administrador do servidor</a> se tiveres dúvidas ou precisares de ajuda.
+      title: Verificação
+  generic:
+    use_this: Usar isto
+  settings:
+    flavours: Sabores
diff --git a/config/locales-glitch/ro.yml b/config/locales-glitch/ro.yml
new file mode 100644
index 000000000..79dbaa871
--- /dev/null
+++ b/config/locales-glitch/ro.yml
@@ -0,0 +1 @@
+ro:
diff --git a/config/locales-glitch/ru.yml b/config/locales-glitch/ru.yml
new file mode 100644
index 000000000..ddc9d1e32
--- /dev/null
+++ b/config/locales-glitch/ru.yml
@@ -0,0 +1 @@
+ru:
diff --git a/config/locales-glitch/sa.yml b/config/locales-glitch/sa.yml
new file mode 100644
index 000000000..07ea4372a
--- /dev/null
+++ b/config/locales-glitch/sa.yml
@@ -0,0 +1 @@
+sa:
diff --git a/config/locales-glitch/sc.yml b/config/locales-glitch/sc.yml
new file mode 100644
index 000000000..91bd6d92f
--- /dev/null
+++ b/config/locales-glitch/sc.yml
@@ -0,0 +1 @@
+sc:
diff --git a/config/locales-glitch/sco.yml b/config/locales-glitch/sco.yml
new file mode 100644
index 000000000..8165e00a1
--- /dev/null
+++ b/config/locales-glitch/sco.yml
@@ -0,0 +1 @@
+sco:
diff --git a/config/locales-glitch/si.yml b/config/locales-glitch/si.yml
new file mode 100644
index 000000000..b0b50956e
--- /dev/null
+++ b/config/locales-glitch/si.yml
@@ -0,0 +1 @@
+si:
diff --git a/config/locales-glitch/simple_form.af.yml b/config/locales-glitch/simple_form.af.yml
new file mode 100644
index 000000000..252f9fd5a
--- /dev/null
+++ b/config/locales-glitch/simple_form.af.yml
@@ -0,0 +1 @@
+af:
diff --git a/config/locales-glitch/simple_form.an.yml b/config/locales-glitch/simple_form.an.yml
new file mode 100644
index 000000000..76cc0689b
--- /dev/null
+++ b/config/locales-glitch/simple_form.an.yml
@@ -0,0 +1 @@
+an:
diff --git a/config/locales-glitch/simple_form.ar.yml b/config/locales-glitch/simple_form.ar.yml
new file mode 100644
index 000000000..c257bc08a
--- /dev/null
+++ b/config/locales-glitch/simple_form.ar.yml
@@ -0,0 +1 @@
+ar:
diff --git a/config/locales-glitch/simple_form.ast.yml b/config/locales-glitch/simple_form.ast.yml
new file mode 100644
index 000000000..d762c9399
--- /dev/null
+++ b/config/locales-glitch/simple_form.ast.yml
@@ -0,0 +1 @@
+ast:
diff --git a/config/locales-glitch/simple_form.be.yml b/config/locales-glitch/simple_form.be.yml
new file mode 100644
index 000000000..91ccc2d7e
--- /dev/null
+++ b/config/locales-glitch/simple_form.be.yml
@@ -0,0 +1 @@
+be:
diff --git a/config/locales-glitch/simple_form.bg.yml b/config/locales-glitch/simple_form.bg.yml
new file mode 100644
index 000000000..d0e375da9
--- /dev/null
+++ b/config/locales-glitch/simple_form.bg.yml
@@ -0,0 +1 @@
+bg:
diff --git a/config/locales-glitch/simple_form.bn.yml b/config/locales-glitch/simple_form.bn.yml
new file mode 100644
index 000000000..152c69829
--- /dev/null
+++ b/config/locales-glitch/simple_form.bn.yml
@@ -0,0 +1 @@
+bn:
diff --git a/config/locales-glitch/simple_form.br.yml b/config/locales-glitch/simple_form.br.yml
new file mode 100644
index 000000000..c7677c850
--- /dev/null
+++ b/config/locales-glitch/simple_form.br.yml
@@ -0,0 +1 @@
+br:
diff --git a/config/locales-glitch/simple_form.bs.yml b/config/locales-glitch/simple_form.bs.yml
new file mode 100644
index 000000000..e9e174462
--- /dev/null
+++ b/config/locales-glitch/simple_form.bs.yml
@@ -0,0 +1 @@
+bs:
diff --git a/config/locales-glitch/simple_form.ca.yml b/config/locales-glitch/simple_form.ca.yml
new file mode 100644
index 000000000..f0c487273
--- /dev/null
+++ b/config/locales-glitch/simple_form.ca.yml
@@ -0,0 +1 @@
+ca:
diff --git a/config/locales-glitch/simple_form.ckb.yml b/config/locales-glitch/simple_form.ckb.yml
new file mode 100644
index 000000000..77d538af7
--- /dev/null
+++ b/config/locales-glitch/simple_form.ckb.yml
@@ -0,0 +1 @@
+ckb:
diff --git a/config/locales-glitch/simple_form.co.yml b/config/locales-glitch/simple_form.co.yml
new file mode 100644
index 000000000..5330938e0
--- /dev/null
+++ b/config/locales-glitch/simple_form.co.yml
@@ -0,0 +1 @@
+co:
diff --git a/config/locales-glitch/simple_form.cs.yml b/config/locales-glitch/simple_form.cs.yml
new file mode 100644
index 000000000..a0823d699
--- /dev/null
+++ b/config/locales-glitch/simple_form.cs.yml
@@ -0,0 +1,24 @@
+---
+cs:
+  simple_form:
+    hints:
+      defaults:
+        fields: Na svém profilu můžete mít zobrazeno několik položek (max. %{count}) jako tabulku
+        setting_default_content_type_html: Předpokládat, že nové příspěvky jsou napsané v HTML, pokud není uvedeno jinak
+        setting_default_content_type_markdown: Předpokládat, že nové příspěvky používají pro formátování Markdown, pokud není uvedeno jinak
+        setting_default_content_type_plain: Předpokládat, že nové příspěvky nejsou nijak formátované, pokud není uvedeno jinak (standardní chování Mastodonu)
+        setting_default_language: Jazyk vašich příspěvků lze detekovat automaticky, ale není to vždycky přesné
+        setting_hide_followers_count: Počet vašich sledujících se nebude nikomu zobrazovat, ani vám. Některé aplikace mohou zobrazit negativní počet sledujících.
+        setting_skin: Aplikuje barevný styl na zvolené rozhraní Mastodonu
+    labels:
+      defaults:
+        setting_default_content_type: Výchozí formát příspěvků
+        setting_default_content_type_plain: Prostý text
+        setting_favourite_modal: Před oblíbením příspěvku zobrazit potvrzovací dialog (pouze pro rozhraní Glitch)
+        setting_hide_followers_count: Skrýt počet vašich sledujících
+        setting_skin: Styl
+        setting_system_emoji_font: Použít výchozí emoji systému (pouze pro rozhraní Glitch)
+      notification_emails:
+        trending_link: Nový populární odkaz vyžaduje schválení
+        trending_status: Nový populární příspěvek vyžaduje schválení
+        trending_tag: Nový populární hashtag vyžaduje schválení
diff --git a/config/locales-glitch/simple_form.cy.yml b/config/locales-glitch/simple_form.cy.yml
new file mode 100644
index 000000000..deefc9438
--- /dev/null
+++ b/config/locales-glitch/simple_form.cy.yml
@@ -0,0 +1 @@
+cy:
diff --git a/config/locales-glitch/simple_form.da.yml b/config/locales-glitch/simple_form.da.yml
new file mode 100644
index 000000000..347c94d5e
--- /dev/null
+++ b/config/locales-glitch/simple_form.da.yml
@@ -0,0 +1 @@
+da:
diff --git a/config/locales-glitch/simple_form.de.yml b/config/locales-glitch/simple_form.de.yml
new file mode 100644
index 000000000..0d92038c5
--- /dev/null
+++ b/config/locales-glitch/simple_form.de.yml
@@ -0,0 +1,27 @@
+---
+de:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Du kannst bis zu %{count} Elemente auf deinem Profil anzeigen lassen, die als Tabelle dargestellt werden
+        setting_default_content_type_html: Beim Schreiben von Toots annehmen, dass sie in rohem HTML geschrieben sind, sofern nicht anders angegeben
+        setting_default_content_type_markdown: Beim Schreiben von Toots annehmen, dass sie in Markdown für Rich-Text-Formatierung geschrieben sind, sofern nicht anders angegeben
+        setting_default_content_type_plain: Beim Schreiben von Toots annehmen, dass sie in Klartext ohne spezielle Formatierung geschrieben sind, sofern nicht anders angegeben (standardmäßiges Mastodon-Verhalten)
+        setting_default_language: Die Sprache deiner Toots kann automatisch erkannt werden, aber sie ist nicht immer korrekt
+        setting_hide_followers_count: Verberge deine Follower-Anzahl vor allen, einschließlich dir. Manche Anwendungen könnten eine negative Follower-Anzahl anzeigen.
+        setting_skin: Verändert die ausgewählte Mastodon-Variante
+    labels:
+      defaults:
+        setting_default_content_type: Standardformat für Toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Unformatierter Text
+        setting_favourite_modal: Bestätigungsdialog vor dem Favorisieren anzeigen (gilt nur für Glitch-Variante)
+        setting_hide_followers_count: Anzahl der Follower verbergen
+        setting_skin: Skin
+        setting_system_emoji_font: Systemschriftart für Emojis verwenden (nur für Glitch-Variante)
+      notification_emails:
+        trending_link: Neuer angesagter Link muss überprüft werden
+        trending_status: Neuer angesagter Post muss überprüft werden
+        trending_tag: Neuer angesagter Tag muss überprüft werden
diff --git a/config/locales-glitch/simple_form.el.yml b/config/locales-glitch/simple_form.el.yml
new file mode 100644
index 000000000..419ec705c
--- /dev/null
+++ b/config/locales-glitch/simple_form.el.yml
@@ -0,0 +1 @@
+el:
diff --git a/config/locales-glitch/simple_form.en-GB.yml b/config/locales-glitch/simple_form.en-GB.yml
new file mode 100644
index 000000000..ef03d1810
--- /dev/null
+++ b/config/locales-glitch/simple_form.en-GB.yml
@@ -0,0 +1 @@
+en-GB:
diff --git a/config/locales-glitch/simple_form.en.yml b/config/locales-glitch/simple_form.en.yml
new file mode 100644
index 000000000..6930c09a6
--- /dev/null
+++ b/config/locales-glitch/simple_form.en.yml
@@ -0,0 +1,27 @@
+---
+en:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: You can have up to %{count} items displayed as a table on your profile
+        setting_default_content_type_html: When writing toots, assume they are written in raw HTML, unless specified otherwise
+        setting_default_content_type_markdown: When writing toots, assume they are using Markdown for rich text formatting, unless specified otherwise
+        setting_default_content_type_plain: When writing toots, assume they are plain text with no special formatting, unless specified otherwise (default Mastodon behavior)
+        setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
+        setting_hide_followers_count: Hide your followers count from everybody, including you. Some applications may display a negative followers count.
+        setting_skin: Reskins the selected Mastodon flavour
+    labels:
+      defaults:
+        setting_default_content_type: Default format for toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Plain text
+        setting_favourite_modal: Show confirmation dialog before favouriting (applies to Glitch flavour only)
+        setting_hide_followers_count: Hide your followers count
+        setting_skin: Skin
+        setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only)
+      notification_emails:
+        trending_link: New trending link requires review
+        trending_status: New trending post requires review
+        trending_tag: New trending tag requires review
diff --git a/config/locales-glitch/simple_form.eo.yml b/config/locales-glitch/simple_form.eo.yml
new file mode 100644
index 000000000..759981404
--- /dev/null
+++ b/config/locales-glitch/simple_form.eo.yml
@@ -0,0 +1 @@
+eo:
diff --git a/config/locales-glitch/simple_form.es-AR.yml b/config/locales-glitch/simple_form.es-AR.yml
new file mode 100644
index 000000000..76529e4e3
--- /dev/null
+++ b/config/locales-glitch/simple_form.es-AR.yml
@@ -0,0 +1,27 @@
+---
+es-AR:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Puedes tener hasta %{count} elementos mostrados como una tabla en tu perfil
+        setting_default_content_type_html: Al escribir toots, asume que estás escritos en HTML, a menos que se especifique lo contrario
+        setting_default_content_type_markdown: Al escribir toots, asume que estás usando Markdown para dar formato de texto enriquecido, a menos que se especifique lo contrario
+        setting_default_content_type_plain: Al escribir toots, asume que estás usando texto sin formato, a menos que se especifique lo contrario (predeterminado de Mastodon)
+        setting_default_language: El idioma de tus toots se puede detectar automáticamente, pero no siempre es correcto
+        setting_hide_followers_count: Ocultar el conteo de seguidores de todos, incluyendo tú. Algunas aplicaciones pueden mostrar un conteo de seguidores negativos.
+        setting_skin: Cambia el diseño de la edición seleccionada de Mastodon
+    labels:
+      defaults:
+        setting_default_content_type: Formato predeterminado de tus toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Sin formato
+        setting_favourite_modal: Mostrar diálogo de confirmación antes de marcar como favorito (sólo aplica a la edición Glich)
+        setting_hide_followers_count: Ocultar tu conteo de seguidorxs
+        setting_skin: Diseño
+        setting_system_emoji_font: Usar la fuente predeterminada del sistema para emojis (sólo aplica a la edición Glitch)
+      notification_emails:
+        trending_link: Un vínculo en tendencia requiere revisión
+        trending_status: Una publicación en tendencia requiere revisión
+        trending_tag: Una etiqueta en tendencia requiere revisión
diff --git a/config/locales-glitch/simple_form.es-MX.yml b/config/locales-glitch/simple_form.es-MX.yml
new file mode 100644
index 000000000..377f20a6e
--- /dev/null
+++ b/config/locales-glitch/simple_form.es-MX.yml
@@ -0,0 +1,27 @@
+---
+es-MX:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Puedes tener hasta %{count} elementos mostrados como una tabla en tu perfil
+        setting_default_content_type_html: Al escribir toots, asume que estás escritos en HTML, a menos que se especifique lo contrario
+        setting_default_content_type_markdown: Al escribir toots, asume que estás usando Markdown para dar formato de texto enriquecido, a menos que se especifique lo contrario
+        setting_default_content_type_plain: Al escribir toots, asume que estás usando texto sin formato, a menos que se especifique lo contrario (predeterminado de Mastodon)
+        setting_default_language: El idioma de tus toots se puede detectar automáticamente, pero no siempre es correcto
+        setting_hide_followers_count: Ocultar el conteo de seguidores de todos, incluyendo tú. Algunas aplicaciones pueden mostrar un conteo de seguidores negativos.
+        setting_skin: Cambia el diseño de la edición seleccionada de Mastodon
+    labels:
+      defaults:
+        setting_default_content_type: Formato predeterminado de tus toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Sin formato
+        setting_favourite_modal: Mostrar diálogo de confirmación antes de marcar como favorito (sólo aplica a la edición Glich)
+        setting_hide_followers_count: Ocultar tu conteo de seguidorxs
+        setting_skin: Diseño
+        setting_system_emoji_font: Usar la fuente predeterminada del sistema para emojis (sólo aplica a la edición Glitch)
+      notification_emails:
+        trending_link: Un vínculo en tendencia requiere revisión
+        trending_status: Una publicación en tendencia requiere revisión
+        trending_tag: Una etiqueta en tendencia requiere revisión
diff --git a/config/locales-glitch/simple_form.es.yml b/config/locales-glitch/simple_form.es.yml
new file mode 100644
index 000000000..6c16642c1
--- /dev/null
+++ b/config/locales-glitch/simple_form.es.yml
@@ -0,0 +1,27 @@
+---
+es:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Puedes tener hasta %{count} elementos mostrados como una tabla en tu perfil
+        setting_default_content_type_html: Al escribir toots, asume que estás escritos en HTML, a menos que se especifique lo contrario
+        setting_default_content_type_markdown: Al escribir toots, asume que estás usando Markdown para dar formato de texto enriquecido, a menos que se especifique lo contrario
+        setting_default_content_type_plain: Al escribir toots, asume que estás usando texto sin formato, a menos que se especifique lo contrario (predeterminado de Mastodon)
+        setting_default_language: El idioma de tus toots se puede detectar automáticamente, pero no siempre es correcto
+        setting_hide_followers_count: Ocultar el conteo de seguidores de todos, incluyendo tú. Algunas aplicaciones pueden mostrar un conteo de seguidores negativos.
+        setting_skin: Cambia el diseño de la edición seleccionada de Mastodon
+    labels:
+      defaults:
+        setting_default_content_type: Formato predeterminado de tus toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Sin formato
+        setting_favourite_modal: Mostrar diálogo de confirmación antes de marcar como favorito (sólo aplica a la edición Glich)
+        setting_hide_followers_count: Ocultar tu conteo de seguidorxs
+        setting_skin: Diseño
+        setting_system_emoji_font: Usar la fuente predeterminada del sistema para emojis (sólo aplica a la edición Glitch)
+      notification_emails:
+        trending_link: Un vínculo en tendencia requiere revisión
+        trending_status: Una publicación en tendencia requiere revisión
+        trending_tag: Una etiqueta en tendencia requiere revisión
diff --git a/config/locales-glitch/simple_form.et.yml b/config/locales-glitch/simple_form.et.yml
new file mode 100644
index 000000000..e020c4ffc
--- /dev/null
+++ b/config/locales-glitch/simple_form.et.yml
@@ -0,0 +1 @@
+et:
diff --git a/config/locales-glitch/simple_form.eu.yml b/config/locales-glitch/simple_form.eu.yml
new file mode 100644
index 000000000..566e176fc
--- /dev/null
+++ b/config/locales-glitch/simple_form.eu.yml
@@ -0,0 +1 @@
+eu:
diff --git a/config/locales-glitch/simple_form.fa.yml b/config/locales-glitch/simple_form.fa.yml
new file mode 100644
index 000000000..88215f82c
--- /dev/null
+++ b/config/locales-glitch/simple_form.fa.yml
@@ -0,0 +1 @@
+fa:
diff --git a/config/locales-glitch/simple_form.fi.yml b/config/locales-glitch/simple_form.fi.yml
new file mode 100644
index 000000000..23c538b19
--- /dev/null
+++ b/config/locales-glitch/simple_form.fi.yml
@@ -0,0 +1 @@
+fi:
diff --git a/config/locales-glitch/simple_form.fo.yml b/config/locales-glitch/simple_form.fo.yml
new file mode 100644
index 000000000..69f792cca
--- /dev/null
+++ b/config/locales-glitch/simple_form.fo.yml
@@ -0,0 +1 @@
+fo:
diff --git a/config/locales-glitch/simple_form.fr-QC.yml b/config/locales-glitch/simple_form.fr-QC.yml
new file mode 100644
index 000000000..70cade1f0
--- /dev/null
+++ b/config/locales-glitch/simple_form.fr-QC.yml
@@ -0,0 +1,27 @@
+---
+fr-QC:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Vous pouvez avoir jusqu'à %{count} éléments affichés en tant que tableau sur votre profil
+        setting_default_content_type_html: Vos posts sont écrits en HTML brut, sauf indication contraire
+        setting_default_content_type_markdown: Vos posts utilisent Markdown pour un formatage de texte enrichi, sauf indication contraire
+        setting_default_content_type_plain: Vos posts sont écrits en texte brut sans formatage spécial, sauf indication contraire (comportement par défaut de Mastodon)
+        setting_default_language: La langue utilisée pour vos posts peut être automatiquement détectée, cependant cette détection n'est pas toujours fiable
+        setting_hide_followers_count: Cacher votre nombre d'abonné·e·s à tout le monde, y compris vous. Certaines applications peuvent afficher un nombre négatif d'abonné·e·s.
+        setting_skin: Relooke le mode de Mastodon choisi
+    labels:
+      defaults:
+        setting_default_content_type: Format par défaut pour les posts
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Texte brut
+        setting_favourite_modal: Afficher une fenêtre de confirmation avant de mettre en favori un post (s'applique uniquement au mode Glitch)
+        setting_hide_followers_count: Cacher votre nombre d'abonné·e·s
+        setting_skin: Thème
+        setting_system_emoji_font: Utiliser la police par défaut du système pour les émojis (s'applique uniquement au mode Glitch)
+      notification_emails:
+        trending_link: Un nouveau lien en tendances nécessite un examen
+        trending_status: Un nouveau post en tendances nécessite un examen
+        trending_tag: Un nouveau tag en tendances nécessite un examen
diff --git a/config/locales-glitch/simple_form.fr.yml b/config/locales-glitch/simple_form.fr.yml
new file mode 100644
index 000000000..bc6302cf8
--- /dev/null
+++ b/config/locales-glitch/simple_form.fr.yml
@@ -0,0 +1,27 @@
+---
+fr:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Vous pouvez avoir jusqu'à %{count} éléments affichés en tant que tableau sur votre profil
+        setting_default_content_type_html: Vos posts sont écrits en HTML brut, sauf indication contraire
+        setting_default_content_type_markdown: Vos posts utilisent Markdown pour un formatage de texte enrichi, sauf indication contraire
+        setting_default_content_type_plain: Vos posts sont écrits en texte brut sans formatage spécial, sauf indication contraire (comportement par défaut de Mastodon)
+        setting_default_language: La langue utilisée pour vos posts peut être automatiquement détectée, cependant cette détection n'est pas toujours fiable
+        setting_hide_followers_count: Cacher votre nombre d'abonné·e·s à tout le monde, y compris vous. Certaines applications peuvent afficher un nombre négatif d'abonné·e·s.
+        setting_skin: Relooke le mode de Mastodon choisi
+    labels:
+      defaults:
+        setting_default_content_type: Format par défaut pour les posts
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Texte brut
+        setting_favourite_modal: Afficher une fenêtre de confirmation avant de mettre en favori un post (s'applique uniquement au mode Glitch)
+        setting_hide_followers_count: Cacher votre nombre d'abonné·e·s
+        setting_skin: Thème
+        setting_system_emoji_font: Utiliser la police par défaut du système pour les émojis (s'applique uniquement au mode Glitch)
+      notification_emails:
+        trending_link: Un nouveau lien en tendances nécessite un examen
+        trending_status: Un nouveau post en tendances nécessite un examen
+        trending_tag: Un nouveau tag en tendances nécessite un examen
diff --git a/config/locales-glitch/simple_form.fy.yml b/config/locales-glitch/simple_form.fy.yml
new file mode 100644
index 000000000..c05849f20
--- /dev/null
+++ b/config/locales-glitch/simple_form.fy.yml
@@ -0,0 +1 @@
+fy:
diff --git a/config/locales-glitch/simple_form.ga.yml b/config/locales-glitch/simple_form.ga.yml
new file mode 100644
index 000000000..20a9da24e
--- /dev/null
+++ b/config/locales-glitch/simple_form.ga.yml
@@ -0,0 +1 @@
+ga:
diff --git a/config/locales-glitch/simple_form.gd.yml b/config/locales-glitch/simple_form.gd.yml
new file mode 100644
index 000000000..1912f6c6a
--- /dev/null
+++ b/config/locales-glitch/simple_form.gd.yml
@@ -0,0 +1 @@
+gd:
diff --git a/config/locales-glitch/simple_form.gl.yml b/config/locales-glitch/simple_form.gl.yml
new file mode 100644
index 000000000..8ec5fc81c
--- /dev/null
+++ b/config/locales-glitch/simple_form.gl.yml
@@ -0,0 +1 @@
+gl:
diff --git a/config/locales-glitch/simple_form.he.yml b/config/locales-glitch/simple_form.he.yml
new file mode 100644
index 000000000..af6fa60a7
--- /dev/null
+++ b/config/locales-glitch/simple_form.he.yml
@@ -0,0 +1 @@
+he:
diff --git a/config/locales-glitch/simple_form.hi.yml b/config/locales-glitch/simple_form.hi.yml
new file mode 100644
index 000000000..d758a5b53
--- /dev/null
+++ b/config/locales-glitch/simple_form.hi.yml
@@ -0,0 +1 @@
+hi:
diff --git a/config/locales-glitch/simple_form.hr.yml b/config/locales-glitch/simple_form.hr.yml
new file mode 100644
index 000000000..f67f33c7e
--- /dev/null
+++ b/config/locales-glitch/simple_form.hr.yml
@@ -0,0 +1 @@
+hr:
diff --git a/config/locales-glitch/simple_form.hu.yml b/config/locales-glitch/simple_form.hu.yml
new file mode 100644
index 000000000..52314c50c
--- /dev/null
+++ b/config/locales-glitch/simple_form.hu.yml
@@ -0,0 +1 @@
+hu:
diff --git a/config/locales-glitch/simple_form.hy.yml b/config/locales-glitch/simple_form.hy.yml
new file mode 100644
index 000000000..c40654016
--- /dev/null
+++ b/config/locales-glitch/simple_form.hy.yml
@@ -0,0 +1 @@
+hy:
diff --git a/config/locales-glitch/simple_form.id.yml b/config/locales-glitch/simple_form.id.yml
new file mode 100644
index 000000000..8446cbad9
--- /dev/null
+++ b/config/locales-glitch/simple_form.id.yml
@@ -0,0 +1 @@
+id:
diff --git a/config/locales-glitch/simple_form.ig.yml b/config/locales-glitch/simple_form.ig.yml
new file mode 100644
index 000000000..7c264f0d7
--- /dev/null
+++ b/config/locales-glitch/simple_form.ig.yml
@@ -0,0 +1 @@
+ig:
diff --git a/config/locales-glitch/simple_form.io.yml b/config/locales-glitch/simple_form.io.yml
new file mode 100644
index 000000000..c63dc0e8d
--- /dev/null
+++ b/config/locales-glitch/simple_form.io.yml
@@ -0,0 +1 @@
+io:
diff --git a/config/locales-glitch/simple_form.is.yml b/config/locales-glitch/simple_form.is.yml
new file mode 100644
index 000000000..337c106df
--- /dev/null
+++ b/config/locales-glitch/simple_form.is.yml
@@ -0,0 +1 @@
+is:
diff --git a/config/locales-glitch/simple_form.it.yml b/config/locales-glitch/simple_form.it.yml
new file mode 100644
index 000000000..85830635a
--- /dev/null
+++ b/config/locales-glitch/simple_form.it.yml
@@ -0,0 +1 @@
+it:
diff --git a/config/locales-glitch/simple_form.ja.yml b/config/locales-glitch/simple_form.ja.yml
new file mode 100644
index 000000000..558d9da9c
--- /dev/null
+++ b/config/locales-glitch/simple_form.ja.yml
@@ -0,0 +1,19 @@
+---
+ja:
+  simple_form:
+    hints:
+      defaults:
+        setting_default_content_type_html: トゥートを作成するとき特に指定がない限り生のHTMLで書かれているとします
+        setting_default_content_type_markdown: トゥートを作成するとき特に指定がない限りリッチテキスト形式のマークダウンで書かれているとします
+        setting_default_content_type_plain: トゥートを作成するとき特に指定がない限りプレーンテキストで書かれているとします(Mastodon既定の動作)
+        setting_default_language: あなたのトゥートの言語を自動検出しますが必ずしも正確ではありません
+        setting_skin: 選択したMastodonフレーバーに変更します
+    labels:
+      defaults:
+        setting_default_content_type: 既定のトゥート形式
+        setting_default_content_type_markdown: マークダウン
+        setting_default_content_type_plain: プレーンテキスト
+        setting_favourite_modal: お気に入りをする前に確認ダイアログを表示する
+        setting_hide_followers_count: フォロワー数を隠す
+        setting_skin: スキン
+        setting_system_emoji_font: 絵文字にシステム既定のフォントを使用する(Glitch Edition フレーバーのみに適用されます)
diff --git a/config/locales-glitch/simple_form.ka.yml b/config/locales-glitch/simple_form.ka.yml
new file mode 100644
index 000000000..57a95cb04
--- /dev/null
+++ b/config/locales-glitch/simple_form.ka.yml
@@ -0,0 +1 @@
+ka:
diff --git a/config/locales-glitch/simple_form.kab.yml b/config/locales-glitch/simple_form.kab.yml
new file mode 100644
index 000000000..2109c04b3
--- /dev/null
+++ b/config/locales-glitch/simple_form.kab.yml
@@ -0,0 +1 @@
+kab:
diff --git a/config/locales-glitch/simple_form.kk.yml b/config/locales-glitch/simple_form.kk.yml
new file mode 100644
index 000000000..1dcc9b127
--- /dev/null
+++ b/config/locales-glitch/simple_form.kk.yml
@@ -0,0 +1 @@
+kk:
diff --git a/config/locales-glitch/simple_form.kn.yml b/config/locales-glitch/simple_form.kn.yml
new file mode 100644
index 000000000..d094088d8
--- /dev/null
+++ b/config/locales-glitch/simple_form.kn.yml
@@ -0,0 +1 @@
+kn:
diff --git a/config/locales-glitch/simple_form.ko.yml b/config/locales-glitch/simple_form.ko.yml
new file mode 100644
index 000000000..0390b7043
--- /dev/null
+++ b/config/locales-glitch/simple_form.ko.yml
@@ -0,0 +1,27 @@
+---
+ko:
+  simple_form:
+    glitch_only: 글리치
+    hints:
+      defaults:
+        fields: 최대 %{count}개의 항목을 프로필에 표 형태로 표시할 수 있습니다
+        setting_default_content_type_html: 게시물을 작성할 때, 형식을 지정하지 않았다면, 생 HTML이라고 가정합니다
+        setting_default_content_type_markdown: 게시물을 작성할 때, 형식을 지정하지 않았다면, 마크다운이라고 가정합니다
+        setting_default_content_type_plain: 게시물을 작성할 때, 형식을 지정하지 않았다면, 일반적인 텍스트라고 가정합니다. (마스토돈의 기본 동작)
+        setting_default_language: 작성하는 게시물의 언어는 자동으로 설정될 수 있습니다, 하지만 언제나 정확하지는 않습니다
+        setting_hide_followers_count: 나를 포함해서 모든 사람들에게서 내 팔로워 수를 숨깁니다. 몇몇 앱에서는 팔로워 수가 음수로 표시될 수 있습니다.
+        setting_skin: 선택한 마스토돈 풍미의 스킨을 바꿉니다
+    labels:
+      defaults:
+        setting_default_content_type: 게시물의 기본 포맷
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: 마크다운
+        setting_default_content_type_plain: 일반 텍스트
+        setting_favourite_modal: 관심글을 지정할 때 확인 창을 띄웁니다(글리치 풍미에만 적용됨)
+        setting_hide_followers_count: 내 팔로워 수 숨기기
+        setting_skin: 스킨
+        setting_system_emoji_font: 에모지에 시스템 기본 폰트 적용하기 (글리치 풍미에만 적용됨)
+      notification_emails:
+        trending_link: 새로 유행중인 링크에 대한 리뷰가 필요할 때
+        trending_status: 새로 유행중인 게시물에 대한 리뷰가 필요할 때
+        trending_tag: 새로 유행중인 해시태그에 대한 리뷰가 필요할 때
diff --git a/config/locales-glitch/simple_form.ku.yml b/config/locales-glitch/simple_form.ku.yml
new file mode 100644
index 000000000..b36f7c988
--- /dev/null
+++ b/config/locales-glitch/simple_form.ku.yml
@@ -0,0 +1 @@
+ku:
diff --git a/config/locales-glitch/simple_form.kw.yml b/config/locales-glitch/simple_form.kw.yml
new file mode 100644
index 000000000..b2cfc12ff
--- /dev/null
+++ b/config/locales-glitch/simple_form.kw.yml
@@ -0,0 +1 @@
+kw:
diff --git a/config/locales-glitch/simple_form.la.yml b/config/locales-glitch/simple_form.la.yml
new file mode 100644
index 000000000..3a7ba0d44
--- /dev/null
+++ b/config/locales-glitch/simple_form.la.yml
@@ -0,0 +1 @@
+la:
diff --git a/config/locales-glitch/simple_form.lt.yml b/config/locales-glitch/simple_form.lt.yml
new file mode 100644
index 000000000..6c5cb837a
--- /dev/null
+++ b/config/locales-glitch/simple_form.lt.yml
@@ -0,0 +1 @@
+lt:
diff --git a/config/locales-glitch/simple_form.lv.yml b/config/locales-glitch/simple_form.lv.yml
new file mode 100644
index 000000000..1be0eabc0
--- /dev/null
+++ b/config/locales-glitch/simple_form.lv.yml
@@ -0,0 +1 @@
+lv:
diff --git a/config/locales-glitch/simple_form.mk.yml b/config/locales-glitch/simple_form.mk.yml
new file mode 100644
index 000000000..8b9144a98
--- /dev/null
+++ b/config/locales-glitch/simple_form.mk.yml
@@ -0,0 +1 @@
+mk:
diff --git a/config/locales-glitch/simple_form.ml.yml b/config/locales-glitch/simple_form.ml.yml
new file mode 100644
index 000000000..6931a683d
--- /dev/null
+++ b/config/locales-glitch/simple_form.ml.yml
@@ -0,0 +1 @@
+ml:
diff --git a/config/locales-glitch/simple_form.mr.yml b/config/locales-glitch/simple_form.mr.yml
new file mode 100644
index 000000000..fe1639c6a
--- /dev/null
+++ b/config/locales-glitch/simple_form.mr.yml
@@ -0,0 +1 @@
+mr:
diff --git a/config/locales-glitch/simple_form.ms.yml b/config/locales-glitch/simple_form.ms.yml
new file mode 100644
index 000000000..2925688a0
--- /dev/null
+++ b/config/locales-glitch/simple_form.ms.yml
@@ -0,0 +1 @@
+ms:
diff --git a/config/locales-glitch/simple_form.my.yml b/config/locales-glitch/simple_form.my.yml
new file mode 100644
index 000000000..5e1fc6bee
--- /dev/null
+++ b/config/locales-glitch/simple_form.my.yml
@@ -0,0 +1 @@
+my:
diff --git a/config/locales-glitch/simple_form.nl.yml b/config/locales-glitch/simple_form.nl.yml
new file mode 100644
index 000000000..f009eadee
--- /dev/null
+++ b/config/locales-glitch/simple_form.nl.yml
@@ -0,0 +1 @@
+nl:
diff --git a/config/locales-glitch/simple_form.nn.yml b/config/locales-glitch/simple_form.nn.yml
new file mode 100644
index 000000000..777f4e600
--- /dev/null
+++ b/config/locales-glitch/simple_form.nn.yml
@@ -0,0 +1 @@
+nn:
diff --git a/config/locales-glitch/simple_form.no.yml b/config/locales-glitch/simple_form.no.yml
new file mode 100644
index 000000000..1dcec34b6
--- /dev/null
+++ b/config/locales-glitch/simple_form.no.yml
@@ -0,0 +1,2 @@
+---
+'no':
diff --git a/config/locales-glitch/simple_form.oc.yml b/config/locales-glitch/simple_form.oc.yml
new file mode 100644
index 000000000..325b34889
--- /dev/null
+++ b/config/locales-glitch/simple_form.oc.yml
@@ -0,0 +1 @@
+oc:
diff --git a/config/locales-glitch/simple_form.pa.yml b/config/locales-glitch/simple_form.pa.yml
new file mode 100644
index 000000000..bb8a6c834
--- /dev/null
+++ b/config/locales-glitch/simple_form.pa.yml
@@ -0,0 +1 @@
+pa:
diff --git a/config/locales-glitch/simple_form.pl.yml b/config/locales-glitch/simple_form.pl.yml
new file mode 100644
index 000000000..081587b14
--- /dev/null
+++ b/config/locales-glitch/simple_form.pl.yml
@@ -0,0 +1,27 @@
+---
+pl:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Możesz ustawić maksymalnie %{count} niestandardowe pola wyświetlane jako tabela w Twoim profilu
+        setting_default_content_type_html: Jeżeli nie powiedziano inaczej, załóż, że wpisy są pisane w HTML
+        setting_default_content_type_markdown: Jeżeli nie powiedziano inaczej, załóż, że wpisy są pisane w Markdown
+        setting_default_content_type_plain: Jeżeli nie powiedziano inaczej, załóż, że wpisy zawierają jedynie czysty tekst (domyślne zachowane Mastodonu)
+        setting_default_language: Język Twoich wpisów może zostać wykryty automatycznie, aczkolwiek poprawność działania tej fukcji nie jest gwarantowana
+        setting_hide_followers_count: Ukryj ilość Twoich obserwujących przed wszystkimi (równiej przed Tobą). Może to spowodować wyświetlanie ujemnej liczby obserwujących przez niektóre aplikacje.
+        setting_skin: Zmienia wygląd używanej odmiany Mastodona
+    labels:
+      defaults:
+        setting_default_content_type: Domyślny format wpisów
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Czysty tekst
+        setting_favourite_modal: Pytaj o potwierdzenie przed dodaniem do ulubionych
+        setting_hide_followers_count: Ukryj liczbę Twoich obserwujących
+        setting_skin: Motyw
+        setting_system_emoji_font: Użyj systemowej czcionki emotikon (tylko w wypadku odmiany „Glitch”)
+      notification_emails:
+        trending_link: Nowy, popularny link wymaga przeglądu
+        trending_status: Nowy, popularny wpis wymaga przeglądu
+        trending_tag: Nowy, popularny tag wymaga przeglądu
diff --git a/config/locales-glitch/simple_form.pt-BR.yml b/config/locales-glitch/simple_form.pt-BR.yml
new file mode 100644
index 000000000..831f18d23
--- /dev/null
+++ b/config/locales-glitch/simple_form.pt-BR.yml
@@ -0,0 +1,27 @@
+---
+pt-BR:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: Você pode ter até %{count} itens exibidos como uma tabela no seu perfil
+        setting_default_content_type_html: Ao escrever toots, assume que estão escritos em HTML bruto, a menos que especificado de outra forma
+        setting_default_content_type_markdown: Ao escrever toots, assume que ele está usando Markdown para formatação de texto, a menos que especificado seja diferente
+        setting_default_content_type_plain: Ao escrever toots, assume que eles são textos simples sem formatação especial, a menos que esteja especificado de outra forma (comportamento padrão do Mastodon)
+        setting_default_language: O idioma dos seus toots pode ser detectado automaticamente, mas nem sempre é preciso
+        setting_hide_followers_count: Ocultar a contagem de seguidores de todos, incluindo você. Algumas aplicações podem exibir uma contagem de seguidores negativa.
+        setting_skin: Reaplica o sabor Mastodon selecionado
+    labels:
+      defaults:
+        setting_default_content_type: Formato padrão para toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Texto sem formatação
+        setting_favourite_modal: Mostrar diálogo antes de favoritar (aplica-se somente ao sabor Glitch)
+        setting_hide_followers_count: Ocultar sua contagem de seguidores
+        setting_skin: Tema
+        setting_system_emoji_font: Usar fonte padrão do sistema para emojis (aplica-se somente ao sabor Glitch)
+      notification_emails:
+        trending_link: Novo link em tendência requer revisão
+        trending_status: Nova postagem em tendência requer revisão
+        trending_tag: Nova tag em tendência requer revisão
diff --git a/config/locales-glitch/simple_form.pt-PT.yml b/config/locales-glitch/simple_form.pt-PT.yml
new file mode 100644
index 000000000..a1a2dc36b
--- /dev/null
+++ b/config/locales-glitch/simple_form.pt-PT.yml
@@ -0,0 +1 @@
+pt-PT:
diff --git a/config/locales-glitch/simple_form.ro.yml b/config/locales-glitch/simple_form.ro.yml
new file mode 100644
index 000000000..79dbaa871
--- /dev/null
+++ b/config/locales-glitch/simple_form.ro.yml
@@ -0,0 +1 @@
+ro:
diff --git a/config/locales-glitch/simple_form.ru.yml b/config/locales-glitch/simple_form.ru.yml
new file mode 100644
index 000000000..ddc9d1e32
--- /dev/null
+++ b/config/locales-glitch/simple_form.ru.yml
@@ -0,0 +1 @@
+ru:
diff --git a/config/locales-glitch/simple_form.sa.yml b/config/locales-glitch/simple_form.sa.yml
new file mode 100644
index 000000000..07ea4372a
--- /dev/null
+++ b/config/locales-glitch/simple_form.sa.yml
@@ -0,0 +1 @@
+sa:
diff --git a/config/locales-glitch/simple_form.sc.yml b/config/locales-glitch/simple_form.sc.yml
new file mode 100644
index 000000000..91bd6d92f
--- /dev/null
+++ b/config/locales-glitch/simple_form.sc.yml
@@ -0,0 +1 @@
+sc:
diff --git a/config/locales-glitch/simple_form.sco.yml b/config/locales-glitch/simple_form.sco.yml
new file mode 100644
index 000000000..8165e00a1
--- /dev/null
+++ b/config/locales-glitch/simple_form.sco.yml
@@ -0,0 +1 @@
+sco:
diff --git a/config/locales-glitch/simple_form.si.yml b/config/locales-glitch/simple_form.si.yml
new file mode 100644
index 000000000..b0b50956e
--- /dev/null
+++ b/config/locales-glitch/simple_form.si.yml
@@ -0,0 +1 @@
+si:
diff --git a/config/locales-glitch/simple_form.sk.yml b/config/locales-glitch/simple_form.sk.yml
new file mode 100644
index 000000000..f634a0282
--- /dev/null
+++ b/config/locales-glitch/simple_form.sk.yml
@@ -0,0 +1 @@
+sk:
diff --git a/config/locales-glitch/simple_form.sl.yml b/config/locales-glitch/simple_form.sl.yml
new file mode 100644
index 000000000..26c7ce2e3
--- /dev/null
+++ b/config/locales-glitch/simple_form.sl.yml
@@ -0,0 +1 @@
+sl:
diff --git a/config/locales-glitch/simple_form.sq.yml b/config/locales-glitch/simple_form.sq.yml
new file mode 100644
index 000000000..44ddadc95
--- /dev/null
+++ b/config/locales-glitch/simple_form.sq.yml
@@ -0,0 +1 @@
+sq:
diff --git a/config/locales-glitch/simple_form.sr-Latn.yml b/config/locales-glitch/simple_form.sr-Latn.yml
new file mode 100644
index 000000000..c482b5e44
--- /dev/null
+++ b/config/locales-glitch/simple_form.sr-Latn.yml
@@ -0,0 +1 @@
+sr-Latn:
diff --git a/config/locales-glitch/simple_form.sr.yml b/config/locales-glitch/simple_form.sr.yml
new file mode 100644
index 000000000..9e26af819
--- /dev/null
+++ b/config/locales-glitch/simple_form.sr.yml
@@ -0,0 +1 @@
+sr:
diff --git a/config/locales-glitch/simple_form.sv.yml b/config/locales-glitch/simple_form.sv.yml
new file mode 100644
index 000000000..7e73a972a
--- /dev/null
+++ b/config/locales-glitch/simple_form.sv.yml
@@ -0,0 +1 @@
+sv:
diff --git a/config/locales-glitch/simple_form.ta.yml b/config/locales-glitch/simple_form.ta.yml
new file mode 100644
index 000000000..4320953ce
--- /dev/null
+++ b/config/locales-glitch/simple_form.ta.yml
@@ -0,0 +1 @@
+ta:
diff --git a/config/locales-glitch/simple_form.te.yml b/config/locales-glitch/simple_form.te.yml
new file mode 100644
index 000000000..34c54f18f
--- /dev/null
+++ b/config/locales-glitch/simple_form.te.yml
@@ -0,0 +1 @@
+te:
diff --git a/config/locales-glitch/simple_form.th.yml b/config/locales-glitch/simple_form.th.yml
new file mode 100644
index 000000000..a4431912a
--- /dev/null
+++ b/config/locales-glitch/simple_form.th.yml
@@ -0,0 +1 @@
+th:
diff --git a/config/locales-glitch/simple_form.tr.yml b/config/locales-glitch/simple_form.tr.yml
new file mode 100644
index 000000000..077d41667
--- /dev/null
+++ b/config/locales-glitch/simple_form.tr.yml
@@ -0,0 +1 @@
+tr:
diff --git a/config/locales-glitch/simple_form.tt.yml b/config/locales-glitch/simple_form.tt.yml
new file mode 100644
index 000000000..5eab4abff
--- /dev/null
+++ b/config/locales-glitch/simple_form.tt.yml
@@ -0,0 +1 @@
+tt:
diff --git a/config/locales-glitch/simple_form.ug.yml b/config/locales-glitch/simple_form.ug.yml
new file mode 100644
index 000000000..289acf241
--- /dev/null
+++ b/config/locales-glitch/simple_form.ug.yml
@@ -0,0 +1 @@
+ug:
diff --git a/config/locales-glitch/simple_form.uk.yml b/config/locales-glitch/simple_form.uk.yml
new file mode 100644
index 000000000..c256c3246
--- /dev/null
+++ b/config/locales-glitch/simple_form.uk.yml
@@ -0,0 +1 @@
+uk:
diff --git a/config/locales-glitch/simple_form.ur.yml b/config/locales-glitch/simple_form.ur.yml
new file mode 100644
index 000000000..2cace5883
--- /dev/null
+++ b/config/locales-glitch/simple_form.ur.yml
@@ -0,0 +1 @@
+ur:
diff --git a/config/locales-glitch/simple_form.vi.yml b/config/locales-glitch/simple_form.vi.yml
new file mode 100644
index 000000000..326506f0b
--- /dev/null
+++ b/config/locales-glitch/simple_form.vi.yml
@@ -0,0 +1 @@
+vi:
diff --git a/config/locales-glitch/simple_form.zh-CN.yml b/config/locales-glitch/simple_form.zh-CN.yml
new file mode 100644
index 000000000..c162ba721
--- /dev/null
+++ b/config/locales-glitch/simple_form.zh-CN.yml
@@ -0,0 +1,27 @@
+---
+zh-CN:
+  simple_form:
+    glitch_only: glitch-soc
+    hints:
+      defaults:
+        fields: 您可以在您的个人资料中最多显示 %{count} 个项目
+        setting_default_content_type_html: 在撰写嘟文时,除非另有指定,假定它们使用原始 HTML 语言撰写
+        setting_default_content_type_markdown: 在撰写嘟文时,除非另有指定,假定它们使用 Markdown 进行富文本格式化
+        setting_default_content_type_plain: 在撰写嘟文时,除非另有指定,假定它们是没有特殊格式的纯文本(默认的 Mastodon 行为)
+        setting_default_language: 你的嘟文语言可以自动检测,但不一定准确
+        setting_hide_followers_count: 对所有人隐藏您的粉丝数量,包括您在内。一些应用程序可能显示负粉丝数。
+        setting_skin: 更换为所选择的 Mastodon 风格
+    labels:
+      defaults:
+        setting_default_content_type: 嘟文的默认格式
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: 纯文本
+        setting_favourite_modal: 在喜欢嘟文前询问我 (仅限于 Glitch 风格)
+        setting_hide_followers_count: 隐藏你的关注者人数
+        setting_skin: 皮肤
+        setting_system_emoji_font: 表情符号使用系统默认字体 (仅限于 Glitch 风格)
+      notification_emails:
+        trending_link: 新热门链接需要审核
+        trending_status: 新热门贴文需要审核
+        trending_tag: 新热门话题标签需要审核
diff --git a/config/locales-glitch/simple_form.zh-HK.yml b/config/locales-glitch/simple_form.zh-HK.yml
new file mode 100644
index 000000000..8e51e5648
--- /dev/null
+++ b/config/locales-glitch/simple_form.zh-HK.yml
@@ -0,0 +1 @@
+zh-HK:
diff --git a/config/locales-glitch/simple_form.zh-TW.yml b/config/locales-glitch/simple_form.zh-TW.yml
new file mode 100644
index 000000000..cb82c0526
--- /dev/null
+++ b/config/locales-glitch/simple_form.zh-TW.yml
@@ -0,0 +1 @@
+zh-TW:
diff --git a/config/locales-glitch/sk.yml b/config/locales-glitch/sk.yml
new file mode 100644
index 000000000..f634a0282
--- /dev/null
+++ b/config/locales-glitch/sk.yml
@@ -0,0 +1 @@
+sk:
diff --git a/config/locales-glitch/sl.yml b/config/locales-glitch/sl.yml
new file mode 100644
index 000000000..26c7ce2e3
--- /dev/null
+++ b/config/locales-glitch/sl.yml
@@ -0,0 +1 @@
+sl:
diff --git a/config/locales-glitch/sq.yml b/config/locales-glitch/sq.yml
new file mode 100644
index 000000000..44ddadc95
--- /dev/null
+++ b/config/locales-glitch/sq.yml
@@ -0,0 +1 @@
+sq:
diff --git a/config/locales-glitch/sr-Latn.yml b/config/locales-glitch/sr-Latn.yml
new file mode 100644
index 000000000..c482b5e44
--- /dev/null
+++ b/config/locales-glitch/sr-Latn.yml
@@ -0,0 +1 @@
+sr-Latn:
diff --git a/config/locales-glitch/sr.yml b/config/locales-glitch/sr.yml
new file mode 100644
index 000000000..9e26af819
--- /dev/null
+++ b/config/locales-glitch/sr.yml
@@ -0,0 +1 @@
+sr:
diff --git a/config/locales-glitch/sv.yml b/config/locales-glitch/sv.yml
new file mode 100644
index 000000000..7e73a972a
--- /dev/null
+++ b/config/locales-glitch/sv.yml
@@ -0,0 +1 @@
+sv:
diff --git a/config/locales-glitch/ta.yml b/config/locales-glitch/ta.yml
new file mode 100644
index 000000000..4320953ce
--- /dev/null
+++ b/config/locales-glitch/ta.yml
@@ -0,0 +1 @@
+ta:
diff --git a/config/locales-glitch/te.yml b/config/locales-glitch/te.yml
new file mode 100644
index 000000000..34c54f18f
--- /dev/null
+++ b/config/locales-glitch/te.yml
@@ -0,0 +1 @@
+te:
diff --git a/config/locales-glitch/th.yml b/config/locales-glitch/th.yml
new file mode 100644
index 000000000..a4431912a
--- /dev/null
+++ b/config/locales-glitch/th.yml
@@ -0,0 +1 @@
+th:
diff --git a/config/locales-glitch/tr.yml b/config/locales-glitch/tr.yml
new file mode 100644
index 000000000..077d41667
--- /dev/null
+++ b/config/locales-glitch/tr.yml
@@ -0,0 +1 @@
+tr:
diff --git a/config/locales-glitch/tt.yml b/config/locales-glitch/tt.yml
new file mode 100644
index 000000000..5eab4abff
--- /dev/null
+++ b/config/locales-glitch/tt.yml
@@ -0,0 +1 @@
+tt:
diff --git a/config/locales-glitch/ug.yml b/config/locales-glitch/ug.yml
new file mode 100644
index 000000000..289acf241
--- /dev/null
+++ b/config/locales-glitch/ug.yml
@@ -0,0 +1 @@
+ug:
diff --git a/config/locales-glitch/uk.yml b/config/locales-glitch/uk.yml
new file mode 100644
index 000000000..c256c3246
--- /dev/null
+++ b/config/locales-glitch/uk.yml
@@ -0,0 +1 @@
+uk:
diff --git a/config/locales-glitch/ur.yml b/config/locales-glitch/ur.yml
new file mode 100644
index 000000000..2cace5883
--- /dev/null
+++ b/config/locales-glitch/ur.yml
@@ -0,0 +1 @@
+ur:
diff --git a/config/locales-glitch/vi.yml b/config/locales-glitch/vi.yml
new file mode 100644
index 000000000..326506f0b
--- /dev/null
+++ b/config/locales-glitch/vi.yml
@@ -0,0 +1 @@
+vi:
diff --git a/config/locales-glitch/zh-CN.yml b/config/locales-glitch/zh-CN.yml
new file mode 100644
index 000000000..d449f83b2
--- /dev/null
+++ b/config/locales-glitch/zh-CN.yml
@@ -0,0 +1,42 @@
+---
+zh-CN:
+  admin:
+    custom_emojis:
+      batch_copy_error: '复制部分所选表情时发生错误: %{message}'
+      batch_error: 发生了一个错误:%{message}
+    settings:
+      captcha_enabled:
+        desc_html: 这依赖于来自hCaptcha的外部脚本,这可能是一个安全和隐私问题。 此外, <strong>这可能使得某些人(尤其是残疾人)</strong>的注册简单程度大幅减少。 出于这些原因,请考虑采取其他措施,例如基于审核或邀请的注册。<br>通过限定邀请链接注册的用户将不需要解决验证码问题
+        title: 要求新用户解决验证码以确认他们的帐户
+      flavour_and_skin:
+        title: 风格与皮肤
+      hide_followers_count:
+        desc_html: 不要在用户资料中显示关注者人数
+        title: 隐藏关注者人数
+      other:
+        preamble: 各种不适合归类到其他类别的glitch-soc设置。
+        title: 其它
+      outgoing_spoilers:
+        desc_html: 在联邦化嘟文的时候,将这个内容警告添加到没有内容警告的嘟文中。如果你的服务器专用于其他服务器可能希望有内容警告的内容,它会很有用。媒体也将被标记为敏感。
+        title: 对外嘟文的内容警告
+      show_reblogs_in_public_timelines:
+        desc_html: 在本地和跨站时间线中显示公开嘟文的公开转嘟。
+        title: 在公共时间线中显示转嘟
+      show_replies_in_public_timelines:
+        desc_html: 除了公开的自我回复(线程模式),在本地和跨站时间轴中显示公开回复。
+        title: 在公共时间轴中显示回复
+      trending_status_cw:
+        desc_html: 当热门帖子被启用时,允许带有内容警告的帖子获得资格。此设置的更改不具有追溯性。
+        title: 允许带有内容警告的帖子进入热门
+  appearance:
+    localization:
+      glitch_guide_link: https://crowdin.com/project/glitch-soc
+      glitch_guide_link_text: Glitch-soc也是如此!
+  auth:
+    captcha_confirmation:
+      hint_html: 只需最后一步!为了确认您的账户,本服务器需要您解决一个验证码。如果您有疑问或需要帮助确认您的账户,请<a href="/about/more">联系服务器管理员</a>。
+      title: 用户验证
+  generic:
+    use_this: 使用这个
+  settings:
+    flavours: 风格
diff --git a/config/locales-glitch/zh-HK.yml b/config/locales-glitch/zh-HK.yml
new file mode 100644
index 000000000..8e51e5648
--- /dev/null
+++ b/config/locales-glitch/zh-HK.yml
@@ -0,0 +1 @@
+zh-HK:
diff --git a/config/locales-glitch/zh-TW.yml b/config/locales-glitch/zh-TW.yml
new file mode 100644
index 000000000..cb82c0526
--- /dev/null
+++ b/config/locales-glitch/zh-TW.yml
@@ -0,0 +1 @@
+zh-TW:
diff --git a/config/navigation.rb b/config/navigation.rb
index 30817d025..aab72d27c 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -15,6 +15,12 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_path
     end
 
+    n.item :flavours, safe_join([fa_icon('paint-brush fw'), t('settings.flavours')]), settings_flavours_path 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_path(flavour)
+      end
+    end
+
     n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? }
     n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? }
     n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional_or_moved? }
diff --git a/config/routes.rb b/config/routes.rb
index 22ef10866..099933116 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -8,6 +8,7 @@ Rails.application.routes.draw do
   # have alternative format representations requiring separate controllers
   web_app_paths = %w(
     /getting-started
+    /getting-started-misc
     /keyboard-shortcuts
     /home
     /public
@@ -71,6 +72,7 @@ Rails.application.routes.draw do
       resource :setup, only: [:show, :update], controller: :setup
       resource :challenge, only: [:create], controller: :challenges
       get 'sessions/security_key_options', to: 'sessions#webauthn_options'
+      post 'captcha_confirmation', to: 'confirmations#confirm_captcha', as: :captcha_confirmation
     end
   end
 
@@ -182,6 +184,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, :create]
 
@@ -275,6 +279,7 @@ Rails.application.routes.draw do
       resource :about, only: [:show, :update], controller: 'about'
       resource :appearance, only: [:show, :update], controller: 'appearance'
       resource :discovery, only: [:show, :update], controller: 'discovery'
+      resource :other, only: [:show, :update], controller: 'other'
     end
 
     resources :site_uploads, only: [:destroy]
@@ -467,6 +472,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
@@ -562,9 +568,10 @@ Rails.application.routes.draw do
         end
       end
 
-      resources :notifications, only: [:index, :show] do
+      resources :notifications, only: [:index, :show, :destroy] do
         collection do
           post :clear
+          delete :destroy_multiple
         end
 
         member do
diff --git a/config/settings.yml b/config/settings.yml
index 4ac521a4b..65eee7516 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -2,7 +2,7 @@
 # important settings can be changed from the admin interface.
 
 defaults: &defaults
-  site_title: Mastodon
+  site_title: 'Mastodon Glitch Edition'
   site_short_description: ''
   site_description: ''
   site_extended_description: ''
@@ -12,14 +12,17 @@ defaults: &defaults
   registrations_mode: 'open'
   profile_directory: true
   closed_registrations_message: ''
-  timeline_preview: true
+  timeline_preview: false
   show_staff_badge: true
   preview_sensitive_media: false
   noindex: false
-  theme: 'default'
+  flavour: 'glitch'
+  skin: 'default'
   trends: true
   trends_as_landing_page: true
   trendable_by_default: false
+  trending_status_cw: true
+  hide_followers_count: false
   reserved_usernames:
     - admin
     - support
@@ -33,9 +36,14 @@ defaults: &defaults
   bootstrap_timeline_accounts: ''
   activity_api_enabled: true
   peers_api_enabled: true
+  show_reblogs_in_public_timelines: false
+  show_replies_in_public_timelines: false
+  default_content_type: 'text/plain'
   show_domain_blocks: 'disabled'
   show_domain_blocks_rationale: 'disabled'
+  outgoing_spoilers: ''
   require_invite_text: false
+  captcha_enabled: false
   backups_retention_period: 7
 
 development:
diff --git a/config/themes.yml b/config/themes.yml
deleted file mode 100644
index 9c21c9459..000000000
--- a/config/themes.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-default: styles/application.scss
-contrast: styles/contrast.scss
-mastodon-light: styles/mastodon-light.scss
diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js
index 25b6b7abd..55ee06c0c 100644
--- a/config/webpack/configuration.js
+++ b/config/webpack/configuration.js
@@ -1,15 +1,58 @@
 // Common configuration for webpacker loaded from config/webpacker.yml
 
-const { resolve } = require('path');
+const { basename, dirname, extname, join, resolve } = require('path');
 const { env } = require('process');
 const { load } = require('js-yaml');
-const { readFileSync } = require('fs');
+const { lstatSync, readFileSync } = require('fs');
+const glob = require('glob');
 
 const configPath = resolve('config', 'webpacker.yml');
 const settings = load(readFileSync(configPath), 'utf8')[env.RAILS_ENV || 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 = load(readFileSync(themePath), 'utf8');
+const core = function () {
+  const coreFile = resolve('app', 'javascript', 'core', 'theme.yml');
+  const data = load(readFileSync(coreFile), 'utf8');
+  if (!data.pack_directory) {
+    data.pack_directory = dirname(coreFile);
+  }
+  return data.pack ? data : {};
+}();
+
+flavourFiles.forEach((flavourFile) => {
+  const data = load(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;
+  }
+});
+
+skinFiles.forEach((skinFile) => {
+  let skin = basename(skinFile);
+  const name = basename(dirname(skinFile));
+  if (!flavours[name]) {
+    return;
+  }
+  const data = flavours[name].skin;
+  if (lstatSync(skinFile).isDirectory()) {
+    data[skin] = {};
+    const skinPacks = glob.sync(join(skinFile, '*.{css,scss}'));
+    skinPacks.forEach((pack) => {
+      data[skin][basename(pack, extname(pack))] = pack;
+    });
+  } else if ((skin = skin.match(/^(.*)\.s?css$/i))) {
+    data[skin[1]] = { common: skinFile };
+  }
+});
 
 const output = {
   path: resolve('public', settings.public_output_path),
@@ -18,7 +61,8 @@ const output = {
 
 module.exports = {
   settings,
-  themes,
+  core,
+  flavours,
   env: {
     NODE_ENV: env.NODE_ENV,
     PUBLIC_OUTPUT_PATH: settings.public_output_path,
diff --git a/config/webpack/generateLocalePacks.js b/config/webpack/generateLocalePacks.js
index b71cf2ade..fedf0c7a1 100644
--- a/config/webpack/generateLocalePacks.js
+++ b/config/webpack/generateLocalePacks.js
@@ -1,52 +1,74 @@
+// 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');
+
+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 => {
+    return /\.json$/.test(filename) &&
+      !/defaultMessages/.test(filename) &&
+      !/whitelist/.test(filename);
+  }).map(filename => filename.replace(/\.json$/, ''));
+
+  let inherited_locales_path = null;
+  if (flavour.inherit_locales && flavours[flavour.inherit_locales]?.locales) {
+    inherited_locales_path = flavours[flavour.inherit_locales]?.locales;
+  }
+
+  const outPath = resolve('tmp', 'locales', entry);
+
+  rimraf.sync(outPath);
+  mkdirp.sync(outPath);
 
-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
+  locales.forEach(function (locale) {
+    const localePath = join(outPath, `${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 => existsSync(filename),
+    ).map(
+      filename => filename.replace(/(?:node_modules|app\/javascript)\//, ''),
+    )[0];
+    const localeContent = `//
+// locales/${entry}/${locale}.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;
+${inherited_locales_path ? `import inherited from '../../../${inherited_locales_path}/${locale}.json';` : ''}
+import messages from '../../../${flavour.locales}/${locale}.json';
+import localeData from '${localeDataPath}';
+import { setLocale } from 'locales';
 
+setLocale({
+  localeData,
+  ${inherited_locales_path ? 'messages: Object.assign({}, inherited, messages)' : 'messages'},
+});
+`;
+    writeFileSync(localePath, localeContent, 'utf8');
+    map[`locales/${entry}/${locale}`] = localePath;
+  });
 
+  return map;
+}, {});
diff --git a/config/webpack/rules/babel.js b/config/webpack/rules/babel.js
index ba1aff93a..8b6205a5c 100644
--- a/config/webpack/rules/babel.js
+++ b/config/webpack/rules/babel.js
@@ -12,6 +12,7 @@ module.exports = {
     {
       loader: 'babel-loader',
       options: {
+        sourceRoot: 'app/javascript',
         cacheDirectory: join(settings.cache_path, 'babel-loader'),
         cacheCompression: env.NODE_ENV === 'production',
         compact: env.NODE_ENV === 'production',
diff --git a/config/webpack/rules/css.js b/config/webpack/rules/css.js
index bc1f42c13..6ecfb3164 100644
--- a/config/webpack/rules/css.js
+++ b/config/webpack/rules/css.js
@@ -20,6 +20,9 @@ module.exports = {
     {
       loader: 'sass-loader',
       options: {
+        sassOptions: {
+          includePaths: ['app/javascript'],
+        },
         implementation: require('sass'),
         sourceMap: true,
       },
diff --git a/config/webpack/shared.js b/config/webpack/shared.js
index 78f660cfc..405858d0c 100644
--- a/config/webpack/shared.js
+++ b/config/webpack/shared.js
@@ -5,33 +5,57 @@ const { basename, dirname, join, relative, resolve } = require('path');
 const { sync } = require('glob');
 const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 const AssetsManifestPlugin = require('webpack-assets-manifest');
-const extname = require('path-complete-extname');
-const { env, settings, themes, output } = require('./configuration');
+const { env, settings, core, flavours, output } = require('./configuration.js');
 const rules = require('./rules');
-const localePackPaths = require('./generateLocalePacks');
+const localePacks = require('./generateLocalePacks');
+
+function reducePacks (data, into = {}) {
+  if (!data.pack) return into;
+
+  for (const entry in data.pack) {
+    const pack = data.pack[entry];
+    if (!pack) continue;
+
+    let packFiles = [];
+    if (typeof pack === 'string')
+      packFiles = [pack];
+    else if (Array.isArray(pack))
+      packFiles = pack;
+    else
+      packFiles = [pack.filename];
+
+    if (packFiles) {
+      into[data.name ? `flavours/${data.name}/${entry}` : `core/${entry}`] = packFiles.map(packFile => resolve(data.pack_directory, packFile));
+    }
+  }
+
+  if (!data.name) return into;
+
+  for (const skinName in data.skin) {
+    const skin = data.skin[skinName];
+    if (!skin) continue;
+
+    for (const entry in skin) {
+      const packFile = skin[entry];
+      if (!packFile) continue;
+
+      into[`skins/${data.name}/${skinName}/${entry}`] = resolve(packFile);
+    }
+  }
+
+  return into;
+}
+
+const entries = Object.assign(
+  { locales: resolve('app', 'javascript', 'locales') },
+  localePacks,
+  reducePacks(core),
+  Object.values(flavours).reduce((map, data) => reducePacks(data, map), {})
+);
 
-const extensionGlob = `**/*{${settings.extensions.join(',')}}*`;
-const entryPath = join(settings.source_path, settings.source_entry_path);
-const packPaths = sync(join(entryPath, extensionGlob));
 
 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;
-    }, {}),
-  ),
+  entry: entries,
 
   output: {
     filename: 'js/[name]-[chunkhash].js',
@@ -44,7 +68,7 @@ module.exports = {
 
   optimization: {
     runtimeChunk: {
-      name: 'common',
+      name: 'locales',
     },
     splitChunks: {
       cacheGroups: {
@@ -52,7 +76,9 @@ module.exports = {
         vendors: false,
         common: {
           name: 'common',
-          chunks: 'all',
+          chunks (chunk) {
+            return !(chunk.name in entries);
+          },
           minChunks: 2,
           minSize: 0,
           test: /^(?!.*[\\/]node_modules[\\/]react-intl[\\/]).+$/,
diff --git a/config/webpack/translationRunner.js b/config/webpack/translationRunner.js
index 38050baf8..c7f84cc7e 100644
--- a/config/webpack/translationRunner.js
+++ b/config/webpack/translationRunner.js
@@ -1,11 +1,12 @@
 const fs = require('fs');
 const path = require('path');
-const { default: manageTranslations } = require('react-intl-translations-manager');
+const { default: manageTranslations, readMessageFiles } = require('react-intl-translations-manager');
 
 const RFC5646_REGEXP = /^[a-z]{2,3}(?:-(?:x|[A-Za-z]{2,4}))*$/;
 
 const rootDirectory = path.resolve(__dirname, '..', '..');
-const translationsDirectory = path.resolve(rootDirectory, 'app', 'javascript', 'mastodon', 'locales');
+const externalDefaultMessages = path.resolve(rootDirectory, 'app', 'javascript', 'mastodon', 'locales', 'defaultMessages.json');
+const translationsDirectory = path.resolve(rootDirectory, 'app', 'javascript', 'flavours', 'glitch', 'locales');
 const messagesDirectory = path.resolve(rootDirectory, 'build', 'messages');
 const availableLanguages = fs.readdirSync(translationsDirectory).reduce((languages, filename) => {
   const basename = path.basename(filename, '.json');
@@ -86,6 +87,25 @@ validateLanguages(languages, [
   !argv.force && testAvailability,
 ].filter(Boolean));
 
+// Override `provideExtractedMessages` to ignore translation strings provided upstream already
+const provideExtractedMessages = () => {
+  const extractedMessages = readMessageFiles(messagesDirectory);
+  const originalExtractedMessages = JSON.parse(fs.readFileSync(externalDefaultMessages, 'utf8'));
+  const originalKeys = new Set();
+
+  originalExtractedMessages.forEach(file => {
+    file.descriptors.forEach(descriptor => {
+      originalKeys.add(descriptor.id)
+    });
+  });
+
+  extractedMessages.forEach(file => {
+    file.descriptors = file.descriptors.filter((descriptor) => !originalKeys.has(descriptor.id));
+  });
+
+  return extractedMessages.filter((file) => file.descriptors.length > 0);
+};
+
 // manage translations
 manageTranslations({
   messagesDirectory,
@@ -96,4 +116,7 @@ manageTranslations({
   jsonOptions: {
     trailingNewline: true,
   },
+  overrideCoreMethods: {
+    provideExtractedMessages,
+  },
 });
diff --git a/config/webpacker.yml b/config/webpacker.yml
index 6fd0fa1a0..f5c93a684 100644
--- a/config/webpacker.yml
+++ b/config/webpacker.yml
@@ -26,6 +26,7 @@ default: &default
     - .tiff
     - .ico
     - .svg
+    - .gif
     - .eot
     - .otf
     - .ttf
diff --git a/crowdin-glitch.yml b/crowdin-glitch.yml
new file mode 100644
index 000000000..14b559c8f
--- /dev/null
+++ b/crowdin-glitch.yml
@@ -0,0 +1,8 @@
+commit_message: '[ci skip]'
+files:
+  - source: /app/javascript/flavours/glitch/locales/en.json
+    translation: /app/javascript/flavours/glitch/locales/%two_letters_code%.json
+  - source: /config/locales-glitch/en.yml
+    translation: /config/locales-glitch/%two_letters_code%.yml
+  - source: /config/locales-glitch/simple_form.en.yml
+    translation: /config/locales-glitch/simple_form.%two_letters_code%.yml
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..d9866dfde
--- /dev/null
+++ b/db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+# This migration is glitch-soc-only because mutes were originally developed in
+# glitch-soc and the default value changed when submitting the code upstream.
+
+# This migration originally changed existing values to `true`, but this has
+# been dropped as to not cause issues when migrating from upstream.
+
+class DefaultExistingMutesToHidingNotifications < ActiveRecord::Migration[5.1]
+  def up
+    change_column_default :mutes, :hide_notifications, from: false, to: 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..77c88b0a5
--- /dev/null
+++ b/db/migrate/20171009222537_create_keyword_mutes.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+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
+
+    safety_assured { 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..b6ea537c2
--- /dev/null
+++ b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+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..010503b10
--- /dev/null
+++ b/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddLocalOnlyFlagToStatuses < ActiveRecord::Migration[5.1]
+  def change
+    add_column :statuses, :local_only, :boolean
+  end
+end
diff --git a/db/migrate/20180410220657_create_bookmarks.rb b/db/migrate/20180410220657_create_bookmarks.rb
new file mode 100644
index 000000000..aba21f5ea
--- /dev/null
+++ b/db/migrate/20180410220657_create_bookmarks.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# This migration is a duplicate of 20180831171112 and may get ignored, see
+# config/initializers/0_duplicate_migrations.rb
+
+class CreateBookmarks < ActiveRecord::Migration[5.1]
+  def change
+    create_table :bookmarks do |t|
+      t.references :account, null: false
+      t.references :status, null: false
+
+      t.timestamps
+    end
+
+    safety_assured do
+      add_foreign_key :bookmarks, :accounts, column: :account_id, on_delete: :cascade
+      add_foreign_key :bookmarks, :statuses, column: :status_id, on_delete: :cascade
+    end
+
+    add_index :bookmarks, [:account_id, :status_id], unique: true
+  end
+end
diff --git a/db/migrate/20180604000556_add_apply_to_mentions_flag_to_keyword_mutes.rb b/db/migrate/20180604000556_add_apply_to_mentions_flag_to_keyword_mutes.rb
new file mode 100644
index 000000000..8078a07bf
--- /dev/null
+++ b/db/migrate/20180604000556_add_apply_to_mentions_flag_to_keyword_mutes.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'mastodon/migration_helpers'
+
+class AddApplyToMentionsFlagToKeywordMutes < ActiveRecord::Migration[5.2]
+  include Mastodon::MigrationHelpers
+
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      add_column_with_default :glitch_keyword_mutes, :apply_to_mentions, :boolean, allow_null: false, default: true
+    end
+  end
+
+  def down
+    remove_column :glitch_keyword_mutes, :apply_to_mentions
+  end
+end
diff --git a/db/migrate/20180707193142_migrate_filters.rb b/db/migrate/20180707193142_migrate_filters.rb
new file mode 100644
index 000000000..8f6b3e1bb
--- /dev/null
+++ b/db/migrate/20180707193142_migrate_filters.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class MigrateFilters < ActiveRecord::Migration[5.2]
+  class GlitchKeywordMute < ApplicationRecord
+    # Dummy class, as we removed Glitch::KeywordMute
+    belongs_to :account, optional: false
+    validates_presence_of :keyword
+  end
+
+  class CustomFilter < ApplicationRecord
+    # Dummy class, in case CustomFilter gets altered in the future
+    belongs_to :account
+    validates :phrase, :context, presence: true
+
+    before_validation :clean_up_contexts
+
+    private
+
+    def clean_up_contexts
+      self.context = Array(context).map(&:strip).filter_map(&:presence)
+    end
+  end
+
+  disable_ddl_transaction!
+
+  def up
+    GlitchKeywordMute.find_each do |filter|
+      filter.account.custom_filters.create!(
+        phrase: filter.keyword,
+        context: filter.apply_to_mentions ? %w(home public notifications) : %w(home public),
+        whole_word: filter.whole_word,
+        irreversible: true
+      )
+    end
+  end
+
+  def down
+    unless table_exists? :glitch_keyword_mutes
+      create_table :glitch_keyword_mutes do |t|
+        t.references :account, null: false
+        t.string :keyword, null: false
+        t.boolean :whole_word, default: true, null: false
+        t.boolean :apply_to_mentions, default: true, null: false
+        t.timestamps
+      end
+
+      safety_assured { add_foreign_key :glitch_keyword_mutes, :accounts, on_delete: :cascade }
+    end
+
+    CustomFilter.where(irreversible: true).find_each do |filter|
+      GlitchKeywordMute.where(account: filter.account).create!(
+        keyword: filter.phrase,
+        whole_word: filter.whole_word,
+        apply_to_mentions: filter.context.include?('notifications')
+      )
+    end
+  end
+end
diff --git a/db/migrate/20180831171112_create_bookmarks.rb b/db/migrate/20180831171112_create_bookmarks.rb
index a08e60739..9f6bfae57 100644
--- a/db/migrate/20180831171112_create_bookmarks.rb
+++ b/db/migrate/20180831171112_create_bookmarks.rb
@@ -1,3 +1,6 @@
+# This migration is a duplicate of 20180410220657 and may get ignored, see
+# config/initializers/0_duplicate_migrations.rb
+
 class CreateBookmarks < ActiveRecord::Migration[5.2]
   def change
     create_table :bookmarks do |t|
diff --git a/db/migrate/20190512200918_add_content_type_to_statuses.rb b/db/migrate/20190512200918_add_content_type_to_statuses.rb
new file mode 100644
index 000000000..31c1a4f17
--- /dev/null
+++ b/db/migrate/20190512200918_add_content_type_to_statuses.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddContentTypeToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    add_column :statuses, :content_type, :string
+  end
+end
diff --git a/db/migrate/20220209175231_add_content_type_to_status_edits.rb b/db/migrate/20220209175231_add_content_type_to_status_edits.rb
new file mode 100644
index 000000000..bb414535d
--- /dev/null
+++ b/db/migrate/20220209175231_add_content_type_to_status_edits.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddContentTypeToStatusEdits < ActiveRecord::Migration[6.1]
+  def change
+    add_column :status_edits, :content_type, :string
+  end
+end
diff --git a/db/migrate/20230215074424_move_glitch_user_settings.rb b/db/migrate/20230215074424_move_glitch_user_settings.rb
new file mode 100644
index 000000000..6b5a25925
--- /dev/null
+++ b/db/migrate/20230215074424_move_glitch_user_settings.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class MoveGlitchUserSettings < ActiveRecord::Migration[6.1]
+  class User < ApplicationRecord; end
+
+  MAPPING = {
+    favourite_modal: 'web.favourite_modal',
+    system_emoji_font: 'web.use_system_emoji_font',
+    hide_followers_count: 'hide_followers_count',
+    default_content_type: 'default_content_type',
+    flavour: 'flavour',
+    skin: 'skin',
+    notification_emails: {
+      trending_link: 'notification_emails.link_trends',
+      trending_status: 'notification_emails.status_trends',
+    }.freeze,
+  }.freeze
+
+  class LegacySetting < ApplicationRecord
+    self.table_name = 'settings'
+
+    def var
+      self[:var]&.to_sym
+    end
+
+    def value
+      YAML.safe_load(self[:value], permitted_classes: [ActiveSupport::HashWithIndifferentAccess]) if self[:value].present?
+    end
+  end
+
+  def up
+    User.find_each do |user|
+      previous_settings = LegacySetting.where(thing_type: 'User', thing_id: user.id).index_by(&:var)
+
+      user_settings = Oj.load(user.settings || '{}')
+      user_settings.delete('theme')
+
+      MAPPING.each do |legacy_key, new_key|
+        value = previous_settings[legacy_key]&.value
+
+        next if value.blank?
+
+        if value.is_a?(Hash)
+          value.each do |nested_key, nested_value|
+            user_settings[MAPPING[legacy_key][nested_key.to_sym]] = nested_value
+          end
+        else
+          user_settings[new_key] = value
+        end
+      end
+
+      user.update_column('settings', Oj.dump(user_settings)) # rubocop:disable Rails/SkipsModelValidations
+    end
+  end
+
+  def down; end
+end
diff --git a/db/post_migrate/20180813160548_post_migrate_filters.rb b/db/post_migrate/20180813160548_post_migrate_filters.rb
new file mode 100644
index 000000000..82acf13d5
--- /dev/null
+++ b/db/post_migrate/20180813160548_post_migrate_filters.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class PostMigrateFilters < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    drop_table :glitch_keyword_mutes if table_exists? :glitch_keyword_mutes
+  end
+
+  def down; end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 620bed2bc..7d894b1aa 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: 2023_02_15_074423) do
+ActiveRecord::Schema.define(version: 2023_02_15_074424) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -879,6 +879,7 @@ ActiveRecord::Schema.define(version: 2023_02_15_074423) do
     t.text "spoiler_text", default: "", null: false
     t.datetime "created_at", precision: 6, null: false
     t.datetime "updated_at", precision: 6, null: false
+    t.string "content_type"
     t.bigint "ordered_media_attachment_ids", array: true
     t.text "media_descriptions", array: true
     t.string "poll_options", array: true
@@ -935,7 +936,9 @@ ActiveRecord::Schema.define(version: 2023_02_15_074423) do
     t.bigint "account_id", null: false
     t.bigint "application_id"
     t.bigint "in_reply_to_account_id"
+    t.boolean "local_only"
     t.bigint "poll_id"
+    t.string "content_type"
     t.datetime "deleted_at"
     t.datetime "edited_at"
     t.boolean "trendable"
diff --git a/jest.config.js b/jest.config.js
index 69222ea35..1eb143a59 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -8,6 +8,7 @@ const config = {
     '<rootDir>/log/',
     '<rootDir>/public/',
     '<rootDir>/tmp/',
+    '<rootDir>/app/javascript/themes/',
   ],
   setupFiles: ['raf/polyfill'],
   setupFilesAfterEnv: ['<rootDir>/app/javascript/mastodon/test_setup.js'],
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 71bcfb4e1..408f60185 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -21,7 +21,7 @@ module Mastodon
     end
 
     def suffix
-      ''
+      '+glitch'
     end
 
     def to_a
@@ -33,7 +33,7 @@ module Mastodon
     end
 
     def repository
-      ENV.fetch('GITHUB_REPOSITORY', 'mastodon/mastodon')
+      ENV.fetch('GITHUB_REPOSITORY', 'glitch-soc/mastodon')
     end
 
     def source_base_url
diff --git a/lib/paperclip/transcoder.rb b/lib/paperclip/transcoder.rb
index afd9f58ff..b3b55f82f 100644
--- a/lib/paperclip/transcoder.rb
+++ b/lib/paperclip/transcoder.rb
@@ -40,8 +40,10 @@ module Paperclip
         @output_options['f']       = 'image2'
         @output_options['vframes'] = 1
       when 'mp4'
-        @output_options['acodec'] = 'aac'
-        @output_options['strict'] = 'experimental'
+        unless eligible_to_passthrough?(metadata)
+          @output_options['acodec'] = 'aac'
+          @output_options['strict'] = 'experimental'
+        end
 
         if high_vfr?(metadata) && !eligible_to_passthrough?(metadata)
           @output_options['vsync'] = 'vfr'
diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb
index 9cc500c36..4c0e9b858 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -36,6 +36,25 @@ class Sanitize
       node['class'] = class_list.join(' ')
     end
 
+    IMG_TAG_TRANSFORMER = lambda do |env|
+      node = env[:node]
+
+      return unless env[:node_name] == 'img'
+
+      node.name = 'a'
+
+      node['href'] = node['src']
+      if node['alt'].present?
+        node.content = "[🖼  #{node['alt']}]"
+      else
+        url = node['href']
+        prefix = url.match(%r{\Ahttps?://(www\.)?}).to_s
+        text   = url[prefix.length, 30]
+        text += '…' if url.length - prefix.length > 30
+        node.content = "[🖼  #{text}]"
+      end
+    end
+
     UNSUPPORTED_HREF_TRANSFORMER = lambda do |env|
       return unless env[:node_name] == 'a'
 
@@ -50,21 +69,13 @@ class Sanitize
       current_node.replace(Nokogiri::XML::Text.new(current_node.text, current_node.document)) unless LINK_PROTOCOLS.include?(scheme)
     end
 
-    UNSUPPORTED_ELEMENTS_TRANSFORMER = lambda do |env|
-      return unless %w(h1 h2 h3 h4 h5 h6).include?(env[:node_name])
-
-      current_node = env[:node]
-
-      current_node.name = 'strong'
-      current_node.wrap('<p></p>')
-    end
-
     MASTODON_STRICT ||= freeze_config(
-      elements: %w(p br span a del pre blockquote code b strong u i em ul ol li),
+      elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li),
 
       attributes: {
-        'a' => %w(href rel class),
+        'a' => %w(href rel class title),
         'span' => %w(class),
+        'blockquote' => %w(cite),
         'ol' => %w(start reversed),
         'li' => %w(value),
       },
@@ -76,11 +87,14 @@ class Sanitize
         },
       },
 
-      protocols: {},
+      protocols: {
+        'a' => { 'href' => LINK_PROTOCOLS },
+        'blockquote' => { 'cite' => LINK_PROTOCOLS },
+      },
 
       transformers: [
         CLASS_WHITELIST_TRANSFORMER,
-        UNSUPPORTED_ELEMENTS_TRANSFORMER,
+        IMG_TAG_TRANSFORMER,
         UNSUPPORTED_HREF_TRANSFORMER,
       ]
     )
@@ -106,5 +120,48 @@ class Sanitize
         'source' => { 'src' => HTTP_PROTOCOLS }
       )
     )
+
+    LINK_REL_TRANSFORMER = lambda do |env|
+      return unless env[:node_name] == 'a' && env[:node]['href']
+
+      node = env[:node]
+
+      rel = (node['rel'] || '').split & ['tag']
+      rel += ['nofollow', 'noopener', 'noreferrer'] unless TagManager.instance.local_url?(node['href'])
+
+      if rel.empty?
+        node.remove_attribute('rel')
+      else
+        node['rel'] = rel.join(' ')
+      end
+    end
+
+    LINK_TARGET_TRANSFORMER = lambda do |env|
+      return unless env[:node_name] == 'a' && env[:node]['href']
+
+      node = env[:node]
+      if node['target'] != '_blank' && TagManager.instance.local_url?(node['href'])
+        node.remove_attribute('target')
+      else
+        node['target'] = '_blank'
+      end
+    end
+
+    MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
+      attributes: merge(
+        MASTODON_STRICT[:attributes],
+        'a' => %w(href rel class title target)
+      ),
+
+      add_attributes: {},
+
+      transformers: [
+        CLASS_WHITELIST_TRANSFORMER,
+        IMG_TAG_TRANSFORMER,
+        UNSUPPORTED_HREF_TRANSFORMER,
+        LINK_REL_TRANSFORMER,
+        LINK_TARGET_TRANSFORMER,
+      ]
+    )
   end
 end
diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake
index 1d2270572..76e190f70 100644
--- a/lib/tasks/assets.rake
+++ b/lib/tasks/assets.rake
@@ -1,13 +1,19 @@
 # frozen_string_literal: true
 
-def render_static_page(action, dest:, **opts)
-  html = ApplicationController.render(action, opts)
-  File.write(dest, html)
-end
-
 namespace :assets do
   desc 'Generate static pages'
   task generate_static_pages: :environment do
+    def render_static_page(action, dest:, **opts)
+      renderer = Class.new(ApplicationController) do
+        def current_user
+          nil
+        end
+      end
+
+      html = renderer.render(action, opts)
+      File.write(dest, html)
+    end
+
     render_static_page 'errors/500', layout: 'error', dest: Rails.public_path.join('assets', '500.html')
   end
 end
diff --git a/lib/tasks/glitchsoc.rake b/lib/tasks/glitchsoc.rake
new file mode 100644
index 000000000..72558fa19
--- /dev/null
+++ b/lib/tasks/glitchsoc.rake
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+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 |status|
+      ActiveRecord::Base.logger.silence do
+        status.update_attribute(:local_only, status.marked_local_only?) # rubocop:disable Rails/SkipsModelValidations
+      end
+    end
+  end
+end
diff --git a/package.json b/package.json
index e36780396..1c09ae413 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
     "@github/webauthn-json": "^2.1.1",
     "@rails/ujs": "^6.1.7",
     "abortcontroller-polyfill": "^1.7.5",
+    "atrament": "0.2.4",
     "arrow-key-navigation": "^1.2.0",
     "autoprefixer": "^10.4.14",
     "axios": "^1.3.4",
@@ -59,7 +60,9 @@
     "emoji-mart": "npm:emoji-mart-lazyload@latest",
     "es6-symbol": "^3.1.3",
     "escape-html": "^1.0.3",
+    "exif-js": "^2.3.0",
     "express": "^4.18.2",
+    "favico.js": "^0.3.10",
     "file-loader": "^6.2.0",
     "font-awesome": "^4.7.0",
     "fuzzysort": "^2.0.4",
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/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
index b5d5c37a9..a677aaad0 100644
--- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
@@ -75,9 +75,11 @@ describe Api::V1::Accounts::CredentialsController do
         end
       end
 
-      describe 'with invalid data' do
+      describe 'with a too long profile bio' do
         before do
-          patch :update, params: { note: 'This is too long. ' * 30 }
+          note = 'This is too long. '
+          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/timelines/direct_controller_spec.rb b/spec/controllers/api/v1/timelines/direct_controller_spec.rb
new file mode 100644
index 000000000..def67a0fe
--- /dev/null
+++ b/spec/controllers/api/v1/timelines/direct_controller_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::Timelines::DirectController do
+  let(:user)  { Fabricate(:user) }
+  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') }
+
+  describe 'GET #show' do
+    it 'returns 200' do
+      allow(controller).to receive(:doorkeeper_token) { token }
+      get :show
+
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/controllers/api/v1/timelines/public_controller_spec.rb b/spec/controllers/api/v1/timelines/public_controller_spec.rb
index 31e594d22..0892d5db6 100644
--- a/spec/controllers/api/v1/timelines/public_controller_spec.rb
+++ b/spec/controllers/api/v1/timelines/public_controller_spec.rb
@@ -44,6 +44,10 @@ describe Api::V1::Timelines::PublicController do
   context 'without a user context' do
     let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil) }
 
+    before do
+      Setting.timeline_preview = true
+    end
+
     describe 'GET #show' do
       it 'returns http success' do
         get :show
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index bc6c6c0c5..82455d874 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -73,35 +73,42 @@ describe ApplicationController, type: :controller do
     end
   end
 
-  describe 'helper_method :current_theme' do
-    it 'returns "default" when theme wasn\'t changed in admin settings' do
-      allow(Setting).to receive(:default_settings).and_return({ 'theme' => 'default' })
+  describe 'helper_method :current_flavour' do
+    it 'returns "glitch" when theme wasn\'t changed in admin settings' do
+      allow(Setting).to receive(:default_settings).and_return({ 'skin' => 'default' })
+      allow(Setting).to receive(:default_settings).and_return({ 'flavour' => 'glitch' })
 
-      expect(controller.view_context.current_theme).to eq 'default'
+      expect(controller.view_context.current_flavour).to eq 'glitch'
     end
 
-    it 'returns instances\'s theme when user is not signed in' do
-      allow(Setting).to receive(:[]).with('theme').and_return 'contrast'
+    it 'returns instances\'s flavour when user is not signed in' do
+      allow(Setting).to receive(:[]).with('skin').and_return 'default'
+      allow(Setting).to receive(:[]).with('flavour').and_return 'vanilla'
 
-      expect(controller.view_context.current_theme).to eq 'contrast'
+      expect(controller.view_context.current_flavour).to eq 'vanilla'
     end
 
-    it 'returns instances\'s default theme when user didn\'t set theme' do
+    it 'returns instances\'s default flavour when user didn\'t set theme' do
       current_user = Fabricate(:user)
-      current_user.settings.update(theme: 'contrast', noindex: false)
-      current_user.save
       sign_in current_user
 
-      expect(controller.view_context.current_theme).to eq 'contrast'
+      allow(Setting).to receive(:[]).with('skin').and_return 'default'
+      allow(Setting).to receive(:[]).with('flavour').and_return 'vanilla'
+      allow(Setting).to receive(:[]).with('noindex').and_return false
+
+      expect(controller.view_context.current_flavour).to eq 'vanilla'
     end
 
-    it 'returns user\'s theme when it is set' do
+    it 'returns user\'s flavour when it is set' do
       current_user = Fabricate(:user)
-      current_user.settings.update(theme: 'mastodon-light')
+      current_user.settings.update(flavour: 'glitch')
       current_user.save
       sign_in current_user
 
-      expect(controller.view_context.current_theme).to eq 'mastodon-light'
+      allow(Setting).to receive(:[]).with('skin').and_return 'default'
+      allow(Setting).to receive(:[]).with('flavour').and_return 'vanilla'
+
+      expect(controller.view_context.current_flavour).to eq 'glitch'
     end
   end
 
diff --git a/spec/controllers/settings/flavours_controller_spec.rb b/spec/controllers/settings/flavours_controller_spec.rb
new file mode 100644
index 000000000..8c7d4a768
--- /dev/null
+++ b/spec/controllers/settings/flavours_controller_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Settings::FlavoursController 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/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb
index 933eff225..1226cfd8e 100644
--- a/spec/lib/activitypub/activity/create_spec.rb
+++ b/spec/lib/activitypub/activity/create_spec.rb
@@ -294,6 +294,31 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
+      context 'limited when direct message assertion is false' do
+        let(:recipient) { Fabricate(:account) }
+
+        let(:object_json) do
+          {
+            id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
+            type: 'Note',
+            content: 'Lorem ipsum',
+            directMessage: false,
+            to: ActivityPub::TagManager.instance.uri_for(recipient),
+            tag: {
+              type: 'Mention',
+              href: ActivityPub::TagManager.instance.uri_for(recipient),
+            },
+          }
+        end
+
+        it 'creates status' do
+          status = sender.statuses.first
+
+          expect(status).to_not be_nil
+          expect(status.visibility).to eq 'limited'
+        end
+      end
+
       context 'direct' do
         let(:recipient) { Fabricate(:account) }
 
@@ -318,6 +343,27 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
+      context 'direct when direct message assertion is true' do
+        let(:recipient) { Fabricate(:account) }
+
+        let(:object_json) do
+          {
+            id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
+            type: 'Note',
+            content: 'Lorem ipsum',
+            to: ActivityPub::TagManager.instance.uri_for(recipient),
+            directMessage: true,
+          }
+        end
+
+        it 'creates status' do
+          status = sender.statuses.first
+
+          expect(status).to_not be_nil
+          expect(status.visibility).to eq 'direct'
+        end
+      end
+
       context 'as a reply' do
         let(:original_status) { Fabricate(:status) }
 
diff --git a/spec/lib/advanced_text_formatter_spec.rb b/spec/lib/advanced_text_formatter_spec.rb
new file mode 100644
index 000000000..8b27b56a1
--- /dev/null
+++ b/spec/lib/advanced_text_formatter_spec.rb
@@ -0,0 +1,300 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe AdvancedTextFormatter do
+  describe '#to_s' do
+    subject { described_class.new(text, preloaded_accounts: preloaded_accounts, content_type: content_type).to_s }
+
+    let(:preloaded_accounts) { nil }
+    let(:content_type) { 'text/markdown' }
+
+    context 'given a markdown source' do
+      let(:content_type) { 'text/markdown' }
+
+      context 'given text containing plain text' do
+        let(:text) { 'text' }
+
+        it 'paragraphizes the text' do
+          expect(subject).to eq '<p>text</p>'
+        end
+      end
+
+      context 'given text containing line feeds' do
+        let(:text) { "line\nfeed" }
+
+        it 'removes line feeds' do
+          expect(subject).to_not include "\n"
+        end
+      end
+
+      context 'given some inline code using backticks' do
+        let(:text) { 'test `foo` bar' }
+
+        it 'formats code using <code>' do
+          expect(subject).to include 'test <code>foo</code> bar'
+        end
+      end
+
+      context 'given a block code' do
+        let(:text) { "test\n\n```\nint main(void) {\n  return 0; // https://joinmastodon.org/foo\n}\n```\n" }
+
+        it 'formats code using <pre> and <code>' do
+          expect(subject).to include '<pre><code>int main'
+        end
+
+        it 'does not strip leading spaces' do
+          expect(subject).to include '>  return 0'
+        end
+
+        it 'does not format links' do
+          expect(subject).to include 'return 0; // https://joinmastodon.org/foo'
+        end
+      end
+
+      context 'given a link in inline code using backticks' do
+        let(:text) { 'test `https://foo.bar/bar` bar' }
+
+        it 'does not rewrite the link' do
+          expect(subject).to include 'test <code>https://foo.bar/bar</code> bar'
+        end
+      end
+
+      context 'given text with a local-domain mention' do
+        let(:text) { 'foo https://cb6e6126.ngrok.io/about/more' }
+
+        it 'creates a link' do
+          expect(subject).to include '<a href="https://cb6e6126.ngrok.io/about/more"'
+        end
+      end
+
+      context 'given text containing linkable mentions' do
+        let(:preloaded_accounts) { [Fabricate(:account, username: 'alice')] }
+        let(:text) { '@alice' }
+
+        it 'creates a mention link' do
+          expect(subject).to include '<a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span>'
+        end
+      end
+
+      context 'given text containing unlinkable mentions' do
+        let(:preloaded_accounts) { [] }
+        let(:text) { '@alice' }
+
+        it 'does not create a mention link' do
+          expect(subject).to include '@alice'
+        end
+      end
+
+      context 'given a stand-alone medium URL' do
+        let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4"'
+        end
+      end
+
+      context 'given a stand-alone google URL' do
+        let(:text) { 'http://google.com' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="http://google.com"'
+        end
+      end
+
+      context 'given a stand-alone URL with a newer TLD' do
+        let(:text) { 'http://example.gay' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="http://example.gay"'
+        end
+      end
+
+      context 'given a stand-alone IDN URL' do
+        let(:text) { 'https://nic.みんな/' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="https://nic.みんな/"'
+        end
+
+        it 'has display URL' do
+          expect(subject).to include '<span class="">nic.みんな/</span>'
+        end
+      end
+
+      context 'given a URL with a trailing period' do
+        let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' }
+
+        it 'matches the full URL but not the period' do
+          expect(subject).to include 'href="http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona"'
+        end
+      end
+
+      context 'given a URL enclosed with parentheses' do
+        let(:text) { '(http://google.com/)' }
+
+        it 'matches the full URL but not the parentheses' do
+          expect(subject).to include 'href="http://google.com/"'
+        end
+      end
+
+      context 'given a URL with a trailing exclamation point' do
+        let(:text) { 'http://www.google.com!' }
+
+        it 'matches the full URL but not the exclamation point' do
+          expect(subject).to include 'href="http://www.google.com"'
+        end
+      end
+
+      context 'given a URL with a trailing single quote' do
+        let(:text) { "http://www.google.com'" }
+
+        it 'matches the full URL but not the single quote' do
+          expect(subject).to include 'href="http://www.google.com"'
+        end
+      end
+    end
+
+    context 'given a URL with a trailing angle bracket' do
+      let(:text) { 'http://www.google.com>' }
+
+      it 'matches the full URL but not the angle bracket' do
+        expect(subject).to include 'href="http://www.google.com"'
+      end
+    end
+
+    context 'given a URL with a query string' do
+      context 'with escaped unicode character' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;q=autolink"'
+        end
+      end
+
+      context 'with unicode character' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&amp;q=autolink"'
+        end
+      end
+
+      context 'with unicode character at the end' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"'
+        end
+      end
+
+      context 'with escaped and not escaped unicode characters' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' }
+
+        it 'preserves escaped unicode characters' do
+          expect(subject).to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;utf81=✓&amp;q=autolink"'
+        end
+      end
+
+      context 'given a URL with parentheses in it' do
+        let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
+
+        it 'matches the full URL' do
+          expect(subject).to include 'href="https://en.wikipedia.org/wiki/Diaspora_(software)"'
+        end
+      end
+
+      context 'given a URL in quotation marks' do
+        let(:text) { '"https://example.com/"' }
+
+        it 'does not match the quotation marks' do
+          expect(subject).to include 'href="https://example.com/"'
+        end
+      end
+
+      context 'given a URL in angle brackets' do
+        let(:text) { '<https://example.com/>' }
+
+        it 'does not match the angle brackets' do
+          expect(subject).to include 'href="https://example.com/"'
+        end
+      end
+
+      context 'given a URL containing unsafe code (XSS attack, invisible part)' do
+        let(:text) { 'http://example.com/blahblahblahblah/a<script>alert("Hello")</script>' }
+
+        it 'does not include the HTML in the URL' do
+          expect(subject).to include '"http://example.com/blahblahblahblah/a"'
+        end
+
+        it 'does not include a script tag' do
+          expect(subject).to_not include '<script>'
+        end
+      end
+
+      context 'given text containing HTML code (script tag)' do
+        let(:text) { '<script>alert("Hello")</script>' }
+
+        it 'does not include a script tag' do
+          expect(subject).to_not include '<script>'
+        end
+      end
+
+      context 'given text containing HTML (XSS attack)' do
+        let(:text) { %q{<img src="javascript:alert('XSS');">} }
+
+        it 'does not include the javascript' do
+          expect(subject).to_not include 'href="javascript:'
+        end
+      end
+
+      context 'given an invalid URL' do
+        let(:text) { 'http://www\.google\.com' }
+
+        it 'outputs the raw URL' do
+          expect(subject).to eq '<p>http://www\.google\.com</p>'
+        end
+      end
+
+      context 'given text containing a hashtag' do
+        let(:text)  { '#hashtag' }
+
+        it 'creates a hashtag link' do
+          expect(subject).to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>'
+        end
+      end
+
+      context 'given text containing a hashtag with Unicode chars' do
+        let(:text)  { '#hashtagタグ' }
+
+        it 'creates a hashtag link' do
+          expect(subject).to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>'
+        end
+      end
+
+      context 'given text with a stand-alone xmpp: URI' do
+        let(:text) { 'xmpp:user@instance.com' }
+
+        it 'matches the full URI' do
+          expect(subject).to include 'href="xmpp:user@instance.com"'
+        end
+      end
+
+      context 'given text with an xmpp: URI with a query-string' do
+        let(:text) { 'please join xmpp:muc@instance.com?join right now' }
+
+        it 'matches the full URI' do
+          expect(subject).to include 'href="xmpp:muc@instance.com?join"'
+        end
+      end
+
+      context 'given text containing a magnet: URI' do
+        let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }
+
+        it 'matches the full URI' do
+          expect(subject).to include 'href="magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"'
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 418bdf089..d1e0d60e0 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -134,6 +134,13 @@ RSpec.describe FeedManager do
         expect(FeedManager.instance.filter?(:home, status, bob)).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, text: 'Hey @jeff')
+        expect(FeedManager.instance.filter?(:home, status, bob)).to be true
+      end
+
       it 'returns true for reblog of a personally blocked domain' do
         alice.block_domain!('example.com')
         alice.follow!(jeff)
diff --git a/spec/lib/sanitize_config_spec.rb b/spec/lib/sanitize_config_spec.rb
index a01122bed..29344476f 100644
--- a/spec/lib/sanitize_config_spec.rb
+++ b/spec/lib/sanitize_config_spec.rb
@@ -3,11 +3,9 @@
 require 'rails_helper'
 
 describe Sanitize::Config do
-  describe '::MASTODON_STRICT' do
-    subject { Sanitize::Config::MASTODON_STRICT }
-
-    it 'converts h1 to p strong' do
-      expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<p><strong>Foo</strong></p>'
+  shared_examples 'common HTML sanitization' do
+    it 'keeps h1' do
+      expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<h1>Foo</h1>'
     end
 
     it 'keeps ul' do
@@ -46,4 +44,21 @@ describe Sanitize::Config do
       expect(Sanitize.fragment('<a href="dweb:/a/foo">Test</a>', subject)).to eq '<a href="dweb:/a/foo" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
     end
   end
+
+  describe '::MASTODON_OUTGOING' do
+    subject { Sanitize::Config::MASTODON_OUTGOING }
+
+    around do |example|
+      original_web_domain = Rails.configuration.x.web_domain
+      example.run
+      Rails.configuration.x.web_domain = original_web_domain
+    end
+
+    it_behaves_like 'common HTML sanitization'
+
+    it 'keeps a with href and rel tag, not adding to rel or target if url is local' do
+      Rails.configuration.x.web_domain = 'domain.test'
+      expect(Sanitize.fragment('<a href="http://domain.test/tags/foo" rel="tag">Test</a>', subject)).to eq '<a href="http://domain.test/tags/foo" rel="tag">Test</a>'
+    end
+  end
 end
diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb
index 7396af6df..32e08d5f7 100644
--- a/spec/models/concerns/account_interactions_spec.rb
+++ b/spec/models/concerns/account_interactions_spec.rb
@@ -14,12 +14,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)
         expect(subject).to eq(target_account_id => { reblogs: true, notify: false, languages: nil })
       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)
+        expect(subject).to eq(target_account_id => { reblogs: false, notify: false, languages: nil })
+      end
+    end
+
     context 'account without Follow' do
       it 'returns {}' do
         expect(subject).to eq({})
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
index 569c160ae..ff81cd78d 100644
--- a/spec/models/follow_request_spec.rb
+++ b/spec/models/follow_request_spec.rb
@@ -15,6 +15,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!
diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb
index 5653aee18..d31aba084 100644
--- a/spec/models/public_feed_spec.rb
+++ b/spec/models/public_feed_spec.rb
@@ -50,6 +50,7 @@ RSpec.describe PublicFeed, type: :model do
       let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
       let!(:local_status)   { Fabricate(:status, account: local_account) }
       let!(:remote_status)  { Fabricate(:status, account: remote_account) }
+      let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) }
 
       context 'without a viewer' do
         let(:viewer) { nil }
@@ -61,6 +62,10 @@ RSpec.describe PublicFeed, type: :model do
         it 'includes local statuses' do
           expect(subject).to include(local_status.id)
         end
+
+        it 'does not include local-only statuses' do
+          expect(subject).to_not include(local_only_status.id)
+        end
       end
 
       context 'with a viewer' do
@@ -73,6 +78,54 @@ RSpec.describe PublicFeed, type: :model do
         it 'includes local statuses' do
           expect(subject).to include(local_status.id)
         end
+
+        it 'does not include local-only statuses' do
+          expect(subject).to_not include(local_only_status.id)
+        end
+      end
+    end
+
+    context 'without local_only option but allow_local_only' do
+      subject { described_class.new(viewer, allow_local_only: true).get(20).map(&:id) }
+
+      let(:viewer) { nil }
+
+      let!(:local_account)  { Fabricate(:account, domain: nil) }
+      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
+      let!(:local_status)   { Fabricate(:status, account: local_account) }
+      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
+      let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) }
+
+      context 'without a viewer' do
+        let(:viewer) { nil }
+
+        it 'includes remote instances statuses' do
+          expect(subject).to include(remote_status.id)
+        end
+
+        it 'includes local statuses' do
+          expect(subject).to include(local_status.id)
+        end
+
+        it 'does not include local-only statuses' do
+          expect(subject).to_not include(local_only_status.id)
+        end
+      end
+
+      context 'with a viewer' do
+        let(:viewer) { Fabricate(:account, username: 'viewer') }
+
+        it 'includes remote instances statuses' do
+          expect(subject).to include(remote_status.id)
+        end
+
+        it 'includes local statuses' do
+          expect(subject).to include(local_status.id)
+        end
+
+        it 'includes local-only statuses' do
+          expect(subject).to include(local_only_status.id)
+        end
       end
     end
 
@@ -83,6 +136,7 @@ RSpec.describe PublicFeed, type: :model do
       let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
       let!(:local_status)   { Fabricate(:status, account: local_account) }
       let!(:remote_status)  { Fabricate(:status, account: remote_account) }
+      let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) }
 
       context 'without a viewer' do
         let(:viewer) { nil }
@@ -91,6 +145,10 @@ RSpec.describe PublicFeed, type: :model do
           expect(subject).to include(local_status.id)
           expect(subject).to_not include(remote_status.id)
         end
+
+        it 'does not include local-only statuses' do
+          expect(subject).to_not include(local_only_status.id)
+        end
       end
 
       context 'with a viewer' do
@@ -106,6 +164,10 @@ RSpec.describe PublicFeed, type: :model do
           expect(subject).to include(local_status.id)
           expect(subject).to_not include(remote_status.id)
         end
+
+        it 'includes local-only statuses' do
+          expect(subject).to include(local_only_status.id)
+        end
       end
     end
 
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 1e58c6d0d..04e5c26af 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -205,6 +205,43 @@ RSpec.describe Status, type: :model do
     end
   end
 
+  describe 'on create' do
+    subject { Status.new }
+
+    let(:local_account) { Fabricate(:account, username: 'local', domain: nil) }
+    let(:remote_account) { Fabricate(:account, username: 'remote', domain: 'example.com') }
+
+    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
     subject { Status.mutes_map([status.conversation.id], account) }
 
@@ -253,6 +290,56 @@ RSpec.describe Status, type: :model do
     end
   end
 
+  describe '.as_direct_timeline' do
+    subject(:results) { Status.as_direct_timeline(account) }
+
+    let(:account) { Fabricate(:account) }
+    let(:followed) { Fabricate(:account) }
+    let(:not_followed) { Fabricate(:account) }
+
+    let!(:self_public_status) { Fabricate(:status, account: account, visibility: :public) }
+    let!(:self_direct_status) { Fabricate(:status, account: account, visibility: :direct) }
+    let!(:followed_public_status) { Fabricate(:status, account: followed, visibility: :public) }
+    let!(:followed_direct_status) { Fabricate(:status, account: followed, visibility: :direct) }
+    let!(:not_followed_direct_status) { Fabricate(:status, account: not_followed, visibility: :direct) }
+
+    before do
+      account.follow!(followed)
+    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 'does not include direct statuses not mentioning recipient from followed' do
+      expect(results).to_not include(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
+
+    it 'includes direct statuses mentioning recipient from followed' do
+      Fabricate(:mention, account: account, status: followed_direct_status)
+      results2 = Status.as_direct_timeline(account)
+      expect(results2).to include(followed_direct_status)
+    end
+
+    it 'includes direct statuses mentioning recipient from non-followed' do
+      Fabricate(:mention, account: account, status: not_followed_direct_status)
+      results2 = Status.as_direct_timeline(account)
+      expect(results2).to include(not_followed_direct_status)
+    end
+  end
+
   describe '.tagged_with' do
     let(:tag1)     { Fabricate(:tag) }
     let(:tag2)     { Fabricate(:tag) }
diff --git a/spec/models/tag_feed_spec.rb b/spec/models/tag_feed_spec.rb
index a498bcf46..d8683b86f 100644
--- a/spec/models/tag_feed_spec.rb
+++ b/spec/models/tag_feed_spec.rb
@@ -66,5 +66,19 @@ describe TagFeed, type: :service do
       results = described_class.new(tag1, nil).get(20)
       expect(results).to include(status)
     end
+
+    context 'when the feed contains a local-only status' do
+      let!(:status) { Fabricate(:status, tags: [tag1], local_only: true) }
+
+      it 'does not show local-only statuses without a viewer' do
+        results = described_class.new(tag1, nil).get(20)
+        expect(results).to_not include(status)
+      end
+
+      it 'shows local-only statuses given a viewer' do
+        results = described_class.new(tag1, account).get(20)
+        expect(results).to include(status)
+      end
+    end
   end
 end
diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb
index 9ae54780e..38b9c4fdb 100644
--- a/spec/policies/status_policy_spec.rb
+++ b/spec/policies/status_policy_spec.rb
@@ -81,6 +81,18 @@ 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?).and_return(true)
+
+      expect(subject).to_not permit(nil, status)
+    end
+
+    it 'denies access when local-only and the viewer is from another domain' do
+      viewer = Fabricate(:account, domain: 'remote-domain')
+      allow(status).to receive(:local_only?).and_return(true)
+      expect(subject).to_not permit(viewer, status)
+    end
   end
 
   permissions :reblog? do
diff --git a/spec/presenters/instance_presenter_spec.rb b/spec/presenters/instance_presenter_spec.rb
index 2a1d668ce..f20dce593 100644
--- a/spec/presenters/instance_presenter_spec.rb
+++ b/spec/presenters/instance_presenter_spec.rb
@@ -108,8 +108,8 @@ describe InstancePresenter do
         end
       end
 
-      it 'defaults to the core mastodon repo URL' do
-        expect(instance_presenter.source_url).to eq('https://github.com/mastodon/mastodon')
+      it 'defaults to the core glitch-soc repo URL' do
+        expect(instance_presenter.source_url).to eq('https://github.com/glitch-soc/mastodon')
       end
     end
   end
diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb
index e132b5618..7e06b9bd9 100644
--- a/spec/validators/status_length_validator_spec.rb
+++ b/spec/validators/status_length_validator_spec.rb
@@ -16,26 +16,31 @@ describe StatusLengthValidator do
       expect(status).to_not receive(:errors)
     end
 
-    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)
@@ -58,7 +63,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' * chars) + " #{username}@#{'b' * 30}.com"
       status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
 
       subject.validate(status)
diff --git a/streaming/index.js b/streaming/index.js
index 91e86fdbc..94568ee9a 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -371,6 +371,7 @@ const startWorker = async (workerId) => {
   const channelNameFromPath = req => {
     const { path, query } = req;
     const onlyMedia = isTruthy(query.only_media);
+    const allowLocalOnly = isTruthy(query.allow_local_only);
 
     switch (path) {
     case '/api/v1/streaming/user':
@@ -600,9 +601,10 @@ const startWorker = async (workerId) => {
    * @param {function(string, string): void} output
    * @param {function(string[], function(string): void): void} attachCloseHandler
    * @param {boolean=} needsFiltering
+   * @param {boolean=} allowLocalOnly
    * @return {function(string): void}
    */
-  const streamFrom = (ids, req, output, attachCloseHandler, needsFiltering = false) => {
+  const streamFrom = (ids, req, output, attachCloseHandler, needsFiltering = false, allowLocalOnly = false) => {
     const accountId = req.accountId || req.remoteAddress;
 
     log.verbose(req.requestId, `Starting stream from ${ids.join(', ')} for ${accountId}`);
@@ -623,6 +625,12 @@ const startWorker = async (workerId) => {
         output(event, encodedPayload);
       };
 
+      // Only send local-only statuses to logged-in users
+      if (event === 'update' && payload.local_only && !(req.accountId && allowLocalOnly)) {
+        log.silly(req.requestId, `Message ${payload.id} filtered because it was local-only`);
+        return;
+      }
+
       // Only messages that may require filtering are statuses, since notifications
       // are already personalized and deletes do not matter
       if (!needsFiltering || event !== 'update') {
@@ -865,7 +873,7 @@ const startWorker = async (workerId) => {
       const onSend = streamToHttp(req, res);
       const onEnd = streamHttpEnd(req, subscriptionHeartbeat(channelIds));
 
-      streamFrom(channelIds, req, onSend, onEnd, options.needsFiltering);
+      streamFrom(channelIds, req, onSend, onEnd, options.needsFiltering, options.allowLocalOnly);
     }).catch(err => {
       log.verbose(req.requestId, 'Subscription error:', err.toString());
       httpNotFound(res);
@@ -938,63 +946,77 @@ const startWorker = async (workerId) => {
     case 'user':
       resolve({
         channelIds: channelsForUserStream(req),
-        options: { needsFiltering: false },
+        options: { needsFiltering: false, allowLocalOnly: true },
       });
 
       break;
     case 'user:notification':
       resolve({
         channelIds: [`timeline:${req.accountId}:notifications`],
-        options: { needsFiltering: false },
+        options: { needsFiltering: false, allowLocalOnly: true },
       });
 
       break;
     case 'public':
       resolve({
         channelIds: ['timeline:public'],
-        options: { needsFiltering: true },
+        options: { needsFiltering: true, allowLocalOnly: isTruthy(params.allow_local_only) },
+      });
+
+      break;
+    case 'public:allow_local_only':
+      resolve({
+        channelIds: ['timeline:public'],
+        options: { needsFiltering: true, allowLocalOnly: true },
       });
 
       break;
     case 'public:local':
       resolve({
         channelIds: ['timeline:public:local'],
-        options: { needsFiltering: true },
+        options: { needsFiltering: true, allowLocalOnly: true },
       });
 
       break;
     case 'public:remote':
       resolve({
         channelIds: ['timeline:public:remote'],
-        options: { needsFiltering: true },
+        options: { needsFiltering: true, allowLocalOnly: false },
       });
 
       break;
     case 'public:media':
       resolve({
         channelIds: ['timeline:public:media'],
-        options: { needsFiltering: true },
+        options: { needsFiltering: true, allowLocalOnly: isTruthy(query.allow_local_only) },
+      });
+
+      break;
+    case 'public:allow_local_only:media':
+      resolve({
+        channelIds: ['timeline:public:media'],
+        options: { needsFiltering: true, allowLocalOnly: true },
       });
 
       break;
     case 'public:local:media':
       resolve({
         channelIds: ['timeline:public:local:media'],
-        options: { needsFiltering: true },
+        options: { needsFiltering: true, allowLocalOnly: true },
       });
 
       break;
     case 'public:remote:media':
       resolve({
         channelIds: ['timeline:public:remote:media'],
-        options: { needsFiltering: true },
+        options: { needsFiltering: true, allowLocalOnly: false },
       });
 
       break;
     case 'direct':
       resolve({
         channelIds: [`timeline:direct:${req.accountId}`],
-        options: { needsFiltering: false },
+        options: { needsFiltering: false, allowLocalOnly: true },
       });
 
       break;
@@ -1004,7 +1026,7 @@ const startWorker = async (workerId) => {
       } else {
         resolve({
           channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}`],
-          options: { needsFiltering: true },
+          options: { needsFiltering: true, allowLocalOnly: true },
         });
       }
 
@@ -1015,7 +1037,7 @@ const startWorker = async (workerId) => {
       } else {
         resolve({
           channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}:local`],
-          options: { needsFiltering: true },
+          options: { needsFiltering: true, allowLocalOnly: true },
         });
       }
 
@@ -1024,7 +1046,7 @@ const startWorker = async (workerId) => {
       authorizeListAccess(params.list, req).then(() => {
         resolve({
           channelIds: [`timeline:list:${params.list}`],
-          options: { needsFiltering: false },
+          options: { needsFiltering: false, allowLocalOnly: true },
         });
       }).catch(() => {
         reject('Not authorized to stream this list');
@@ -1074,7 +1096,7 @@ const startWorker = async (workerId) => {
 
       const onSend = streamToWs(request, socket, streamNameFromChannelName(channelName, params));
       const stopHeartbeat = subscriptionHeartbeat(channelIds);
-      const listener = streamFrom(channelIds, request, onSend, undefined, options.needsFiltering);
+      const listener = streamFrom(channelIds, request, onSend, undefined, options.needsFiltering, options.allowLocalOnly);
 
       subscriptions[channelIds.join(';')] = {
         listener,
diff --git a/stylelint.config.js b/stylelint.config.js
index cdb10c02e..1a153adb9 100644
--- a/stylelint.config.js
+++ b/stylelint.config.js
@@ -2,6 +2,8 @@ module.exports = {
   extends: ['stylelint-config-standard-scss'],
   ignoreFiles: [
     'app/javascript/styles/mastodon/reset.scss',
+    'app/javascript/flavours/glitch/styles/reset.scss',
+    'app/javascript/styles/win95.scss',
     'node_modules/**/*',
     'vendor/**/*',
   ],
diff --git a/yarn.lock b/yarn.lock
index e3ba8c8d5..45ed66d8b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3190,6 +3190,11 @@ atob@^2.1.2:
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
+atrament@0.2.4:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/atrament/-/atrament-0.2.4.tgz#6f78196edfcd194e568b7c0b9c88201ec371ac66"
+  integrity sha512-hSA9VwW6COMwvRhSEO4uZweZ91YGOdHqwvslNyrJZG+8mzc4qx/qMsDZBuAeXFeWZO/QKtRjIXguOUy1aNMl3A==
+
 autoprefixer@^10.4.14:
   version "10.4.14"
   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.14.tgz#e28d49902f8e759dd25b153264e862df2705f79d"
@@ -5330,6 +5335,11 @@ execa@^7.0.0:
     signal-exit "^3.0.7"
     strip-final-newline "^3.0.0"
 
+exif-js@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/exif-js/-/exif-js-2.3.0.tgz#9d10819bf571f873813e7640241255ab9ce1a814"
+  integrity sha1-nRCBm/Vx+HOBPnZAJBJVq5zhqBQ=
+
 exit@^0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@@ -5477,6 +5487,11 @@ fastq@^1.6.0:
   dependencies:
     reusify "^1.0.4"
 
+favico.js@^0.3.10:
+  version "0.3.10"
+  resolved "https://registry.yarnpkg.com/favico.js/-/favico.js-0.3.10.tgz#80586e27a117f24a8d51c18a99bdc714d4339301"
+  integrity sha1-gFhuJ6EX8kqNUcGKmb3HFNQzkwE=
+
 faye-websocket@^0.11.3:
   version "0.11.3"
   resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.3.tgz#5c0e9a8968e8912c286639fde977a8b209f2508e"