about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.nvmrc2
-rw-r--r--.rubocop.yml42
-rw-r--r--Gemfile4
-rw-r--r--Gemfile.lock14
-rw-r--r--app/controllers/about_controller.rb17
-rw-r--r--app/controllers/accounts_controller.rb81
-rw-r--r--app/controllers/activitypub/claims_controller.rb2
-rw-r--r--app/controllers/activitypub/inboxes_controller.rb2
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb34
-rw-r--r--app/controllers/activitypub/replies_controller.rb2
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb78
-rw-r--r--app/controllers/admin/domain_allows_controller.rb2
-rw-r--r--app/controllers/admin/pending_accounts_controller.rb6
-rw-r--r--app/controllers/admin/tags_controller.rb2
-rw-r--r--app/controllers/api/base_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb77
-rw-r--r--app/controllers/api/v1/accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/domain_allows_controller.rb54
-rw-r--r--app/controllers/api/v1/admin/domain_blocks_controller.rb54
-rw-r--r--app/controllers/api/v1/domain_permissions_controller.rb81
-rw-r--r--app/controllers/api/v1/instances/activity_controller.rb4
-rw-r--r--app/controllers/api/v1/instances/peers_controller.rb4
-rw-r--r--app/controllers/api/v1/instances_controller.rb2
-rw-r--r--app/controllers/api/v1/polls/votes_controller.rb1
-rw-r--r--app/controllers/api/v1/polls_controller.rb1
-rw-r--r--app/controllers/api/v1/statuses/hides_controller.rb28
-rw-r--r--app/controllers/api/v1/statuses/mutes_controller.rb4
-rw-r--r--app/controllers/api/v1/statuses/publishing_controller.rb26
-rw-r--r--app/controllers/api/v1/statuses_controller.rb103
-rw-r--r--app/controllers/application_controller.rb48
-rw-r--r--app/controllers/auth/registrations_controller.rb8
-rw-r--r--app/controllers/concerns/account_owned_concern.rb2
-rw-r--r--app/controllers/custom_emojis_controller.rb97
-rw-r--r--app/controllers/home_controller.rb2
-rw-r--r--app/controllers/matrix/base_controller.rb100
-rw-r--r--app/controllers/matrix/identity/v1/check_credentials_controller.rb53
-rw-r--r--app/controllers/media_controller.rb5
-rw-r--r--app/controllers/media_proxy_controller.rb3
-rw-r--r--app/controllers/remote_interaction_controller.rb4
-rw-r--r--app/controllers/settings/preferences/filters_controller.rb9
-rw-r--r--app/controllers/settings/preferences/privacy_controller.rb9
-rw-r--r--app/controllers/settings/preferences/publishing_controller.rb9
-rw-r--r--app/controllers/settings/preferences_controller.rb26
-rw-r--r--app/controllers/settings/profiles_controller.rb4
-rw-r--r--app/controllers/statuses_controller.rb17
-rw-r--r--app/controllers/tags_controller.rb6
-rw-r--r--app/controllers/user_profile_css_controller.rb24
-rw-r--r--app/controllers/user_webapp_css_controller.rb83
-rw-r--r--app/helpers/domain_control_helper.rb10
-rw-r--r--app/helpers/img_proxy_helper.rb128
-rw-r--r--app/helpers/jsonld_helper.rb27
-rw-r--r--app/helpers/settings_helper.rb1
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js4
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js9
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js9
-rw-r--r--app/javascript/flavours/glitch/actions/mutes.js7
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js7
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/registerer.js4
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js74
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js5
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js13
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js61
-rw-r--r--app/javascript/flavours/glitch/components/status.js8
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js25
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js246
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js25
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js8
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js22
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js9
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js5
-rw-r--r--app/javascript/flavours/glitch/features/list_adder/index.js5
-rw-r--r--app/javascript/flavours/glitch/features/lists/index.js5
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js12
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js7
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js17
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js53
-rw-r--r--app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js20
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js1
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/list_panel.js5
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/mute_modal.js27
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js6
-rw-r--r--app/javascript/flavours/glitch/locales/en-MP.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js16
-rw-r--r--app/javascript/flavours/glitch/reducers/mutes.js5
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js3
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js3
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js5
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss14
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss13
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss15
-rw-r--r--app/javascript/flavours/glitch/styles/index.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/about.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/composer.scss11
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/formatting.scss175
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/index.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/status.scss243
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/index.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/nightshade.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/nightshade/diff.scss440
-rw-r--r--app/javascript/flavours/glitch/styles/nightshade/variables.scss41
-rw-r--r--app/javascript/flavours/glitch/styles/variables.scss8
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss1
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js2
-rw-r--r--app/javascript/fonts/opensans/LICENSE.txt202
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Bold.ttfbin0 -> 104120 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Bold.woff2bin0 -> 46296 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-BoldItalic.ttfbin0 -> 92628 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-BoldItalic.woff2bin0 -> 42116 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-ExtraBold.ttfbin0 -> 102076 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-ExtraBold.woff2bin0 -> 46028 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.ttfbin0 -> 92772 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.woff2bin0 -> 42180 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Italic.ttfbin0 -> 92240 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Italic.woff2bin0 -> 42456 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Light.ttfbin0 -> 101696 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Light.woff2bin0 -> 45632 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-LightItalic.ttfbin0 -> 92488 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-LightItalic.woff2bin0 -> 41908 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Regular.ttfbin0 -> 96932 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-Regular.woff2bin0 -> 44504 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-SemiBold.ttfbin0 -> 100820 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-SemiBold.woff2bin0 -> 46376 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.ttfbin0 -> 92180 bytes
-rw-r--r--app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.woff2bin0 -> 43340 bytes
-rw-r--r--app/javascript/locales/locale-data/en-MP.js8
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js5
-rw-r--r--app/javascript/mastodon/actions/streaming.js5
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js6
-rw-r--r--app/javascript/mastodon/components/status_content.js20
-rw-r--r--app/javascript/mastodon/features/compose/components/poll_form.js4
-rw-r--r--app/javascript/mastodon/locales/en-MP.json181
-rw-r--r--app/javascript/mastodon/locales/locale-data/en-MP.js8
-rw-r--r--app/javascript/mastodon/locales/whitelist_en-MP.json2
-rw-r--r--app/javascript/mastodon/reducers/compose.js4
-rw-r--r--app/javascript/skins/glitch/nightshade/common.scss1
-rw-r--r--app/javascript/skins/glitch/nightshade/names.yml5
-rw-r--r--app/javascript/styles/fonts/montserrat.scss4
-rw-r--r--app/javascript/styles/fonts/opensans.scss134
-rw-r--r--app/javascript/styles/fonts/roboto-mono.scss2
-rw-r--r--app/javascript/styles/fonts/roboto.scss8
-rw-r--r--app/javascript/styles/mailer.scss2
-rw-r--r--app/javascript/styles/mastodon/variables.scss6
-rw-r--r--app/lib/activitypub/activity.rb10
-rw-r--r--app/lib/activitypub/activity/add.rb2
-rw-r--r--app/lib/activitypub/activity/announce.rb20
-rw-r--r--app/lib/activitypub/activity/block.rb2
-rw-r--r--app/lib/activitypub/activity/create.rb181
-rw-r--r--app/lib/activitypub/activity/delete.rb7
-rw-r--r--app/lib/activitypub/activity/update.rb4
-rw-r--r--app/lib/activitypub/adapter.rb12
-rw-r--r--app/lib/activitypub/case_transform.rb4
-rw-r--r--app/lib/activitypub/tag_manager.rb84
-rw-r--r--app/lib/command_tag/commands.rb10
-rw-r--r--app/lib/command_tag/commands/account_tools.rb37
-rw-r--r--app/lib/command_tag/commands/footer_tools.rb50
-rw-r--r--app/lib/command_tag/commands/hello_world.rb11
-rw-r--r--app/lib/command_tag/commands/parent_status_tools.rb80
-rw-r--r--app/lib/command_tag/commands/status_tools.rb239
-rw-r--r--app/lib/command_tag/commands/text_tools.rb89
-rw-r--r--app/lib/command_tag/commands/variables.rb40
-rw-r--r--app/lib/command_tag/processor.rb343
-rw-r--r--app/lib/feed_manager.rb240
-rw-r--r--app/lib/formatter.rb113
-rw-r--r--app/lib/img_tag_handler.rb30
-rw-r--r--app/lib/rss/serializer.rb1
-rw-r--r--app/lib/rss_builder.rb6
-rw-r--r--app/lib/sanitize_config.rb29
-rw-r--r--app/lib/status_filter.rb6
-rw-r--r--app/lib/user_settings_decorator.rb119
-rw-r--r--app/models/account.rb45
-rw-r--r--app/models/account_domain_permission.rb70
-rw-r--r--app/models/account_metadata.rb52
-rw-r--r--app/models/collection_item.rb21
-rw-r--r--app/models/collection_page.rb17
-rw-r--r--app/models/concerns/account_associations.rb18
-rw-r--r--app/models/concerns/account_finder_concern.rb4
-rw-r--r--app/models/concerns/account_interactions.rb29
-rw-r--r--app/models/concerns/status_threading_concern.rb2
-rw-r--r--app/models/conversation.rb2
-rw-r--r--app/models/conversation_mute.rb5
-rw-r--r--app/models/custom_emoji.rb8
-rw-r--r--app/models/custom_emoji_filter.rb9
-rw-r--r--app/models/domain_allow.rb2
-rw-r--r--app/models/domain_block.rb1
-rw-r--r--app/models/follow_request.rb5
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/form/custom_emoji_batch.rb39
-rw-r--r--app/models/inline_media_attachment.rb20
-rw-r--r--app/models/invite.rb2
-rw-r--r--app/models/list.rb3
-rw-r--r--app/models/media_attachment.rb32
-rw-r--r--app/models/mute.rb2
-rw-r--r--app/models/public_feed.rb7
-rw-r--r--app/models/queued_boost.rb15
-rw-r--r--app/models/status.rb290
-rw-r--r--app/models/status_domain_permission.rb69
-rw-r--r--app/models/status_mute.rb20
-rw-r--r--app/models/user.rb76
-rw-r--r--app/policies/account_domain_permission_policy.rb17
-rw-r--r--app/policies/custom_emoji_policy.rb36
-rw-r--r--app/policies/status_policy.rb27
-rw-r--r--app/presenters/activitypub/activity_presenter.rb18
-rw-r--r--app/presenters/status_relationships_presenter.rb6
-rw-r--r--app/serializers/activitypub/actor_serializer.rb12
-rw-r--r--app/serializers/activitypub/note_serializer.rb63
-rw-r--r--app/serializers/activitypub/outbox_serializer.rb2
-rw-r--r--app/serializers/activitypub/undo_announce_serializer.rb2
-rw-r--r--app/serializers/activitypub/update_poll_serializer.rb4
-rw-r--r--app/serializers/initial_state_serializer.rb1
-rw-r--r--app/serializers/nodeinfo/serializer.rb19
-rw-r--r--app/serializers/rest/account_domain_permission_serializer.rb9
-rw-r--r--app/serializers/rest/account_serializer.rb2
-rw-r--r--app/serializers/rest/instance_serializer.rb20
-rw-r--r--app/serializers/rest/list_serializer.rb2
-rw-r--r--app/serializers/rest/mute_serializer.rb6
-rw-r--r--app/serializers/rest/preferences_serializer.rb6
-rw-r--r--app/serializers/rest/status_domain_permission_serializer.rb10
-rw-r--r--app/serializers/rest/status_serializer.rb61
-rw-r--r--app/services/activitypub/fetch_collection_items_service.rb167
-rw-r--r--app/services/activitypub/fetch_featured_collection_service.rb5
-rw-r--r--app/services/activitypub/fetch_replies_service.rb56
-rw-r--r--app/services/activitypub/process_account_service.rb13
-rw-r--r--app/services/activitypub/process_collection_items_service.rb30
-rw-r--r--app/services/after_block_domain_from_account_service.rb5
-rw-r--r--app/services/after_block_service.rb30
-rw-r--r--app/services/backup_service.rb2
-rw-r--r--app/services/batched_remove_status_service.rb14
-rw-r--r--app/services/block_domain_service.rb1
-rw-r--r--app/services/block_service.rb8
-rw-r--r--app/services/concerns/payloadable.rb6
-rw-r--r--app/services/defederate_account_serivice.rb38
-rw-r--r--app/services/defederate_domain_service.rb11
-rw-r--r--app/services/fan_out_on_write_service.rb27
-rw-r--r--app/services/fetch_remote_status_service.rb12
-rw-r--r--app/services/fetch_resource_service.rb16
-rw-r--r--app/services/keys/query_service.rb2
-rw-r--r--app/services/mute_conversation_service.rb10
-rw-r--r--app/services/mute_service.rb6
-rw-r--r--app/services/mute_status_service.rb10
-rw-r--r--app/services/notify_service.rb7
-rw-r--r--app/services/post_status_service.rb84
-rw-r--r--app/services/precompute_feed_service.rb1
-rw-r--r--app/services/process_command_tags_service.rb10
-rw-r--r--app/services/process_hashtags_service.rb10
-rw-r--r--app/services/process_mentions_service.rb59
-rw-r--r--app/services/publish_status_service.rb45
-rw-r--r--app/services/reblog_service.rb12
-rw-r--r--app/services/remove_hashtags_service.rb21
-rw-r--r--app/services/remove_media_attachments_service.rb11
-rw-r--r--app/services/remove_status_service.rb50
-rw-r--r--app/services/resolve_mentions_service.rb61
-rw-r--r--app/services/resolve_url_service.rb4
-rw-r--r--app/services/revoke_status_service.rb98
-rw-r--r--app/services/search_service.rb2
-rw-r--r--app/services/suspend_account_service.rb4
-rw-r--r--app/services/unallow_domain_service.rb3
-rw-r--r--app/services/unfollow_service.rb5
-rw-r--r--app/services/update_status_service.rb161
-rw-r--r--app/validators/poll_validator.rb8
-rw-r--r--app/views/about/_domain_allows.html.haml12
-rw-r--r--app/views/about/_registration.html.haml5
-rw-r--r--app/views/about/more.html.haml10
-rw-r--r--app/views/about/show.html.haml171
-rw-r--r--app/views/accounts/_header.html.haml5
-rw-r--r--app/views/accounts/show.html.haml10
-rw-r--r--app/views/admin/accounts/index.html.haml2
-rw-r--r--app/views/admin/domain_allows/new.html.haml1
-rw-r--r--app/views/admin/instances/index.html.haml10
-rw-r--r--app/views/admin/instances/show.html.haml7
-rw-r--r--app/views/admin/pending_accounts/index.html.haml2
-rw-r--r--app/views/admin/settings/edit.html.haml47
-rw-r--r--app/views/auth/registrations/new.html.haml5
-rw-r--r--app/views/custom_emojis/_custom_emoji.html.haml (renamed from app/views/admin/custom_emojis/_custom_emoji.html.haml)1
-rw-r--r--app/views/custom_emojis/index.html.haml (renamed from app/views/admin/custom_emojis/index.html.haml)41
-rw-r--r--app/views/custom_emojis/new.html.haml (renamed from app/views/admin/custom_emojis/new.html.haml)2
-rwxr-xr-xapp/views/layouts/application.html.haml5
-rw-r--r--app/views/layouts/public.html.haml7
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml34
-rw-r--r--app/views/settings/preferences/filters/show.html.haml24
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml5
-rw-r--r--app/views/settings/preferences/other/show.html.haml27
-rw-r--r--app/views/settings/preferences/privacy/show.html.haml37
-rw-r--r--app/views/settings/preferences/publishing/show.html.haml23
-rw-r--r--app/views/settings/profiles/show.html.haml29
-rw-r--r--app/views/statuses/_detailed_status.html.haml21
-rw-r--r--app/views/statuses/_simple_status.html.haml25
-rw-r--r--app/views/statuses/_status.html.haml9
-rw-r--r--app/views/statuses/show.html.haml4
-rw-r--r--app/workers/account_defederation_worker.rb11
-rw-r--r--app/workers/activitypub/distribute_poll_update_worker.rb2
-rw-r--r--app/workers/activitypub/distribution_worker.rb21
-rw-r--r--app/workers/activitypub/process_collection_items_for_account_worker.rb20
-rw-r--r--app/workers/activitypub/process_collection_items_worker.rb27
-rw-r--r--app/workers/activitypub/raw_distribution_worker.rb5
-rw-r--r--app/workers/activitypub/reply_distribution_worker.rb13
-rw-r--r--app/workers/activitypub/sync_account_worker.rb57
-rw-r--r--app/workers/activitypub/update_distribution_worker.rb2
-rw-r--r--app/workers/block_worker.rb5
-rw-r--r--app/workers/clear_reblogs_worker.rb11
-rw-r--r--app/workers/distribution_worker.rb5
-rw-r--r--app/workers/domain_defederation_worker.rb11
-rw-r--r--app/workers/fetch_reply_worker.rb9
-rw-r--r--app/workers/link_crawl_worker.rb3
-rw-r--r--app/workers/move_worker.rb3
-rw-r--r--app/workers/mute_conversation_worker.rb11
-rw-r--r--app/workers/publish_scheduled_status_worker.rb2
-rw-r--r--app/workers/redownload_media_worker.rb19
-rw-r--r--app/workers/remove_media_attachments_worker.rb11
-rw-r--r--app/workers/reset_account_worker.rb16
-rw-r--r--app/workers/revoke_status_worker.rb11
-rw-r--r--app/workers/scheduler/ambassador_scheduler.rb56
-rw-r--r--app/workers/scheduler/database_cleanup_scheduler.rb14
-rw-r--r--app/workers/scheduler/publish_status_scheduler.rb11
-rw-r--r--app/workers/scheduler/status_cleanup_scheduler.rb13
-rw-r--r--app/workers/scheduler/user_cleanup_scheduler.rb5
-rw-r--r--app/workers/softblock_worker.rb16
-rw-r--r--app/workers/thread_resolve_worker.rb7
-rw-r--r--config/application.rb6
-rw-r--r--config/environments/development.rb2
-rw-r--r--config/environments/production.rb2
-rw-r--r--config/environments/test.rb4
-rw-r--r--config/initializers/2_whitelist_mode.rb2
-rw-r--r--config/initializers/doorkeeper.rb9
-rw-r--r--config/initializers/inflections.rb2
-rw-r--r--config/initializers/locale.rb1
-rw-r--r--config/initializers/rack_attack.rb4
-rw-r--r--config/initializers/sidekiq.rb4
-rw-r--r--config/locales/en-MP.yml190
-rw-r--r--config/locales/en.yml2
-rw-r--r--config/locales/simple_form.en-MP.yml90
-rw-r--r--config/navigation.rb11
-rw-r--r--config/routes.rb45
-rw-r--r--config/settings.yml2
-rw-r--r--config/sidekiq.yml12
-rw-r--r--db/migrate/20200628105849_add_hidden_to_domain_allows.rb7
-rw-r--r--db/migrate/20200630222227_add_edited_to_statuses.rb10
-rw-r--r--db/migrate/20200630222517_backfill_default_statuses_edited.rb14
-rw-r--r--db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb7
-rw-r--r--db/migrate/20200706171939_add_not_null_to_monsterfork_additions.rb11
-rw-r--r--db/migrate/20200717014609_add_nest_level_to_statuses.rb7
-rw-r--r--db/migrate/20200718011317_add_require_dereference_to_accounts.rb7
-rw-r--r--db/migrate/20200719024610_add_show_replies_to_accounts.rb7
-rw-r--r--db/migrate/20200719033609_add_show_unlisted_to_accounts.rb7
-rw-r--r--db/migrate/20200719114344_add_timelines_only_to_mute.rb7
-rw-r--r--db/migrate/20200719181947_add_published_to_statuses.rb7
-rw-r--r--db/migrate/20200719184152_add_unpublished_index_to_statuses.rb7
-rw-r--r--db/migrate/20200720212317_create_status_mutes.rb10
-rw-r--r--db/migrate/20200721184347_limit_visibility_of_replies_to_private_statuses.rb13
-rw-r--r--db/migrate/20200721195456_add_index_on_statuses_visibility.rb7
-rw-r--r--db/migrate/20200721202723_add_account_id_to_conversations.rb9
-rw-r--r--db/migrate/20200721221427_add_public_to_conversations.rb7
-rw-r--r--db/migrate/20200721221659_backfill_conversation_visibility.rb15
-rw-r--r--db/migrate/20200723225552_add_title_to_statuses.rb5
-rw-r--r--db/migrate/20200724035808_add_inline_to_media_attachments.rb7
-rw-r--r--db/migrate/20200724045955_create_inline_media_attachments.rb12
-rw-r--r--db/migrate/20200725071818_create_status_domain_permissions.rb13
-rw-r--r--db/migrate/20200725080000_create_account_domain_permissions.rb13
-rw-r--r--db/migrate/20200726094737_add_semiprivate_to_statuses.rb7
-rw-r--r--db/migrate/20200728135753_add_original_text_to_statuses.rb5
-rw-r--r--db/migrate/20200728171900_add_private_to_accounts.rb7
-rw-r--r--db/migrate/20200728173757_add_require_auth_to_accounts.rb7
-rw-r--r--db/migrate/20200731064236_create_account_metadata.rb10
-rw-r--r--db/migrate/20200731135033_backfill_account_metadata.rb11
-rw-r--r--db/migrate/20200731163700_create_destructing_statuses.rb11
-rw-r--r--db/migrate/20200731205913_create_queued_boosts.rb10
-rw-r--r--db/migrate/20200731211100_create_publishing_delays.rb10
-rw-r--r--db/migrate/20200801210543_add_accounts_to_publishing_delays.rb9
-rw-r--r--db/migrate/20200801220000_add_accounts_to_destructing_statuses.rb9
-rw-r--r--db/migrate/20200811024642_update_status_indexes.rb23
-rw-r--r--db/migrate/20200816200108_add_root_to_conversations.rb7
-rw-r--r--db/migrate/20200816200239_backfill_root_to_conversations.rb19
-rw-r--r--db/migrate/20200817003033_add_defaults_to_conversations.rb8
-rw-r--r--db/migrate/20200817003653_status_mute_account_id_bigint.rb7
-rw-r--r--db/migrate/20200817225525_add_footer_to_statuses.rb5
-rw-r--r--db/migrate/20200818040629_add_last_synced_at_to_accounts.rb5
-rw-r--r--db/migrate/20200818160057_create_collection_items.rb12
-rw-r--r--db/migrate/20200818160106_create_collection_pages.rb13
-rw-r--r--db/migrate/20200821051721_add_retries_to_collection_items.rb5
-rw-r--r--db/migrate/20200822054516_remove_public_column_from_conversations.rb7
-rw-r--r--db/migrate/20200823002835_unlink_blocked_replies.rb28
-rw-r--r--db/migrate/20200826125821_add_username_and_nospam_to_users.rb6
-rw-r--r--db/migrate/20200901035527_add_sticky_to_account_domain_permissions.rb7
-rw-r--r--db/migrate/20200901183004_backfill_user_username.rb11
-rw-r--r--db/migrate/20200904002209_add_expires_at_to_statuses.rb8
-rw-r--r--db/migrate/20200904004330_add_publish_at_to_statuses.rb8
-rw-r--r--db/migrate/20200904005553_drop_publishing_delay.rb5
-rw-r--r--db/migrate/20200904005706_drop_destructing_status.rb5
-rw-r--r--db/migrate/20200904184045_add_originally_local_only_to_statuses.rb7
-rw-r--r--db/migrate/20200904184155_backfill_originally_local_only.rb14
-rw-r--r--db/migrate/20200904200803_backfill_default_false_to_local_only.rb13
-rw-r--r--db/migrate/20200904201028_add_default_false_to_local_only.rb7
-rw-r--r--db/migrate/20200907195410_add_index_to_users_username.rb8
-rw-r--r--db/migrate/20200919234917_add_account_to_custom_emoji.rb7
-rw-r--r--db/migrate/20200920084007_backfill_custom_emoji_ownership.rb12
-rw-r--r--db/migrate/20200921024447_add_curated_to_statuses.rb7
-rw-r--r--db/migrate/20200921030158_backfill_curated_statuses.rb12
-rw-r--r--db/migrate/20200923000000_update_status_indexes_202009.rb25
-rw-r--r--db/migrate/20200923000001_remove_conversation_account.rb7
-rw-r--r--db/migrate/20200923000002_remove_semiprivate_flag.rb7
-rw-r--r--db/migrate/20200923000003_add_reblogs_flag_to_lists.rb8
-rw-r--r--db/migrate/20200925035221_drop_conversations_public.rb7
-rw-r--r--db/migrate/20201119035441_drop_require_dereference_from_accounts.rb7
-rw-r--r--db/migrate/20201123152231_add_no_verify_auth_to_accounts.rb7
-rw-r--r--db/migrate/20201123171722_add_allow_anonymous_to_accounts.rb7
-rw-r--r--db/migrate/20201124005733_remove_require_auth_from_accounts.rb7
-rw-r--r--db/schema.rb135
-rw-r--r--lib/mastodon/domains_cli.rb5
-rw-r--r--lib/mastodon/snowflake.rb13
-rw-r--r--lib/mastodon/version.rb41
-rw-r--r--lib/tasks/monsterfork.rake43
-rw-r--r--monsterfork.code-workspace11
-rw-r--r--package.json3
-rw-r--r--public/registration.js54
-rw-r--r--streaming/index.js4
-rw-r--r--yarn.lock44
423 files changed, 9187 insertions, 1118 deletions
diff --git a/.nvmrc b/.nvmrc
index 48082f72f..8351c1939 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-12
+14
diff --git a/.rubocop.yml b/.rubocop.yml
index 14728bf0e..74651358c 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -299,3 +299,45 @@ Style/TrailingCommaInHashLiteral:
 
 Style/UnpackFirst:
   Enabled: false
+
+Layout/EmptyLinesAroundAttributeAccessor:
+  Enabled: true
+
+Layout/SpaceAroundMethodCallOperator:
+  Enabled: true
+
+Lint/DeprecatedOpenSSLConstant:
+  Enabled: true
+
+Lint/MixedRegexpCaptureTypes:
+  Enabled: true
+
+Lint/RaiseException:
+  Enabled: true
+
+Lint/StructNewOverride:
+  Enabled: true
+
+Style/ExponentialNotation:
+  Enabled: true
+
+Style/HashEachMethods:
+  Enabled: true
+
+Style/HashTransformKeys:
+  Enabled: true
+
+Style/HashTransformValues:
+  Enabled: true
+
+Style/RedundantFetchBlock:
+  Enabled: true
+
+Style/RedundantRegexpCharacterClass:
+  Enabled: true
+
+Style/RedundantRegexpEscape:
+  Enabled: true
+
+Style/SlicingWithRange:
+  Enabled: true
diff --git a/Gemfile b/Gemfile
index 865d28a26..eaebe76ef 100644
--- a/Gemfile
+++ b/Gemfile
@@ -161,3 +161,7 @@ gem 'connection_pool', require: false
 
 gem 'xorcist', '~> 1.1'
 gem 'pluck_each', '~> 0.1.3'
+
+gem "w3c_validators", "~> 1.3"
+
+gem "activerecord-import", "~> 1.0"
diff --git a/Gemfile.lock b/Gemfile.lock
index 341e9c4bc..11fcfb30e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -54,6 +54,8 @@ GEM
       activemodel (= 5.2.4.4)
       activesupport (= 5.2.4.4)
       arel (>= 9.0)
+    activerecord-import (1.0.7)
+      activerecord (>= 3.2)
     activestorage (5.2.4.4)
       actionpack (= 5.2.4.4)
       activerecord (= 5.2.4.4)
@@ -638,6 +640,10 @@ GEM
     unf_ext (0.0.7.7)
     unicode-display_width (1.7.0)
     uniform_notifier (1.13.0)
+    w3c_validators (1.3.6)
+      json (>= 1.8)
+      nokogiri (~> 1.6)
+      rexml (~> 3.2)
     warden (1.2.9)
       rack (>= 2.0.9)
     webauthn (3.0.0.alpha1)
@@ -676,6 +682,7 @@ PLATFORMS
 DEPENDENCIES
   active_model_serializers (~> 0.10)
   active_record_query_trace (~> 1.8)
+  activerecord-import (~> 1.0)
   addressable (~> 2.7)
   annotate (~> 3.1)
   aws-sdk-s3 (~> 1.84)
@@ -797,8 +804,15 @@ DEPENDENCIES
   tty-prompt (~> 0.22)
   twitter-text (~> 1.14)
   tzinfo-data (~> 1.2020)
+  w3c_validators (~> 1.3)
   webauthn (~> 3.0.0.alpha1)
   webmock (~> 3.10)
   webpacker (~> 5.2)
   webpush
   xorcist (~> 1.1)
+
+RUBY VERSION
+   ruby 2.7.1p83
+
+BUNDLED WITH
+   2.1.4
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 5d5db937c..bf3d3ff42 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -4,16 +4,14 @@ class AboutController < ApplicationController
   before_action :set_pack
   layout 'public'
 
-  before_action :require_open_federation!, only: [:show, :more]
+  #before_action :require_open_federation!, only: [:show, :more]
   before_action :set_body_classes, only: :show
   before_action :set_instance_presenter
   before_action :set_expires_in, only: [:show, :more, :terms]
 
   skip_before_action :require_functional!, only: [:more, :terms]
 
-  def show; end
-
-  def more
+  def show
     flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
 
     toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description)
@@ -21,10 +19,15 @@ class AboutController < ApplicationController
     @contents          = toc_generator.html
     @table_of_contents = toc_generator.toc
     @blocks            = DomainBlock.with_user_facing_limitations.by_severity if display_blocks?
+    @allows            = DomainAllow.where(hidden: false) if display_allows?
   end
 
+  alias more show
+
   def terms; end
 
+  helper_method :display_allows?
+
   helper_method :display_blocks?
   helper_method :display_blocks_rationale?
   helper_method :public_fetch_mode?
@@ -66,4 +69,10 @@ class AboutController < ApplicationController
   def set_expires_in
     expires_in 0, public: true
   end
+
+  # Monsterfork additions
+
+  def display_allows?
+    Setting.show_domain_allows == 'all' || (Setting.show_domain_allows == 'users' && user_signed_in?)
+  end
 end
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 052394ab4..3d328e920 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -8,23 +8,27 @@ class AccountsController < ApplicationController
   include SignatureAuthentication
 
   before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
+  before_action :require_authenticated!, if: -> { @account.private? }
+  before_action :require_following!, if: -> { request.format != :rss && @account.private? }
   before_action :set_cache_headers
   before_action :set_body_classes
 
   skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
-  skip_before_action :require_functional!, unless: :whitelist_mode?
+  skip_before_action :require_functional! # , unless: :whitelist_mode?
 
   def show
+    @without_unlisted = !@account.show_unlisted?
+
     respond_to do |format|
       format.html do
         use_pack 'public'
-        expires_in 0, public: true unless user_signed_in?
+        expires_in 0, public: true unless user_signed_in? || signed_request_account.present?
 
         @pinned_statuses   = []
-        @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
-        @featured_hashtags = @account.featured_tags.order(statuses_count: :desc)
+        @endorsed_accounts = unauthorized? ? [] : @account.endorsed_accounts.to_a.sample(4)
+        @featured_hashtags = unauthorized? ? [] : @account.featured_tags.order(statuses_count: :desc)
 
-        if current_account && @account.blocking?(current_account)
+        if unauthorized?
           @statuses = []
           return
         end
@@ -40,16 +44,19 @@ class AccountsController < ApplicationController
       end
 
       format.rss do
-        expires_in 1.minute, public: true
+        return render xml: '', status: 404 if !@account.allow_anonymous? || unauthorized?
+
+        expires_in 1.minute, public: !current_account?
 
-        limit     = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
+        @without_unlisted = true
+        limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
         @statuses = filtered_statuses.without_reblogs.limit(limit)
         @statuses = cache_collection(@statuses, Status)
         render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
       end
 
       format.json do
-        expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?)
+        expires_in 3.minutes, public: !current_account?
         render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
       end
     end
@@ -62,19 +69,27 @@ class AccountsController < ApplicationController
   end
 
   def show_pinned_statuses?
-    [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
+    [threads_requested?, replies_requested?, reblogs_requested?, mentions_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
   end
 
   def filtered_statuses
+    return mentions_scope if mentions_requested?
+
     default_statuses.tap do |statuses|
-      statuses.merge!(hashtag_scope)    if tag_requested?
       statuses.merge!(only_media_scope) if media_requested?
-      statuses.merge!(no_replies_scope) unless replies_requested?
     end
   end
 
   def default_statuses
-    @account.statuses.not_local_only.where(visibility: [:public, :unlisted])
+    @account.statuses.permitted_for(
+      @account,
+      current_account,
+      include_reblogs: !(threads_requested? || replies_requested?),
+      only_reblogs: reblogs_requested?,
+      include_replies: replies_requested?,
+      tag: tag_requested? ? params[:tag] : nil,
+      public: @without_unlisted
+    )
   end
 
   def only_media_scope
@@ -85,18 +100,10 @@ class AccountsController < ApplicationController
     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
   end
 
-  def no_replies_scope
-    Status.without_replies
-  end
-
-  def hashtag_scope
-    tag = Tag.find_normalized(params[:tag])
+  def mentions_scope
+    return Status.none unless current_account?
 
-    if tag
-      Status.tagged_with(tag.id)
-    else
-      Status.none
-    end
+    Status.mentions_between(@account, current_account)
   end
 
   def username_param
@@ -128,8 +135,14 @@ class AccountsController < ApplicationController
       short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id)
     elsif media_requested?
       short_account_media_url(@account, max_id: max_id, min_id: min_id)
+    elsif threads_requested?
+      short_account_threads_url(@account, max_id: max_id, min_id: min_id)
     elsif replies_requested?
       short_account_with_replies_url(@account, max_id: max_id, min_id: min_id)
+    elsif reblogs_requested?
+      short_account_reblogs_url(@account, max_id: max_id, min_id: min_id)
+    elsif mentions_requested?
+      short_account_mentions_url(@account, max_id: max_id, min_id: min_id)
     else
       short_account_url(@account, max_id: max_id, min_id: min_id)
     end
@@ -139,7 +152,13 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?('/media') && !tag_requested?
   end
 
+  def threads_requested?
+    request.path.split('.').first.ends_with?('/threads') && !tag_requested?
+  end
+
   def replies_requested?
+    return false unless current_account&.id == @account.id || @account.show_replies?
+
     request.path.split('.').first.ends_with?('/with_replies') && !tag_requested?
   end
 
@@ -147,6 +166,22 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
   end
 
+  def reblogs_requested?
+    request.path.split('.').first.ends_with?('/reblogs') && !tag_requested?
+  end
+
+  def mentions_requested?
+    request.path.split('.').first.ends_with?('/mentions') && !tag_requested?
+  end
+
+  def blocked?
+    @blocked ||= current_account && @account.blocking?(current_account)
+  end
+
+  def unauthorized?
+    @unauthorized ||= blocked? || (@account.private? && !following?(@account))
+  end
+
   def cached_filtered_status_page
     cache_collection_paginated_by_id(
       filtered_statuses,
diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb
index 08ad952df..5009a9f05 100644
--- a/app/controllers/activitypub/claims_controller.rb
+++ b/app/controllers/activitypub/claims_controller.rb
@@ -4,7 +4,7 @@ class ActivityPub::ClaimsController < ActivityPub::BaseController
   include SignatureVerification
   include AccountOwnedConcern
 
-  skip_before_action :authenticate_user!
+  #skip_before_action :authenticate_user!
 
   before_action :require_signature!
   before_action :set_claim_result
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
index d3044f180..0a3bceb7b 100644
--- a/app/controllers/activitypub/inboxes_controller.rb
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -7,7 +7,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
 
   before_action :skip_unknown_actor_delete
   before_action :require_signature!
-  skip_before_action :authenticate_user!
+  #skip_before_action :authenticate_user!
 
   def create
     upgrade_account
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index 5fd735ad6..e06688994 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -7,12 +7,13 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
   include AccountOwnedConcern
 
   before_action :require_signature!, if: :authorized_fetch_mode?
+  before_action :require_following!, if: -> { @account.private? }
   before_action :set_statuses
   before_action :set_cache_headers
 
   def show
-    expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode? && !(signed_request_account.present? && page_requested?))
-    render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+    expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode? && !(current_account.present? && page_requested?))
+    render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', domain: current_account&.domain
   end
 
   private
@@ -54,11 +55,38 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
     account_outbox_url(@account, page: true, min_id: @statuses.first.id) unless @statuses.empty?
   end
 
+  def permitted_account_statuses
+    @account.statuses.permitted_for(
+      @account,
+      current_account,
+      include_replies: true,
+      include_reblogs: true,
+      public: !(owner? || follower?),
+      exclude_local_only: true
+    )
+  end
+
+  def owner?
+    return @owner if defined?(@owner)
+
+    @owner   = @account.id == current_account&.id
+    @owner ||= @account.moved_to_account_id == current_account&.id if @account.moved_to_account_id.present?
+    @owner
+  end
+
+  def follower?
+    @following ||= current_account&.following?(@account)
+  end
+
+  def mutual_follower?
+    follower? && @account.following?(current_account)
+  end
+
   def set_statuses
     return unless page_requested?
 
     @statuses = cache_collection_paginated_by_id(
-      @account.statuses.permitted_for(@account, signed_request_account),
+      permitted_account_statuses,
       Status,
       LIMIT,
       params_slice(:max_id, :min_id, :since_id)
diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb
index fde6c861f..a12a23fbb 100644
--- a/app/controllers/activitypub/replies_controller.rb
+++ b/app/controllers/activitypub/replies_controller.rb
@@ -14,7 +14,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
 
   def index
     expires_in 0, public: public_fetch_mode?
-    render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
+    render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true, target_domain: current_account&.domain
   end
 
   private
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
deleted file mode 100644
index 71efb543e..000000000
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class CustomEmojisController < BaseController
-    def index
-      authorize :custom_emoji, :index?
-
-      @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
-      @form          = Form::CustomEmojiBatch.new
-    end
-
-    def new
-      authorize :custom_emoji, :create?
-
-      @custom_emoji = CustomEmoji.new
-    end
-
-    def create
-      authorize :custom_emoji, :create?
-
-      @custom_emoji = CustomEmoji.new(resource_params)
-
-      if @custom_emoji.save
-        log_action :create, @custom_emoji
-        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
-      else
-        render :new
-      end
-    end
-
-    def batch
-      @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
-      @form.save
-    rescue ActionController::ParameterMissing
-      flash[:alert] = I18n.t('admin.accounts.no_account_selected')
-    rescue Mastodon::NotPermittedError
-      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
-    ensure
-      redirect_to admin_custom_emojis_path(filter_params)
-    end
-
-    private
-
-    def resource_params
-      params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
-    end
-
-    def filtered_custom_emojis
-      CustomEmojiFilter.new(filter_params).results
-    end
-
-    def filter_params
-      params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS)
-    end
-
-    def action_from_button
-      if params[:update]
-        'update'
-      elsif params[:list]
-        'list'
-      elsif params[:unlist]
-        'unlist'
-      elsif params[:enable]
-        'enable'
-      elsif params[:disable]
-        'disable'
-      elsif params[:copy]
-        'copy'
-      elsif params[:delete]
-        'delete'
-      end
-    end
-
-    def form_custom_emoji_batch_params
-      params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: [])
-    end
-  end
-end
diff --git a/app/controllers/admin/domain_allows_controller.rb b/app/controllers/admin/domain_allows_controller.rb
index 31be1978b..95d9a31fb 100644
--- a/app/controllers/admin/domain_allows_controller.rb
+++ b/app/controllers/admin/domain_allows_controller.rb
@@ -35,6 +35,6 @@ class Admin::DomainAllowsController < Admin::BaseController
   end
 
   def resource_params
-    params.require(:domain_allow).permit(:domain)
+    params.require(:domain_allow).permit(:domain, :hidden)
   end
 end
diff --git a/app/controllers/admin/pending_accounts_controller.rb b/app/controllers/admin/pending_accounts_controller.rb
index b62a9bc84..8a9a51d84 100644
--- a/app/controllers/admin/pending_accounts_controller.rb
+++ b/app/controllers/admin/pending_accounts_controller.rb
@@ -18,19 +18,19 @@ module Admin
     end
 
     def approve_all
-      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'approve').save
+      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.confirmed.pluck(:account_id), action: 'approve').save
       redirect_to admin_pending_accounts_path(current_params)
     end
 
     def reject_all
-      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'reject').save
+      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.confirmed.pluck(:account_id), action: 'reject').save
       redirect_to admin_pending_accounts_path(current_params)
     end
 
     private
 
     def set_accounts
-      @accounts = Account.joins(:user).merge(User.pending.recent).includes(user: :invite_request).page(params[:page])
+      @accounts = Account.joins(:user).merge(User.pending.confirmed.recent).includes(user: :invite_request).page(params[:page])
     end
 
     def form_account_batch_params
diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
index 59df4470e..8abe19626 100644
--- a/app/controllers/admin/tags_controller.rb
+++ b/app/controllers/admin/tags_controller.rb
@@ -54,7 +54,7 @@ module Admin
 
     def set_usage_by_domain
       @usage_by_domain = @tag.statuses
-                             .with_public_visibility
+                             .distributable
                              .excluding_silenced_accounts
                              .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
                              .joins(:account)
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index fe199e689..399d11766 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -7,7 +7,7 @@ class Api::BaseController < ApplicationController
   include RateLimitHeaders
 
   skip_before_action :store_current_location
-  skip_before_action :require_functional!, unless: :whitelist_mode?
+  skip_before_action :require_functional! #, unless: :whitelist_mode?
 
   before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access?
   before_action :set_cache_headers
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index 64b5cb747..886c00282 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -21,7 +21,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   private
 
   def account_params
-    params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value])
+    params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable,
+                  :show_replies, :show_unlisted,
+                  fields_attributes: [:name, :value])
   end
 
   def user_settings_params
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 92ccb8061..91b8629e3 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -8,7 +8,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
 
   def index
     @statuses = load_statuses
-    render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+    render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_account&.id)
   end
 
   private
@@ -17,17 +17,17 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
     @account = Account.find(params[:account_id])
   end
 
+  def owner?
+    @account.id == current_account&.id
+  end
+
   def load_statuses
-    @account.suspended? ? [] : cached_account_statuses
+    unauthorized? ? [] : cached_account_statuses
   end
 
   def cached_account_statuses
     statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
-
     statuses.merge!(only_media_scope) if truthy_param?(:only_media)
-    statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
-    statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
-    statuses.merge!(hashtag_scope)    if params[:tagged].present?
 
     cache_collection_paginated_by_id(
       statuses,
@@ -38,39 +38,64 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def permitted_account_statuses
-    @account.statuses.permitted_for(@account, current_account)
+    return mentions_scope if truthy_param?(:mentions)
+
+    @account.statuses.permitted_for(
+      @account,
+      current_account,
+      include_reblogs: include_reblogs?,
+      include_replies: include_replies?,
+      only_reblogs: only_reblogs?,
+      only_replies: only_replies?,
+      include_unpublished: owner?,
+      tag: params[:tagged]
+    )
   end
 
   def only_media_scope
     Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
   end
 
-  def pinned_scope
-    return Status.none if @account.blocking?(current_account)
+  def unauthorized?
+    @account.suspended? || (@account.private? && !following?(@account))
+  end
 
-    @account.pinned_statuses
+  def include_reblogs?
+    params[:include_reblogs].present? ? truthy_param?(:include_reblogs) : !truthy_param?(:exclude_reblogs)
+  end
+
+  def include_replies?
+    return false unless owner? || @account.show_replies?
+
+    params[:include_replies].present? ? truthy_param?(:include_replies) : !truthy_param?(:exclude_replies)
   end
 
-  def no_replies_scope
-    Status.without_replies
+  def only_reblogs?
+    truthy_param?(:only_reblogs).presence || false
   end
 
-  def no_reblogs_scope
-    Status.without_reblogs
+  def only_replies?
+    return false unless owner? || @account.show_replies?
+
+    truthy_param?(:only_replies).presence || false
   end
 
-  def hashtag_scope
-    tag = Tag.find_normalized(params[:tagged])
+  def mentions_scope
+    return Status.none unless current_account?
+
+    Status.mentions_between(@account, current_account)
+  end
 
-    if tag
-      Status.tagged_with(tag.id)
-    else
-      Status.none
-    end
+  def pinned_scope
+    return Status.none if @account.blocking?(current_account)
+
+    @account.pinned_statuses
   end
 
   def pagination_params(core_params)
-    params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
+    params.slice(:limit, :only_media, :include_replies, :exclude_replies, :only_replies, :include_reblogs, :exclude_reblogs, :only_relogs, :mentions)
+          .permit(:limit, :only_media, :include_replies, :exclude_replies, :only_replies, :include_reblogs, :exclude_reblogs, :only_relogs, :mentions)
+          .merge(core_params)
   end
 
   def insert_pagination_headers
@@ -78,15 +103,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def next_path
-    if records_continue?
-      api_v1_account_statuses_url pagination_params(max_id: pagination_max_id)
-    end
+    api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
   end
 
   def prev_path
-    unless @statuses.empty?
-      api_v1_account_statuses_url pagination_params(min_id: pagination_since_id)
-    end
+    api_v1_account_statuses_url pagination_params(min_id: pagination_since_id) unless @statuses.empty?
   end
 
   def records_continue?
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 3e66ff212..6e909bbf2 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def mute
-    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), duration: (params[:duration] || 0))
+    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only), duration: (params[:duration] || 0))
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb
new file mode 100644
index 000000000..1b150d480
--- /dev/null
+++ b/app/controllers/api/v1/admin/domain_allows_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::DomainAllowsController < Api::BaseController
+  include Authorization
+
+  LIMIT = 100
+
+  before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:domain_allows' }, only: :show
+  before_action :require_staff!
+  after_action :insert_pagination_headers, only: :show
+
+  def show
+    @allows = load_domain_allows
+    render json: @allows
+  end
+
+  private
+
+  def load_domain_allows
+    DomainAllow.paginate_by_max_id(
+      limit_param(LIMIT),
+      params[:max_id],
+      params[:since_id]
+    )
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    api_v1_admin_domain_allows_url pagination_params(max_id: pagination_max_id) if records_continue?
+  end
+
+  def prev_path
+    api_v1_admin_domain_allows_url pagination_params(since_id: pagination_since_id) unless @allows.empty?
+  end
+
+  def pagination_max_id
+    @allows.last.id
+  end
+
+  def pagination_since_id
+    @allows.first.id
+  end
+
+  def records_continue?
+    @allows.size == limit_param(LIMIT)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+end
diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb
new file mode 100644
index 000000000..c0ce0da25
--- /dev/null
+++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::DomainBlocksController < Api::BaseController
+  include Authorization
+
+  LIMIT = 100
+
+  before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:domain_blocks' }, only: :show
+  before_action :require_staff!
+  after_action :insert_pagination_headers, only: :show
+
+  def show
+    @blocks = load_domain_blocks
+    render json: @blocks
+  end
+
+  private
+
+  def load_domain_blocks
+    DomainBlock.paginate_by_max_id(
+      limit_param(LIMIT),
+      params[:max_id],
+      params[:since_id]
+    )
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    api_v1_admin_domain_blocks_url pagination_params(max_id: pagination_max_id) if records_continue?
+  end
+
+  def prev_path
+    api_v1_admin_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty?
+  end
+
+  def pagination_max_id
+    @blocks.last.id
+  end
+
+  def pagination_since_id
+    @blocks.first.id
+  end
+
+  def records_continue?
+    @blocks.size == limit_param(LIMIT)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+end
diff --git a/app/controllers/api/v1/domain_permissions_controller.rb b/app/controllers/api/v1/domain_permissions_controller.rb
new file mode 100644
index 000000000..1b0e37135
--- /dev/null
+++ b/app/controllers/api/v1/domain_permissions_controller.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+class Api::V1::DomainPermissionsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:domain_permissions', :'read:domain_permissions:account' }, only: :show
+  before_action -> { doorkeeper_authorize! :write, :'write:domain_permissions', :'write:domain_permissions:account' }, only: [:create, :update, :destroy]
+  before_action :require_user!
+  before_action :set_permission, except: [:show, :create]
+  after_action :insert_pagination_headers
+
+  LIMIT = 100
+
+  def show
+    @permissions = load_account_domain_permissions
+    render json: @permissions, each_serializer: REST::AccountDomainPermissionSerializer
+  end
+
+  def create
+    @permission = current_account.domain_permissions.create!(domain_permission_params)
+    render json: @permission, serializer: REST::AccountDomainPermissionSerializer
+  end
+
+  def update
+    @permission.update!(domain_permission_params)
+    render json: @permission, serializer: REST::AccountDomainPermissionSerializer
+  end
+
+  def destroy
+    @permission.destroy!
+    render_empty
+  end
+
+  private
+
+  def load_account_domain_permissions
+    account_domain_permissions.paginate_by_max_id(
+      limit_param(LIMIT),
+      params[:max_id],
+      params[:since_id]
+    )
+  end
+
+  def set_permission
+    @permission = current_account.domain_permissions.find(params[:id])
+  end
+
+  def account_domain_permissions
+    current_account.domain_permissions
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    api_v1_domain_permissions_url pagination_params(max_id: pagination_max_id) if records_continue?
+  end
+
+  def prev_path
+    api_v1_domain_permissions_url pagination_params(since_id: pagination_since_id) unless @permissions.empty?
+  end
+
+  def pagination_max_id
+    @permissions.last.id
+  end
+
+  def pagination_since_id
+    @permissions.first.id
+  end
+
+  def records_continue?
+    @permissions.size == limit_param(LIMIT)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+
+  def domain_permission_params
+    params.permit(:domain, :visibility)
+  end
+end
diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb
index 4f6b4bcbf..f2ac902e1 100644
--- a/app/controllers/api/v1/instances/activity_controller.rb
+++ b/app/controllers/api/v1/instances/activity_controller.rb
@@ -4,7 +4,7 @@ class Api::V1::Instances::ActivityController < Api::BaseController
   before_action :require_enabled_api!
 
   skip_before_action :set_cache_headers
-  skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
+  skip_before_action :require_authenticated_user! #, unless: :whitelist_mode?
 
   def show
     expires_in 1.day, public: true
@@ -33,6 +33,6 @@ class Api::V1::Instances::ActivityController < Api::BaseController
   end
 
   def require_enabled_api!
-    head 404 unless Setting.activity_api_enabled && !whitelist_mode?
+    head 404 unless Setting.activity_api_enabled #&& !whitelist_mode?
   end
 end
diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb
index 9fa440935..d30ef1fe9 100644
--- a/app/controllers/api/v1/instances/peers_controller.rb
+++ b/app/controllers/api/v1/instances/peers_controller.rb
@@ -4,7 +4,7 @@ class Api::V1::Instances::PeersController < Api::BaseController
   before_action :require_enabled_api!
 
   skip_before_action :set_cache_headers
-  skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
+  skip_before_action :require_authenticated_user! #, unless: :whitelist_mode?
 
   def index
     expires_in 1.day, public: true
@@ -14,6 +14,6 @@ class Api::V1::Instances::PeersController < Api::BaseController
   private
 
   def require_enabled_api!
-    head 404 unless Setting.peers_api_enabled && !whitelist_mode?
+    head 404 unless Setting.peers_api_enabled #&& !whitelist_mode?
   end
 end
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
index 5b5058a7b..844bab68a 100644
--- a/app/controllers/api/v1/instances_controller.rb
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -2,7 +2,7 @@
 
 class Api::V1::InstancesController < Api::BaseController
   skip_before_action :set_cache_headers
-  skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
+  skip_before_action :require_authenticated_user! #, unless: :whitelist_mode?
 
   def show
     expires_in 3.minutes, public: true
diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb
index 513b937ef..91ca96ef0 100644
--- a/app/controllers/api/v1/polls/votes_controller.rb
+++ b/app/controllers/api/v1/polls/votes_controller.rb
@@ -17,6 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
   def set_poll
     @poll = Poll.attached.find(params[:poll_id])
     authorize @poll.status, :show?
+    authorize @poll.status.reblog, :show? if @poll.status.reblog?
   rescue Mastodon::NotPermittedError
     not_found
   end
diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb
index 6435e9f0d..75f5a9f08 100644
--- a/app/controllers/api/v1/polls_controller.rb
+++ b/app/controllers/api/v1/polls_controller.rb
@@ -16,6 +16,7 @@ class Api::V1::PollsController < Api::BaseController
   def set_poll
     @poll = Poll.attached.find(params[:id])
     authorize @poll.status, :show?
+    authorize @poll.status.reblog, :show? if @poll.status.reblog?
   rescue Mastodon::NotPermittedError
     not_found
   end
diff --git a/app/controllers/api/v1/statuses/hides_controller.rb b/app/controllers/api/v1/statuses/hides_controller.rb
new file mode 100644
index 000000000..8c5457c82
--- /dev/null
+++ b/app/controllers/api/v1/statuses/hides_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::HidesController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write, :'write:mutes' }
+  before_action :require_user!
+  before_action :set_status
+
+  def create
+    MuteStatusService.new.call(current_account, @status)
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  def destroy
+    current_account.unmute_status!(@status)
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  private
+
+  def set_status
+    @status = Status.find(params[:status_id])
+    authorize @status, :show?
+  rescue Mastodon::NotPermittedError
+    not_found
+  end
+end
diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb
index 87071a2b9..418c19840 100644
--- a/app/controllers/api/v1/statuses/mutes_controller.rb
+++ b/app/controllers/api/v1/statuses/mutes_controller.rb
@@ -9,12 +9,14 @@ class Api::V1::Statuses::MutesController < Api::BaseController
   before_action :set_conversation
 
   def create
-    current_account.mute_conversation!(@conversation)
+    MuteConversationService.new.call(current_account, @status.conversation)
     @mutes_map = { @conversation.id => true }
 
     render json: @status, serializer: REST::StatusSerializer
   end
 
+  alias update create
+
   def destroy
     current_account.unmute_conversation!(@conversation)
     @mutes_map = { @conversation.id => false }
diff --git a/app/controllers/api/v1/statuses/publishing_controller.rb b/app/controllers/api/v1/statuses/publishing_controller.rb
new file mode 100644
index 000000000..97c052e22
--- /dev/null
+++ b/app/controllers/api/v1/statuses/publishing_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::PublishingController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write, :'write:statuses:publish' }
+  before_action :require_user!
+  before_action :set_status
+
+  def create
+    PublishStatusService.new.call(@status)
+
+    render json: @status,
+           serializer: (@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer),
+           source_requested: truthy_param?(:source)
+  end
+
+  private
+
+  def set_status
+    @status = Status.unpublished.find(params[:status_id])
+    authorize @status, :destroy?
+  rescue Mastodon::NotPermittedError
+    not_found
+  end
+end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index c8529318f..c7c429bfb 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -19,7 +19,7 @@ class Api::V1::StatusesController < Api::BaseController
 
   def show
     @status = cache_collection([@status], Status).first
-    render json: @status, serializer: REST::StatusSerializer
+    render json: @status, serializer: REST::StatusSerializer, source_requested: truthy_param?(:source)
   end
 
   def context
@@ -31,7 +31,7 @@ class Api::V1::StatusesController < Api::BaseController
     @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
     statuses = [@status] + @context.ancestors + @context.descendants
 
-    render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
+    render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id), current_account_id: current_user&.account_id
   end
 
   def create
@@ -41,24 +41,82 @@ class Api::V1::StatusesController < Api::BaseController
                                          media_ids: status_params[:media_ids],
                                          sensitive: status_params[:sensitive],
                                          spoiler_text: status_params[:spoiler_text],
+                                         title: status_params[:title],
+                                         footer: status_params[:footer],
+                                         notify: status_params[:notify],
+                                         publish: status_params[:publish],
                                          visibility: status_params[:visibility],
+                                         local_only: status_params[:local_only],
                                          scheduled_at: status_params[:scheduled_at],
                                          application: doorkeeper_token.application,
                                          poll: status_params[:poll],
                                          content_type: status_params[:content_type],
+                                         tags: parse_tags_param(status_params[:tags]),
+                                         mentions: parse_mentions_param(status_params[:mentions]),
                                          idempotency: request.headers['Idempotency-Key'],
-                                         with_rate_limit: true)
+                                         with_rate_limit: true,
+                                         expires_at: status_params[:expires_at],
+                                         publish_at: status_params[:publish_at])
 
-    render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
+    render json: @status,
+           serializer: (@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer),
+           source_requested: truthy_param?(:source)
+  end
+
+  def update
+    @status = Status.where(account_id: current_user.account).find(params[:id])
+    authorize @status, :destroy?
+
+    @status = PostStatusService.new.call(current_user.account,
+                                         text: status_params[:status],
+                                         thread: @thread,
+                                         media_ids: status_params[:media_ids],
+                                         sensitive: status_params[:sensitive],
+                                         spoiler_text: status_params[:spoiler_text],
+                                         title: status_params[:title],
+                                         footer: status_params[:footer],
+                                         notify: status_params[:notify],
+                                         publish: status_params[:publish],
+                                         visibility: status_params[:visibility],
+                                         local_only: status_params[:local_only],
+                                         scheduled_at: status_params[:scheduled_at],
+                                         application: doorkeeper_token.application,
+                                         poll: status_params[:poll],
+                                         content_type: status_params[:content_type],
+                                         status: @status,
+                                         tags: parse_tags_param(status_params[:tags]),
+                                         mentions: parse_mentions_param(status_params[:mentions]),
+                                         idempotency: request.headers['Idempotency-Key'],
+                                         with_rate_limit: true,
+                                         expires_at: status_params[:expires_at],
+                                         publish_at: status_params[:publish_at])
+
+    render json: @status,
+           serializer: (@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer),
+           source_requested: truthy_param?(:source)
   end
 
   def destroy
     @status = Status.where(account_id: current_user.account).find(params[:id])
     authorize @status, :destroy?
 
-    @status.discard
-    RemovalWorker.perform_async(@status.id, redraft: true)
-    @status.account.statuses_count = @status.account.statuses_count - 1
+    if !(current_user.setting_unpublish_on_delete && @status.published?) || truthy_param?(:redraft)
+      @status.discard
+      RemovalWorker.perform_async(@status.id, redraft: true, unpublished: true)
+      @status.account.statuses_count = @status.account.statuses_count - 1
+    else
+      RemovalWorker.perform_async(@status.id, redraft: true, unpublish: true)
+      tag_script = "#!redraft #{@status.id}\n"
+      @status.text = "#{tag_script}#{@status.text.sub(/^\s*#!redraft \d+\n/, '')}"
+      @status.original_text = "#{tag_script}#{@status.original_text.sub(/^\s*#!redraft \d+\n/, '')}"
+    end
+
+    @status.local_only = @status.originally_local_only?
+    unless @status.original_text.match?(/^\s*#!\s*federate\b/i)
+      tag_script = "#!federate #{@status.originally_local_only? ? 'off' : 'on'}\n"
+      @status.text.prepend(tag_script)
+      @status.original_text.prepend(tag_script)
+    end
 
     render json: @status, serializer: REST::StatusSerializer, source_requested: true
   end
@@ -84,9 +142,18 @@ class Api::V1::StatusesController < Api::BaseController
       :in_reply_to_id,
       :sensitive,
       :spoiler_text,
+      :title,
+      :footer,
+      :notify,
+      :publish,
       :visibility,
+      :local_only,
       :scheduled_at,
       :content_type,
+      :expires_at,
+      :publish_at,
+      tags: [],
+      mentions: [],
       media_ids: [],
       poll: [
         :multiple,
@@ -100,4 +167,26 @@ class Api::V1::StatusesController < Api::BaseController
   def pagination_params(core_params)
     params.slice(:limit).permit(:limit).merge(core_params)
   end
+
+  def parse_tags_param(tags_param)
+    return if tags_param.blank?
+
+    tags_param.select { |value| value.respond_to?(:to_str) && value.present? }
+  end
+
+  def parse_mentions_param(mentions_param)
+    return if mentions_param.blank?
+
+    mentions_param.map do |value|
+      next if value.blank?
+
+      value = value.split('@', 3) if value.respond_to?(:to_str)
+      next unless value.is_a?(Enumerable)
+
+      mentioned_account = Account.find_by(username: value[0], domain: value[1])
+      next if mentioned_account.nil? || mentioned_account.suspended?
+
+      mentioned_account
+    end
+  end
 end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e996c2217..9074e6450 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
   include SessionTrackingConcern
   include CacheConcern
   include DomainControlHelper
+  include SignatureVerification
 
   helper_method :current_account
   helper_method :current_session
@@ -44,11 +45,11 @@ class ApplicationController < ActionController::Base
   private
 
   def https_enabled?
-    Rails.env.production? && !request.path.start_with?('/health')
+    Rails.env.production? && !request.path.start_with?('/health', '/_matrix-internal/')
   end
 
   def authorized_fetch_mode?
-    ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode
+    !(current_account&.allow_anonymous? || (@account&.id && current_user&.account_id == @account.id) || Rails.env.development? || Rails.env.test?)
   end
 
   def public_fetch_mode?
@@ -68,7 +69,29 @@ class ApplicationController < ActionController::Base
   end
 
   def require_functional!
-    redirect_to edit_user_registration_path unless current_user.functional?
+    redirect_to edit_user_registration_path unless current_user&.functional?
+  end
+
+  def require_authenticated!
+    return if current_account?
+
+    respond_to do |format|
+      format.any { redirect_to edit_user_registration_path }
+      format.json { forbidden }
+    end
+  end
+
+  def require_known!(account)
+    return if authenticated_or_following?(account)
+
+    respond_to do |format|
+      format.any { redirect_to edit_user_registration_path }
+      format.json { forbidden }
+    end
+  end
+
+  def require_following!
+    forbidden unless @account.present? && following?(@account)
   end
 
   def after_sign_out_path_for(_resource_or_scope)
@@ -197,7 +220,7 @@ class ApplicationController < ActionController::Base
   def current_account
     return @current_account if defined?(@current_account)
 
-    @current_account = current_user&.account
+    @current_account = current_user&.account.presence || signed_request_account
   end
 
   def current_session
@@ -225,4 +248,21 @@ class ApplicationController < ActionController::Base
       format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code }
     end
   end
+
+  def following?(account)
+    return if account.blank?
+
+    @account_following ||= {}
+    return @account_following[account.id] if @account_following[account.id].present?
+
+    @account_following[account.id] = current_account.present? && (current_account.id == account.id || current_account.following?(account))
+  end
+
+  def authenticated_or_following?(account)
+    current_user&.account.present? || following?(account)
+  end
+
+  def current_account?
+    current_account.present?
+  end
 end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 23e5a22e1..4dedef817 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -35,6 +35,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     end
   end
 
+  def create
+    super do |resource|
+      return redirect_to root_path, notice: I18n.t('about.registration.failed_kobold')  if resource.destroyed?
+    end
+  end
+
   protected
 
   def update_resource(resource, params)
@@ -55,7 +61,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   def configure_sign_up_params
     devise_parameter_sanitizer.permit(:sign_up) do |u|
-      u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement)
+      u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :username, :email, :password, :password_confirmation, :kobold, :invite_code, :agreement)
     end
   end
 
diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb
index 62e379846..568011c9e 100644
--- a/app/controllers/concerns/account_owned_concern.rb
+++ b/app/controllers/concerns/account_owned_concern.rb
@@ -4,7 +4,7 @@ module AccountOwnedConcern
   extend ActiveSupport::Concern
 
   included do
-    before_action :authenticate_user!, if: -> { whitelist_mode? && request.format != :json }
+    #before_action :authenticate_user!, if: -> { whitelist_mode? && request.format != :json }
     before_action :set_account, if: :account_required?
     before_action :check_account_approval, if: :account_required?
     before_action :check_account_suspension, if: :account_required?
diff --git a/app/controllers/custom_emojis_controller.rb b/app/controllers/custom_emojis_controller.rb
new file mode 100644
index 000000000..0ef8d0a50
--- /dev/null
+++ b/app/controllers/custom_emojis_controller.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+class CustomEmojisController < ApplicationController
+  include Authorization
+  include AccountableConcern
+
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_pack
+  before_action :set_body_classes
+
+  def index
+    authorize :custom_emoji, :index?
+
+    @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
+    @form          = Form::CustomEmojiBatch.new
+  end
+
+  def new
+    authorize :custom_emoji, :create?
+
+    @custom_emoji = CustomEmoji.new(account: current_account)
+  end
+
+  def create
+    authorize :custom_emoji, :create?
+
+    @custom_emoji = CustomEmoji.new(resource_params.merge(account: current_account))
+
+    if @custom_emoji.save
+      log_action :create, @custom_emoji
+      redirect_to custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
+    else
+      render :new
+    end
+  end
+
+  def batch
+    @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form.save
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+  rescue Mastodon::NotPermittedError
+    flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+  ensure
+    redirect_to custom_emojis_path(filter_params)
+  end
+
+  private
+
+  def resource_params
+    params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
+  end
+
+  def filtered_custom_emojis
+    CustomEmojiFilter.new(filter_params, current_account).results
+  end
+
+  def filter_params
+    params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS)
+  end
+
+  def action_from_button
+    if params[:update]
+      'update'
+    elsif params[:list]
+      'list'
+    elsif params[:unlist]
+      'unlist'
+    elsif params[:enable]
+      'enable'
+    elsif params[:disable]
+      'disable'
+    elsif params[:copy]
+      'copy'
+    elsif params[:delete]
+      'delete'
+    elsif params[:claim]
+      'claim'
+    elsif params[:unclaim]
+      'unclaim'
+    end
+  end
+
+  def form_custom_emoji_batch_params
+    params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: [])
+  end
+
+  def set_pack
+    use_pack 'settings'
+  end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index c9b840881..d15adbf62 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -47,7 +47,7 @@ class HomeController < ApplicationController
   end
 
   def default_redirect_path
-    if request.path.start_with?('/web') || whitelist_mode?
+    if request.path.start_with?('/web') #|| whitelist_mode?
       new_user_session_path
     elsif single_user_mode?
       short_account_path(Account.local.without_suspended.where('id > 0').first)
diff --git a/app/controllers/matrix/base_controller.rb b/app/controllers/matrix/base_controller.rb
new file mode 100644
index 000000000..5922501ec
--- /dev/null
+++ b/app/controllers/matrix/base_controller.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+class Matrix::BaseController < ApplicationController
+  include RateLimitHeaders
+
+  skip_before_action :store_current_location
+  skip_before_action :require_functional!
+
+  before_action :set_cache_headers
+
+  protect_from_forgery with: :null_session
+
+  skip_around_action :set_locale
+
+  rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
+    render json: { success: false, error: e.to_s }, status: 422
+  end
+
+  rescue_from ActiveRecord::RecordNotUnique do
+    render json: { success: false, error: 'Duplicate record' }, status: 422
+  end
+
+  rescue_from ActiveRecord::RecordNotFound do
+    render json: { success: false, error: 'Record not found' }, status: 404
+  end
+
+  rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do
+    render json: { success: false, error: 'Remote data could not be fetched' }, status: 503
+  end
+
+  rescue_from OpenSSL::SSL::SSLError do
+    render json: { success: false, error: 'Remote SSL certificate could not be verified' }, status: 503
+  end
+
+  rescue_from Mastodon::NotPermittedError do
+    render json: { success: false, error: 'This action is not allowed' }, status: 403
+  end
+
+  rescue_from Mastodon::RaceConditionError do
+    render json: { success: false, error: 'There was a temporary problem serving your request, please try again' }, status: 503
+  end
+
+  rescue_from Mastodon::RateLimitExceededError do
+    render json: { auth: { success: false }, success: false, error: I18n.t('errors.429') }, status: 429
+  end
+
+  rescue_from ActionController::ParameterMissing do |e|
+    render json: { success: false, error: e.to_s }, status: 400
+  end
+
+  def doorkeeper_unauthorized_render_options(error: nil)
+    { json: { success: false, error: (error.try(:description) || 'Not authorized') } }
+  end
+
+  def doorkeeper_forbidden_render_options(*)
+    { json: { success: false, error: 'This action is outside the authorized scopes' } }
+  end
+
+  protected
+
+  def current_resource_owner
+    @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
+  end
+
+  def current_user
+    current_resource_owner || super
+  rescue ActiveRecord::RecordNotFound
+    nil
+  end
+
+  def require_authenticated_user!
+    render json: { success: false, error: 'This method requires an authenticated user' }, status: 401 unless current_user
+  end
+
+  def require_user!
+    if !current_user
+      render json: { success: false, error: 'This method requires an authenticated user' }, status: 422
+    elsif current_user.disabled?
+      render json: { success: false, error: 'Your login is currently disabled' }, status: 403
+    elsif !current_user.confirmed?
+      render json: { success: false, error: 'Your login is missing a confirmed e-mail address' }, status: 403
+    elsif !current_user.approved?
+      render json: { success: false, error: 'Your login is currently pending approval' }, status: 403
+    else
+      set_user_activity
+    end
+  end
+
+  def render_empty
+    render json: {}, status: 200
+  end
+
+  def authorize_if_got_token!(*scopes)
+    doorkeeper_authorize!(*scopes) if doorkeeper_token
+  end
+
+  def set_cache_headers
+    response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
+  end
+end
diff --git a/app/controllers/matrix/identity/v1/check_credentials_controller.rb b/app/controllers/matrix/identity/v1/check_credentials_controller.rb
new file mode 100644
index 000000000..1770c6767
--- /dev/null
+++ b/app/controllers/matrix/identity/v1/check_credentials_controller.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class Matrix::Identity::V1::CheckCredentialsController < Matrix::BaseController
+  def create
+    matrix_profile = matrix_profile_json
+    return render json: fail_json if matrix_profile.blank?
+
+    render json: matrix_profile
+  rescue ActionController::ParameterMissing, ActiveRecord::RecordNotFound
+    render json: fail_json
+  end
+
+  private
+
+  def resource_params
+    params.require(:user).permit(:id, :password)
+  end
+
+  def matrix_domains
+    ENV.fetch('MATRIX_AUTH_DOMAINS', '').delete(',').split.to_set
+  end
+
+  def matrix_profile_json
+    user_params = resource_params
+    return unless user_params[:id].present? && user_params[:password].present? && user_params[:id][0] == '@'
+
+    (username, domain) = user_params[:id].downcase.split(':', 2)
+    return unless matrix_domains.include?(domain)
+
+    user = User.find_by_lower_username!(username[1..-1])
+    return unless user.valid_password?(user_params[:password])
+
+    {
+      auth: {
+        success: true,
+        mxid: "@#{username}:#{domain}",
+        profile: {
+          display_name: user.account.display_name.presence || user.username,
+          three_pids: [
+            {
+              medium: 'email',
+              address: user.email,
+            },
+          ]
+        }
+      }
+    }
+  end
+
+  def fail_json
+    { auth: { success: false } }
+  end
+end
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 772fc42cb..db8ccd173 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -4,9 +4,9 @@ class MediaController < ApplicationController
   include Authorization
 
   skip_before_action :store_current_location
-  skip_before_action :require_functional!, unless: :whitelist_mode?
+  skip_before_action :require_functional! #, unless: :whitelist_mode?
 
-  before_action :authenticate_user!, if: :whitelist_mode?
+  #before_action :authenticate_user!, if: :whitelist_mode?
   before_action :set_media_attachment
   before_action :verify_permitted_status!
   before_action :check_playable, only: :player
@@ -33,6 +33,7 @@ class MediaController < ApplicationController
 
   def verify_permitted_status!
     authorize @media_attachment.status, :show?
+    authorize @media_attachment.status.reblog, :show? if @media_attachment.status.reblog?
   rescue Mastodon::NotPermittedError
     not_found
   end
diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb
index 0b1d09de9..ee7568a33 100644
--- a/app/controllers/media_proxy_controller.rb
+++ b/app/controllers/media_proxy_controller.rb
@@ -7,7 +7,7 @@ class MediaProxyController < ApplicationController
   skip_before_action :store_current_location
   skip_before_action :require_functional!
 
-  before_action :authenticate_user!, if: :whitelist_mode?
+  #before_action :authenticate_user!, if: :whitelist_mode?
 
   rescue_from ActiveRecord::RecordInvalid, with: :not_found
   rescue_from Mastodon::UnexpectedResponseError, with: :not_found
@@ -19,6 +19,7 @@ class MediaProxyController < ApplicationController
       if lock.acquired?
         @media_attachment = MediaAttachment.remote.attached.find(params[:id])
         authorize @media_attachment.status, :show?
+        authorize @media_attachment.status.reblog, :show? if @media_attachment.status.reblog?
         redownload! if @media_attachment.needs_redownload? && !reject_media?
       else
         raise Mastodon::RaceConditionError
diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb
index a277bfa10..5ead3aaa0 100644
--- a/app/controllers/remote_interaction_controller.rb
+++ b/app/controllers/remote_interaction_controller.rb
@@ -5,13 +5,13 @@ class RemoteInteractionController < ApplicationController
 
   layout 'modal'
 
-  before_action :authenticate_user!, if: :whitelist_mode?
+  #before_action :authenticate_user!, if: :whitelist_mode?
   before_action :set_interaction_type
   before_action :set_status
   before_action :set_body_classes
   before_action :set_pack
 
-  skip_before_action :require_functional!, unless: :whitelist_mode?
+  skip_before_action :require_functional! #, unless: :whitelist_mode?
 
   def new
     @remote_follow = RemoteFollow.new(session_params)
diff --git a/app/controllers/settings/preferences/filters_controller.rb b/app/controllers/settings/preferences/filters_controller.rb
new file mode 100644
index 000000000..c58a698ef
--- /dev/null
+++ b/app/controllers/settings/preferences/filters_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Settings::Preferences::FiltersController < Settings::PreferencesController
+  private
+
+  def after_update_redirect_path
+    settings_preferences_filters_path
+  end
+end
diff --git a/app/controllers/settings/preferences/privacy_controller.rb b/app/controllers/settings/preferences/privacy_controller.rb
new file mode 100644
index 000000000..f447fa598
--- /dev/null
+++ b/app/controllers/settings/preferences/privacy_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Settings::Preferences::PrivacyController < Settings::PreferencesController
+  private
+
+  def after_update_redirect_path
+    settings_preferences_privacy_path
+  end
+end
diff --git a/app/controllers/settings/preferences/publishing_controller.rb b/app/controllers/settings/preferences/publishing_controller.rb
new file mode 100644
index 000000000..5b298d94d
--- /dev/null
+++ b/app/controllers/settings/preferences/publishing_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Settings::Preferences::PublishingController < Settings::PreferencesController
+  private
+
+  def after_update_redirect_path
+    settings_preferences_publishing_path
+  end
+end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index d05ceb53f..e8d45ff2a 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -4,7 +4,11 @@ class Settings::PreferencesController < Settings::BaseController
   def show; end
 
   def update
-    user_settings.update(user_settings_params.to_h)
+    old_home_reblogs = current_user.home_reblogs?
+  
+    if user_settings.update(user_settings_params.to_h)
+      ClearReblogsWorker.perform_async(current_user.account_id) unless old_home_reblogs == current_user.home_reblogs? || current_user.home_reblogs?
+    end
 
     if current_user.update(user_params)
       I18n.locale = current_user.locale
@@ -50,7 +54,6 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_noindex,
       :setting_hide_network,
       :setting_hide_followers_count,
-      :setting_aggregate_reblogs,
       :setting_show_application,
       :setting_advanced_layout,
       :setting_default_content_type,
@@ -58,6 +61,25 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_use_pending_items,
       :setting_trends,
       :setting_crop_images,
+      :setting_manual_publish,
+      :setting_style_dashed_nest,
+      :setting_style_underline_a,
+      :setting_style_css_profile,
+      :setting_style_css_webapp,
+      :setting_style_wide_media,
+      :setting_style_lowercase,
+      :setting_publish_in,
+      :setting_unpublish_in,
+      :setting_unpublish_delete,
+      :setting_boost_every,
+      :setting_boost_jitter,
+      :setting_boost_random,
+      :setting_filter_unknown,
+      :setting_unpublish_on_delete,
+      :setting_home_reblogs,
+      :setting_max_history_public,
+      :setting_max_history_private,
+      :setting_web_push,
       notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
       interactions: %i(must_be_follower must_be_following must_be_following_dm)
     )
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 0c15447a6..93d08ee4b 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -20,7 +20,9 @@ class Settings::ProfilesController < Settings::BaseController
   private
 
   def account_params
-    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value])
+    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable,
+                                    :show_replies, :show_unlisted, :private, :allow_anonymous, :no_verify_auth,
+                                    fields_attributes: [:name, :value])
   end
 
   def set_account
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index a6ab8828f..27575e414 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -10,6 +10,7 @@ class StatusesController < ApplicationController
 
   before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
   before_action :set_status
+  before_action :require_following!, if: -> { @account.private? && !(@status.public_visibility? || @status.unlisted_visibility?) }
   before_action :set_instance_presenter
   before_action :set_link_headers
   before_action :redirect_to_original, only: :show
@@ -19,7 +20,7 @@ class StatusesController < ApplicationController
   before_action :set_autoplay, only: :embed
 
   skip_around_action :set_locale, if: -> { request.format == :json }
-  skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
+  skip_before_action :require_functional!, only: [:show, :embed] # , unless: :whitelist_mode?
 
   content_security_policy only: :embed do |p|
     p.frame_ancestors(false)
@@ -37,14 +38,24 @@ class StatusesController < ApplicationController
 
       format.json do
         expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
-        render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
+        render_with_cache json: @status,
+                          content_type: 'application/activity+json',
+                          serializer: ActivityPub::NoteSerializer,
+                          adapter: ActivityPub::Adapter,
+                          domain: current_account&.domain,
+                          key: "statuses/json:#{current_account&.id}:#{@status.id}"
       end
     end
   end
 
   def activity
     expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
-    render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
+    render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status, current_account&.domain, embed: true),
+                      content_type: 'application/activity+json',
+                      serializer: ActivityPub::ActivitySerializer,
+                      adapter: ActivityPub::Adapter,
+                      domain: current_account&.domain,
+                      key: "statuses/activity:#{current_account&.id}:#{@status.id}"
   end
 
   def embed
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 64736e77f..1f703085f 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -9,14 +9,14 @@ class TagsController < ApplicationController
   layout 'public'
 
   before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
-  before_action :authenticate_user!, if: :whitelist_mode?
+  # before_action :authenticate_user!, if: :whitelist_mode?
   before_action :set_local
   before_action :set_tag
   before_action :set_statuses
   before_action :set_body_classes
   before_action :set_instance_presenter
 
-  skip_before_action :require_functional!, unless: :whitelist_mode?
+  skip_before_action :require_functional! # , unless: :whitelist_mode?
 
   def show
     respond_to do |format|
@@ -32,7 +32,7 @@ class TagsController < ApplicationController
 
       format.json do
         expires_in 3.minutes, public: public_fetch_mode?
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', domain: current_account&.domain
       end
     end
   end
diff --git a/app/controllers/user_profile_css_controller.rb b/app/controllers/user_profile_css_controller.rb
new file mode 100644
index 000000000..0a0588e88
--- /dev/null
+++ b/app/controllers/user_profile_css_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class UserProfileCssController < ApplicationController
+  skip_before_action :store_current_location
+  skip_before_action :require_functional!
+
+  before_action :set_cache_headers
+  before_action :set_account
+
+  def show
+    expires_in 3.minutes, public: true
+    render plain: css, content_type: 'text/css'
+  end
+
+  private
+
+  def css
+    @account.user&.setting_style_css_profile_errors.blank? ? (@account.user&.setting_style_css_profile || '') : ''
+  end
+
+  def set_account
+    @account = Account.find(params[:id])
+  end
+end
diff --git a/app/controllers/user_webapp_css_controller.rb b/app/controllers/user_webapp_css_controller.rb
new file mode 100644
index 000000000..3acb21bb1
--- /dev/null
+++ b/app/controllers/user_webapp_css_controller.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+class UserWebappCssController < ApplicationController
+  skip_before_action :store_current_location
+  skip_before_action :require_functional!
+
+  before_action :set_account
+
+  def show
+    render plain: css, content_type: 'text/css'
+  end
+
+  private
+
+  def css_dashed_nest
+    return unless @account.user&.setting_style_dashed_nest
+
+    %(
+      div[data-nest-level]
+      { border-style: dashed; }
+    )
+  end
+
+  def css_underline_a
+    return unless @account.user&.setting_style_underline_a
+
+    %(
+      .status__content__text a,
+      .reply-indicator__content a,
+      .composer--reply > .content a,
+      .account__header__content a
+      { text-decoration: underline; }
+
+      .status__content__text a:hover,
+      .reply-indicator__content a:hover,
+      .composer--reply > .content a:hover,
+      .account__header__content a:hover
+      { text-decoration: none; }
+    )
+  end
+
+  def css_wide_media
+    return unless @account.user&.setting_style_wide_media
+
+    %(
+      .media-gallery
+      { height: auto !important; }
+
+      .media-gallery__item
+      { width: 100% !important; }
+
+      .spoiler-button + .media-gallery__item
+      { height: 5em !important; }
+
+      .spoiler-button--minified + .media-gallery__item
+      { height: 280px !important; }
+    )
+  end
+
+  def css_lowercase
+    return unless @account.user&.setting_style_lowercase
+
+    %(
+      div, button, span
+      { text-transform: lowercase; }
+
+      code, pre
+      { text-transform: initial !important; }
+    )
+  end
+
+  def css_webapp
+    @account.user&.setting_style_css_webapp_errors.blank? ? (@account.user&.setting_style_css_webapp || '') : ''
+  end
+
+  def css
+    "#{css_dashed_nest}\n#{css_underline_a}\n#{css_wide_media}\n#{css_lowercase}\n#{css_webapp}".squish
+  end
+
+  def set_account
+    @account = Account.find(params[:id])
+  end
+end
diff --git a/app/helpers/domain_control_helper.rb b/app/helpers/domain_control_helper.rb
index ac60cad29..cd51a8b7e 100644
--- a/app/helpers/domain_control_helper.rb
+++ b/app/helpers/domain_control_helper.rb
@@ -12,14 +12,12 @@ module DomainControlHelper
       end
     end
 
-    if whitelist_mode?
-      !DomainAllow.allowed?(domain)
-    else
-      DomainBlock.blocked?(domain)
-    end
+    domain != Rails.configuration.x.local_domain && (!DomainAllow.allowed?(domain) || DomainBlock.blocked?(domain))
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
+    nil
   end
 
   def whitelist_mode?
-    Rails.configuration.x.whitelist_mode
+    !(Rails.env.development? || Rails.env.test?)
   end
 end
diff --git a/app/helpers/img_proxy_helper.rb b/app/helpers/img_proxy_helper.rb
new file mode 100644
index 000000000..5ea4bcd93
--- /dev/null
+++ b/app/helpers/img_proxy_helper.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+#                  .~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.                  #
+###################              Cthulhu Code!              ###################
+#                  `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`                  #
+# - Has a high complexity level and needs tests.                              #
+# - Makes many assumptions the environment it's included into.                #
+# - Incurs a high performance penalty.                                        #
+#                                                                             #
+###############################################################################
+
+module ImgProxyHelper
+  def process_inline_images!
+    raise NameError('@status must be defined by the instance this method is being called from.') unless defined?(@status)
+    return if @status.text&.strip.blank? || @status.content_type == 'text/plain'
+
+    replace_markdown_images_with_html!
+
+    handler = ImgTagHandler.new
+    Ox.sax_parse(handler, StringIO.new(@status.text, 'r'))
+    return if handler.srcs.blank?
+
+    @skip_download_from = { @status.account.domain => DomainBlock.reject_media?(@status.account.domain) }
+    @redownload_attachment_ids = Set[]
+
+    handler.srcs.each do |src|
+      alt                   = handler.alts[src]
+      normalized_src_parts  = begin
+                                Addressable::URI.parse(src&.strip).normalize
+                              rescue Addressable::URI::InvalidURIError
+                                nil
+                              end
+      normalized_src        = normalized_src_parts.to_s
+
+      next replace_text!(src) if normalized_src.blank? || skip_download_from?(normalized_src_parts.host)
+
+      file_name             = normalized_src_parts.path.split('/').last
+      media_attachment      = find_media_attachment(normalized_src, file_name)
+
+      if media_attachment.present?
+        media_attachment.update(description: alt) if alt_more_descriptive?(alt, media_attachment.description)
+      elsif normalized_src_parts.scheme.blank? || !file_name.match?(/\S\.\w{3,}/)
+        next replace_text!(src)
+      else
+        media_attachment = create_media_attachment!(normalized_src, alt)
+      end
+
+      next replace_text!(src) if media_attachment.blank? || media_attachment.destroyed?
+
+      if media_attachment.needs_redownload?
+        replace_text!(src, "#{media_attachment.file.url(:small)}##{media_attachment.id}")
+      else
+        replace_text!(src, media_attachment.file.url(:small))
+      end
+    end
+  end
+
+  private
+
+  def skip_download_from?(domain)
+    return true if @skip_download_from[@status.account.domain]
+    return @skip_download_from[domain] if @skip_download_from[domain]
+
+    @skip_download_from[domain] = DomainBlock.reject_media?(domain)
+  end
+
+  def unsupported_media_type?(mime_type)
+    mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
+  end
+
+  def html_entities
+    @html_entities ||= HTMLEntities.new
+  end
+
+  def replace_markdown_images_with_html!
+    return unless @status.content_type == 'text/markdown'
+
+    @status.text.gsub!(/!\[(\S+)\]\(\s*(\S+)\s*\)/) do
+      begin
+        alt = html_entities.encode(Regexp.last_match(1).strip)
+        url = Addressable::URI.parse(Regexp.last_match(2)).normalize.to_s
+        "<img title=\"#{alt}\" alt=\"#{alt}\" src=\"#{url}\" />"
+      rescue Addressable::URI::InvalidURIError
+        ''
+      end
+    end
+  end
+
+  def replace_text!(text, replacement = '')
+    @status.text.gsub!(text, replacement)
+  end
+
+  def alt_more_descriptive?(alt, description)
+    return false unless alt.present? && description != alt
+    return true if description.blank? || alt.split(/[\s\n\r]+/).count > description.split(/[\s\n\r]+/).count
+  end
+
+  def find_media_attachment(src, file_name)
+    media_attachment = src.start_with?('http') ? MediaAttachment.find_by(account: @account, remote_url: src, inline: true) : nil
+    return media_attachment if media_attachment.present?
+
+    MediaAttachment.where(account: @status.account, file_file_name: file_name, inline: true)
+                   .find { |m| [m.file.url(:small), m.file.url(:original)].include?(src) || m.status_id == @status.id }
+  end
+
+  def create_media_attachment!(src, alt)
+    media_attachment = MediaAttachment.create!(account: @status.account, remote_url: src, description: alt, focus: nil, inline: true)
+    media_attachment = process_media_attachment!(media_attachment)
+    return if media_attachment.destroyed?
+
+    @status.inlined_attachments.first_or_create!(media_attachment: media_attachment)
+    media_attachment
+  end
+
+  def process_media_attachment!(media_attachment)
+    media_attachment.download_file!
+    media_attachment.download_thumbnail!
+    media_attachment.save!
+    media_attachment.destroy! if unsupported_media_type?(media_attachment.file.content_type)
+    media_attachment
+  rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
+    return if @redownload_attachment_ids.include?(media_attachment.id)
+
+    RedownloadMediaWorker.perform_in(rand(30..60).seconds, media_attachment.id)
+    @redownload_attachment_ids << media_attachment.id
+    media_attachment
+  end
+end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index 1c473efa3..b93284637 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -76,9 +76,31 @@ module JsonLdHelper
     json.present? && json['id'] == uri ? json : nil
   end
 
+  def uri_allowed?(uri)
+    host = Addressable::URI.parse(uri)&.normalized_host
+    Rails.cache.fetch("fetch_resource:#{host}", expires_in: 1.hour) { DomainAllow.allowed?(host) }
+  rescue Addressable::URI::InvalidURIError
+    false
+  end
+
   def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false)
+    return unless uri_allowed?(uri)
+
     on_behalf_of ||= Account.representative
+    skip_retry = on_behalf_of.id == -99 || Rails.env.development?
 
+    begin
+      fetch_body(uri, on_behalf_of, !skip_retry || raise_on_temporary_error)
+    rescue Mastodon::UnexpectedResponseError
+      raise if skip_retry
+
+      fetch_body(uri, Account.representative, raise_on_temporary_error)
+    end
+  rescue Addressable::URI::InvalidURIError
+    nil
+  end
+
+  def fetch_body(uri, on_behalf_of, raise_on_temporary_error = false)
     build_request(uri, on_behalf_of).perform do |response|
       raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
 
@@ -87,6 +109,9 @@ module JsonLdHelper
   end
 
   def body_to_json(body, compare_id: nil)
+    body.strip! if body.is_a?(String)
+    return if body.blank?
+
     json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body
 
     return if compare_id.present? && json['id'] != compare_id
@@ -114,7 +139,7 @@ module JsonLdHelper
 
   def build_request(uri, on_behalf_of = nil)
     Request.new(:get, uri).tap do |request|
-      request.on_behalf_of(on_behalf_of) if on_behalf_of
+      request.on_behalf_of(on_behalf_of) unless Rails.env.development? || on_behalf_of.blank?
       request.add_headers('Accept' => 'application/activity+json, application/ld+json')
     end
   end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 5b39497b6..3fd15548d 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -2,6 +2,7 @@
 
 module SettingsHelper
   HUMAN_LOCALES = {
+    'en-MP': 'English (Monsterpit)',
     ar: 'العربية',
     ast: 'Asturianu',
     bg: 'Български',
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
index 912a3d179..a4eef2fd2 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -274,11 +274,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id, notifications, duration=0) {
+export function muteAccount(id, notifications, timelinesOnly, duration=0) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly, 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 => {
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index f83738093..8c126c6e2 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -147,16 +147,16 @@ export function submitCompose(routerHistory) {
     let media  = getState().getIn(['compose', 'media_attachments']);
     const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
     let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
+    const id = getState().getIn(['compose', 'id'], null);
+    const submit_url = id ? `/api/v1/statuses/${id}` : '/api/v1/statuses';
+    const submit_action = (res, body, config) => id ? api(getState).put(res, body, config) : api(getState).post(res, body, config);
 
     if ((!status || !status.length) && media.size === 0) {
       return;
     }
 
     dispatch(submitComposeRequest());
-    if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
-      status = status + ' 👁️';
-    }
-    api(getState).post('/api/v1/statuses', {
+    submit_action(submit_url, {
       status,
       content_type: getState().getIn(['compose', 'content_type']),
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
@@ -165,6 +165,7 @@ export function submitCompose(routerHistory) {
       spoiler_text: spoilerText,
       visibility: getState().getIn(['compose', 'privacy']),
       poll: getState().getIn(['compose', 'poll'], null),
+      local_only: getState().getIn(['compose', 'advanced_options', 'do_not_federate']),
     }, {
       headers: {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 05955963c..729c8d700 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
 
 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');
+  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)).concat(status.tags ? status.tags.map(tag => tag.name) : []).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
   return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
 }
 
@@ -53,11 +53,15 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
+  const oldUpdatedAt = normalOldStatus ? normalOldStatus.updated_at || normalOldStatus.created_at : null;
+  const newUpdatedAt = normalStatus ? normalStatus.updated_at || normalStatus.created_at : null;
+
   // Only calculate these values when status first encountered
   // Otherwise keep the ones already in the reducer
-  if (normalOldStatus) {
+  if (normalOldStatus && oldUpdatedAt === newUpdatedAt) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
+    normalStatus.articleHtml = normalOldStatus.get('articleHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
@@ -66,6 +70,7 @@ export function normalizeStatus(status, normalOldStatus) {
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
+    normalStatus.articleHtml  = normalStatus.article_content ? emojify(normalStatus.article_content, emojiMap) : normalStatus.contentHtml;
     normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
   }
 
diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js
index 2bacfadb7..b85cc7863 100644
--- a/app/javascript/flavours/glitch/actions/mutes.js
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -14,6 +14,7 @@ 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 const MUTES_TOGGLE_TIMELINES_ONLY = 'MUTES_TOGGLE_TIMELINES_ONLY';
 
 export function fetchMutes() {
   return (dispatch, getState) => {
@@ -114,3 +115,9 @@ export function changeMuteDuration(duration) {
     });
   };
 }
+
+export function toggleTimelinesOnly() {
+  return dispatch => {
+    dispatch({ type: MUTES_TOGGLE_TIMELINES_ONLY });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 7273191b2..091a4b435 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -13,7 +13,7 @@ import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
 import { unescapeHTML } from 'flavours/glitch/util/html';
 import { getFiltersRegex } from 'flavours/glitch/selectors';
-import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
+import { usePendingItems as preferPendingItems, webPushEnabled } from 'flavours/glitch/util/initial_state';
 import compareId from 'flavours/glitch/util/compare_id';
 import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
 import { requestNotificationPermission } from 'flavours/glitch/util/notifications';
@@ -337,6 +337,11 @@ export function markNotificationsAsRead() {
 // Browser support
 export function setupBrowserNotifications() {
   return dispatch => {
+    if (!webPushEnabled) {
+      dispatch(setBrowserSupport(false));
+      return;
+    }
+
     dispatch(setBrowserSupport('Notification' in window));
     if ('Notification' in window) {
       dispatch(setBrowserPermission(Notification.permission));
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
index 8fdb239f7..1f9ebe569 100644
--- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
+++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
@@ -1,7 +1,9 @@
 import api from 'flavours/glitch/util/api';
+import { webPushEnabled } from 'flavours/glitch/util/initial_state';
 import { pushNotificationsSetting } from 'flavours/glitch/util/settings';
 import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
 
+
 // Taken from https://www.npmjs.com/package/web-push
 const urlBase64ToUint8Array = (base64String) => {
   const padding = '='.repeat((4 - base64String.length % 4) % 4);
@@ -49,7 +51,7 @@ const sendSubscriptionToBackend = (getState, subscription, me) => {
 };
 
 // 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);
+const supportsPushNotifications = (webPushEnabled && 'serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
 
 export function register () {
   return (dispatch, getState) => {
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 4d2bda78b..018641fc7 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -12,6 +12,10 @@ 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 STATUS_PUBLISH_REQUEST = 'STATUS_PUBLISH_REQUEST';
+export const STATUS_PUBLISH_SUCCESS = 'STATUS_PUBLISH_SUCCESS';
+export const STATUS_PUBLISH_FAIL    = 'STATUS_PUBLISH_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';
@@ -34,9 +38,9 @@ export function fetchStatusRequest(id, skipLoading) {
   };
 };
 
-export function fetchStatus(id) {
+export function fetchStatus(id, skipLoading = null) {
   return (dispatch, getState) => {
-    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+    skipLoading = skipLoading === null ? getState().getIn(['statuses', id], null) !== null : skipLoading;
 
     dispatch(fetchContext(id));
 
@@ -55,6 +59,59 @@ export function fetchStatus(id) {
   };
 };
 
+export function editStatus(status, routerHistory) {
+  return (dispatch, getState) => {
+    const id = status.get('id');
+
+    dispatch(fetchContext(id));
+    dispatch(fetchStatusRequest(id, false));
+
+    api(getState).get(`/api/v1/statuses/${id}`, { params: { source: 1 } }).then(response => {
+      dispatch(importFetchedStatus(response.data));
+      dispatch(fetchStatusSuccess(false));
+      dispatch(redraft(status, response.data.text, response.data.content_type, true));
+      ensureComposeIsVisible(getState, routerHistory);
+    }).catch(error => {
+      dispatch(fetchStatusFail(id, error, false));
+    });
+  };
+};
+
+export function publishStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(publishStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/publish`).then(() => {
+      dispatch(publishStatusSuccess(id));
+      dispatch(fetchStatus(id, false));
+    }).catch(error => {
+      dispatch(publishStatusFail(id, error));
+    });
+  };
+};
+
+export function publishStatusRequest(id) {
+  return {
+    type: STATUS_PUBLISH_REQUEST,
+    id: id,
+  };
+};
+
+export function publishStatusSuccess(id) {
+  return {
+    type: STATUS_PUBLISH_SUCCESS,
+    id: id,
+  };
+};
+
+export function publishStatusFail(id, error) {
+  return {
+    type: STATUS_PUBLISH_FAIL,
+    id: id,
+    error: error,
+  };
+};
+
 export function fetchStatusSuccess(skipLoading) {
   return {
     type: STATUS_FETCH_SUCCESS,
@@ -72,12 +129,13 @@ export function fetchStatusFail(id, error, skipLoading) {
   };
 };
 
-export function redraft(status, raw_text, content_type) {
+export function redraft(status, raw_text, content_type, inplace = false) {
   return {
     type: REDRAFT,
     status,
     raw_text,
     content_type,
+    inplace,
   };
 };
 
@@ -91,7 +149,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
 
     dispatch(deleteStatusRequest(id));
 
-    api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
+    api(getState).delete(`/api/v1/statuses/${id}`, { params: { redraft: withRedraft?1:0 } } ).then(response => {
       dispatch(deleteStatusSuccess(id));
       dispatch(deleteFromTimelines(id));
 
@@ -172,12 +230,16 @@ export function fetchContextFail(id, error) {
   };
 };
 
-export function muteStatus(id) {
+export function muteStatus(id, hide = false) {
   return (dispatch, getState) => {
     dispatch(muteStatusRequest(id));
 
-    api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
+    api(getState).post(`/api/v1/statuses/${id}/mute`, { params: { hide: hide?1:0 } }).then(() => {
       dispatch(muteStatusSuccess(id));
+
+      if (hide) {
+        dispatch(deleteFromTimelines(id));
+      }
     }).catch(error => {
       dispatch(muteStatusFail(id, error));
     });
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index 35db5dcc9..295896e55 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -18,6 +18,7 @@ import {
 } from './announcements';
 import { fetchFilters } from './filters';
 import { getLocale } from 'mastodon/locales';
+import { resetCompose } from 'flavours/glitch/actions/compose';
 
 const { messages } = getLocale();
 
@@ -96,6 +97,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'announcement.delete':
           dispatch(deleteAnnouncement(data.payload));
           break;
+        case 'refresh':
+          dispatch(resetCompose());
+          window.location.reload();
+          break;
         }
       },
     };
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index b19666e62..bd79d64f5 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -133,7 +133,18 @@ export const expandHomeTimeline            = ({ maxId } = {}, done = noOp) => ex
 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 } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
+export const expandAccountTimeline         = (accountId, { maxId, filter } = {}) => {
+  const path = filter ? filter : '';
+  const params = {
+    include_replies: filter === ':replies',
+    include_reblogs: filter === ':reblogs',
+    only_reblogs: filter === ':reblogs',
+    mentions: filter === ':mentions',
+    max_id: maxId,
+  };
+
+  return expandTimeline(`account:${accountId}${path}`, `/api/v1/accounts/${accountId}/statuses`, params);
+};
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
 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);
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 890a422d3..7521d4645 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -385,6 +385,66 @@ class MediaGallery extends React.PureComponent {
       );
     }
 
+    let parts = {};
+
+    media.map(
+      (attachment, i) => {
+        if (attachment.get('description')) {
+          if (attachment.get('description') in parts) {
+            parts[attachment.get('description')].push([i, attachment.get('url'), attachment.get('id')]);
+          } else {
+            parts[attachment.get('description')] = [[i, attachment.get('url'), attachment.get('id')]];
+          }
+        }
+      },
+    );
+
+    let descriptions = Object.entries(parts).map(
+      part => {
+        const [desc, idx] = part;
+        if (idx.length === 1) {
+          const url = idx[0][1];
+          return (
+            <p key={idx[0][2]}>
+              <strong>
+                <a href={url} title={url} target='_blank' rel='nofollow noopener'>
+                  <FormattedMessage id='status.media.description' defaultMessage='Attachment #{index}: ' values={{ index: 1+idx[0][0] }} />
+                </a>
+              </strong>
+              <span>{desc}</span>
+            </p>
+          );
+        } else if (idx.length !== 0) {
+          const indexes = (
+            <React.Fragment>
+              {
+                idx.map((i, c) => {
+                  const url = i[1];
+                  return (<span key={i[2]}>{c === 0 ? ' ' : ', '}<a href={url} title={url} target='_blank' rel='nofollow noopener'>#{1+i[0]}</a></span>);
+                })
+              }
+            </React.Fragment>
+          );
+          return (
+            <p key={idx[0][2]}>
+              <strong>
+                <FormattedMessage id='status.media.descriptions' defaultMessage='Attachments {list}: ' values={{ list: indexes }} />
+              </strong>
+              <span>{desc}</span>
+            </p>
+          );
+        } else {
+          return null;
+        }
+      },
+    );
+
+    let description_wrapper = visible && (
+      <div className='media-caption'>
+        {descriptions}
+      </div>
+    );
+
     return (
       <div className={computedClass} style={style} ref={this.handleRef}>
         <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
@@ -397,6 +457,7 @@ class MediaGallery extends React.PureComponent {
         </div>
 
         {children}
+        {description_wrapper}
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 1b7dce4c4..0de36ce2e 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -74,6 +74,8 @@ class Status extends ImmutablePureComponent {
     onReblog: PropTypes.func,
     onBookmark: PropTypes.func,
     onDelete: PropTypes.func,
+    onEdit: PropTypes.func,
+    onPublish: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onPin: PropTypes.func,
@@ -373,7 +375,7 @@ class Status extends ImmutablePureComponent {
   }
 
   handleExpandedToggle = () => {
-    if (this.props.status.get('spoiler_text')) {
+    if (this.props.status.get('spoiler_text') || this.props.status.get('reblogSpoilerHtml')) {
       this.setExpansion(!this.state.isExpanded);
     }
   };
@@ -689,6 +691,9 @@ class Status extends ImmutablePureComponent {
     //  Users can use those for theming, hiding avatars etc via UserStyle
     const selectorAttribs = {
       'data-status-by': `@${status.getIn(['account', 'acct'])}`,
+      'data-nest-level': status.get('nest_level'),
+      'data-nest-deep': status.get('nest_level') >= 15,
+      'data-local-only': !!status.get('local_only'),
     };
 
     if (prepend && account) {
@@ -710,6 +715,7 @@ class Status extends ImmutablePureComponent {
 
     const computedClass = classNames('status', `status-${status.get('visibility')}`, {
       collapsed: isCollapsed,
+      unpublished: status.get('published') === false,
       'has-background': isCollapsed && background,
       'status__wrapper-reply': !!status.get('in_reply_to_id'),
       unread,
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 2ccb02c62..b844590c0 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -13,6 +13,8 @@ import classNames from 'classnames';
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
   redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+  edit: { id: 'status.edit', defaultMessage: 'Edit' },
+  publish: { id: 'status.publish', defaultMessage: 'Publish' },
   direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
@@ -53,6 +55,8 @@ class StatusActionBar extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onEdit: PropTypes.func,
+    onPublish: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onMute: PropTypes.func,
@@ -115,7 +119,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
   _openInteractionDialog = type => {
     window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
-   }
+  }
 
   handleDeleteClick = () => {
     this.props.onDelete(this.props.status, this.context.router.history);
@@ -125,6 +129,14 @@ class StatusActionBar extends ImmutablePureComponent {
     this.props.onDelete(this.props.status, this.context.router.history, true);
   }
 
+  handleEditClick = () => {
+    this.props.onEdit(this.props.status, this.context.router.history);
+  }
+
+  handlePublishClick = () => {
+    this.props.onPublish(this.props.status);
+  }
+
   handlePinClick = () => {
     this.props.onPin(this.props.status);
   }
@@ -211,10 +223,8 @@ class StatusActionBar extends ImmutablePureComponent {
 
     menu.push(null);
 
-    if (status.getIn(['account', 'id']) === me || withDismiss) {
-      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
-      menu.push(null);
-    }
+    menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+    menu.push(null);
 
     if (status.getIn(['account', 'id']) === me) {
       if (publicStatus) {
@@ -223,6 +233,11 @@ class StatusActionBar extends ImmutablePureComponent {
 
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+
+      if (status.get('published') === false) {
+        menu.push({ text: intl.formatMessage(messages.publish), action: this.handlePublishClick });
+      }
     } 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 });
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index a39f747b8..a4546edfd 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { isRtl } from 'flavours/glitch/util/rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
 import classnames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 import { autoPlayGif } from 'flavours/glitch/util/initial_state';
@@ -13,7 +14,7 @@ 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 = [];
@@ -77,11 +78,13 @@ export default class StatusContent extends React.PureComponent {
     onUpdate: PropTypes.func,
     tagLinks: PropTypes.bool,
     rewriteMentions: PropTypes.string,
+    article: PropTypes.bool,
   };
 
   static defaultProps = {
     tagLinks: true,
     rewriteMentions: 'no',
+    article: false,
   };
 
   state = {
@@ -231,7 +234,7 @@ export default class StatusContent extends React.PureComponent {
 
     let element = e.target;
     while (element) {
-      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName)) {
+      if (['button', 'video', 'a', 'label', 'canvas', 'details', 'summary'].includes(element.localName)) {
         return;
       }
       element = element.parentNode;
@@ -271,23 +274,213 @@ export default class StatusContent extends React.PureComponent {
       disabled,
       tagLinks,
       rewriteMentions,
+      article,
     } = this.props;
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
 
-    const content = { __html: status.get('contentHtml') };
-    const spoilerContent = { __html: status.get('spoilerHtml') };
+    const edited = (status.get('edited') === 0) ? null : (
+      <div className='status__notice status__edit-notice'>
+        <Icon id='pencil-square-o' />
+        <FormattedMessage
+          id='status.edited'
+          defaultMessage='{count, plural, one {# edit} other {# edits}} · last update: {updated_at}'
+          key={`edit-${status.get('id')}`}
+          values={{
+            count: status.get('edited'),
+            updated_at: <RelativeTimestamp timestamp={status.get('updated_at')} />,
+          }}
+        />
+      </div>
+    );
+
+    const unpublished = (status.get('published') === false) && (
+      <div className='status__notice status__unpublished-notice'>
+        <Icon id='chain-broken' />
+        <FormattedMessage
+          id='status.unpublished'
+          defaultMessage='Unpublished'
+          key={`unpublished-${status.get('id')}`}
+        />
+      </div>
+    );
+
+    const local_only = (status.get('local_only') === true) && (
+      <div className='status__notice status__localonly-notice'>
+        <Icon id='home' />
+        <FormattedMessage
+          id='advanced_options.local-only.short'
+          defaultMessage='Local-only'
+          key={`localonly-${status.get('id')}`}
+        />
+      </div>
+    );
+
+    const quiet = (status.get('notify') === false) && (
+      <div className='status__notice status__quiet-notice'>
+        <Icon id='bell-slash' />
+        <FormattedMessage
+          id='status.quiet'
+          defaultMessage='Quiet local publish'
+          key={`quiet-${status.get('id')}`}
+        />
+      </div>
+    );
+
+    const article_content = status.get('article') && (
+      <div className='status__notice status__article-notice'>
+        <Icon id='file-text-o' />
+        <Permalink
+          href={status.get('url')}
+          to={`/statuses/${status.get('id')}`}
+        >
+          <FormattedMessage
+            id='status.article'
+            defaultMessage='Article'
+            key={`article-${status.get('id')}`}
+          />
+        </Permalink>
+      </div>
+    );
+
+    const publish_at = status.get('publish_at') && (
+      <div className='status__notice status__publish-notice'>
+        <Icon id='bullhorn' />
+        <FormattedMessage
+          id='status.publish_at'
+          defaultMessage='Auto-publish: {publish_at}'
+          key={`publish-${status.get('id')}`}
+          values={{
+            publish_at: <RelativeTimestamp timestamp={status.get('publish_at')} futureDate />,
+          }}
+        />
+      </div>
+    );
+
+    const expires_at = !unpublished && status.get('expires_at') && (
+      <div className='status__notice status__expires-notice'>
+        <Icon id='clock-o' />
+        <FormattedMessage
+          id='status.expires_at'
+          defaultMessage='Self-destruct: {expires_at}'
+          key={`expires-${status.get('id')}`}
+          values={{
+            expires_at: <RelativeTimestamp timestamp={status.get('expires_at')} futureDate />,
+          }}
+        />
+      </div>
+    );
+
+    const status_notice_wrapper = (
+      <div className='status__notice-wrapper'>
+        {unpublished}
+        {publish_at}
+        {expires_at}
+        {quiet}
+        {edited}
+        {local_only}
+        {article_content}
+      </div>
+    );
+
+    const permissions_present = status.get('domain_permissions') && status.get('domain_permissions').size > 0;
+
+    const status_permission_items = permissions_present && status.get('domain_permissions').map((permission) => (
+      <li className='permission-status'>
+        <Icon id='eye-slash' />
+        <FormattedMessage
+          id='status.permissions.visibility.status'
+          defaultMessage='{visibility} 🡲 {domain}'
+          key={`permissions-visibility-${status.get('id')}`}
+          values={{
+            domain: <span>{permission.get('domain')}</span>,
+            visibility: <span>{permission.get('visibility')}</span>,
+          }}
+        />
+      </li>
+    ));
+
+    const permissions = status_permission_items && (
+      <details className='status__permissions' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+        <summary>
+          <Icon id='unlock-alt' />
+          <FormattedMessage
+            id='status.permissions.title'
+            defaultMessage='Show extended permissions...'
+            key={`permissions-${status.get('id')}`}
+          />
+        </summary>
+        <ul>
+          {status_permission_items}
+        </ul>
+      </details>
+    );
+
+    const tag_items = (status.get('tags') && status.get('tags').size > 0) && status.get('tags').map(hashtag =>
+      (
+        <li>
+          <Icon id='tag' />
+          <Permalink
+            href={hashtag.get('url')}
+            to={`/timelines/tag/${hashtag.get('name')}`}
+          >
+            <span>{hashtag.get('name')}</span>
+          </Permalink>
+        </li>
+      ));
+
+    const tags = tag_items && (
+      <details className='status__tags' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+        <summary>
+          <Icon id='tag' />
+          <FormattedMessage
+            id='status.tags'
+            defaultMessage='Show all tags...'
+            key={`tags-${status.get('id')}`}
+          />
+        </summary>
+        <ul>
+          {tag_items}
+        </ul>
+      </details>
+    );
+
+    const footers = (
+      <div className='status__footers'>
+        {permissions}
+        {tags}
+      </div>
+    );
+
+    const reblog_spoiler_html = status.get('reblogSpoilerPresent') && { __html: status.get('reblogSpoilerHtml') };
+    const reblog_spoiler = reblog_spoiler_html && (
+      <div className='reblog-spoiler spoiler'>
+        <Icon id='retweet' />
+        <span dangerouslySetInnerHTML={reblog_spoiler_html} />
+      </div>
+    );
+
+    const spoiler_html = status.get('spoiler_text').length > 0 && { __html: status.get('spoilerHtml') };
+    const spoiler = spoiler_html && (
+      <div className='spoiler'>
+        <Icon id='info-circle' />
+        <span dangerouslySetInnerHTML={spoiler_html} />
+      </div>
+    );
+
+    const spoiler_present = status.get('spoiler_text').length > 0 || status.get('reblogSpoilerPresent');
+    const content = { __html: article ? status.get('articleHtml') : status.get('contentHtml') };
     const directionStyle = { direction: 'ltr' };
     const classNames = classnames('status__content', {
       'status__content--with-action': parseClick && !disabled,
-      'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+      'status__content--with-spoiler': spoiler_present,
     });
 
     if (isRtl(status.get('search_index'))) {
       directionStyle.direction = 'rtl';
     }
 
-    if (status.get('spoiler_text').length > 0) {
+    if (spoiler_present) {
       let mentionsPlaceholder = '';
 
       const mentionLinks = status.get('mentions').map(item => (
@@ -302,11 +495,19 @@ export default class StatusContent extends React.PureComponent {
       )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
 
       const toggleText = hidden ? [
-        <FormattedMessage
-          id='status.show_more'
-          defaultMessage='Show more'
-          key='0'
-        />,
+        article ? (
+          <FormattedMessage
+            id='status.show_article'
+            defaultMessage='Show article'
+            key='0'
+          />
+        ) : (
+          <FormattedMessage
+            id='status.show_more'
+            defaultMessage='Show more'
+            key='0'
+          />
+        ),
         mediaIcon ? (
           <Icon
             fixedWidth
@@ -330,15 +531,18 @@ export default class StatusContent extends React.PureComponent {
 
       return (
         <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} ref={this.setRef}>
-          <p
+          {status_notice_wrapper}
+          <div
             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
           >
-            <span dangerouslySetInnerHTML={spoilerContent} />
-            {' '}
-            <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
-              {toggleText}
-            </button>
-          </p>
+            {reblog_spoiler}
+            {spoiler}
+            <div class='spoiler-actions'>
+              <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
+                {toggleText}
+              </button>
+            </div>
+          </div>
 
           {mentionsPlaceholder}
 
@@ -354,6 +558,8 @@ export default class StatusContent extends React.PureComponent {
             {media}
           </div>
 
+          {footers}
+
         </div>
       );
     } else if (parseClick) {
@@ -366,6 +572,7 @@ export default class StatusContent extends React.PureComponent {
           tabIndex='0'
           ref={this.setRef}
         >
+          {status_notice_wrapper}
           <div
             ref={this.setContentsRef}
             key={`contents-${tagLinks}-${rewriteMentions}`}
@@ -374,6 +581,7 @@ export default class StatusContent extends React.PureComponent {
             tabIndex='0'
           />
           {media}
+          {footers}
         </div>
       );
     } else {
@@ -384,8 +592,10 @@ export default class StatusContent extends React.PureComponent {
           tabIndex='0'
           ref={this.setRef}
         >
+          {status_notice_wrapper}
           <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} tabIndex='0' />
           {media}
+          {footers}
         </div>
       );
     }
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 7782246a6..5d60ffd97 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -17,7 +17,7 @@ import {
   pin,
   unpin,
 } from 'flavours/glitch/actions/interactions';
-import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus, editStatus, publishStatus } 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';
@@ -39,6 +39,8 @@ const messages = defineMessages({
   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?' },
+  publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' },
+  publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' },
   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}}' },
@@ -168,6 +170,18 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     }
   },
 
+  onEdit (status, history) {
+    dispatch(editStatus(status, history));
+  },
+
+  onPublish (status) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.publishMessage),
+      confirm: intl.formatMessage(messages.publishConfirm),
+      onConfirm: () => dispatch(publishStatus(status.get('id'))),
+    }));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js
index 2d4cc7f49..0f1b83b2d 100644
--- a/app/javascript/flavours/glitch/features/account/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -44,14 +44,20 @@ class ActionBar extends React.PureComponent {
     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>
+          <p>
+            <Icon id='info-circle' fixedWidth /> <FormattedMessage
+              id='account.disclaimer_full'
+              defaultMessage="Information below may reflect the user's profile incompletely."
+            />
+          </p>
+          <p>
+            <Icon id='link' fixedWidth /> <a target='_blank' rel='noopener' href={account.get('url')}>
+              <FormattedMessage
+                id='account.view_full_profile'
+                defaultMessage='View full profile'
+              />
+            </a>
+          </p>
         </div>
       );
     }
@@ -64,17 +70,14 @@ class ActionBar extends React.PureComponent {
           <div className='account__action-bar-links'>
             <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
               <FormattedMessage id='account.posts' defaultMessage='Posts' />
-              <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
             </NavLink>
 
             <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
               <FormattedMessage id='account.follows' defaultMessage='Follows' />
-              <strong><FormattedNumber value={account.get('following_count')} /></strong>
             </NavLink>
 
             <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
               <FormattedMessage id='account.followers' defaultMessage='Followers' />
-              <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong>
             </NavLink>
           </div>
         </div>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
index a6b57d331..d399d4aa9 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { FormattedMessage } from 'react-intl';
 import { NavLink } from 'react-router-dom';
 import MovedNote from './moved_note';
+import { me } from 'flavours/glitch/util/initial_state';
 
 export default class Header extends ImmutablePureComponent {
 
@@ -128,9 +129,12 @@ export default class Header extends ImmutablePureComponent {
 
         {!hideTabs && (
           <div className='account__section-headline'>
-            <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
-            <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.threads' defaultMessage='Threads' /></NavLink>
+            { (account.get('id') === me || account.get('show_replies')) &&
+                (<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>) }
+            { (account.get('id') !== me) && (<NavLink exact to={`/accounts/${account.get('id')}/mentions`}><FormattedMessage id='account.mentions' defaultMessage='Mentions' /></NavLink>) }
             <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}/reblogs`}><FormattedMessage id='account.reblogs' defaultMessage='Boosts' /></NavLink>
           </div>
         )}
       </div>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index c56cc9b8e..c88f6ac89 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -19,15 +19,15 @@ import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
 const emptyList = ImmutableList();
 
-const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
-  const path = withReplies ? `${accountId}:with_replies` : accountId;
+const mapStateToProps = (state, { params: { accountId }, filter = '' }) => {
+  const path = `${accountId}${filter}`;
 
   return {
     remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
     remoteUrl: state.getIn(['accounts', accountId, 'url']),
     isAccount: !!state.getIn(['accounts', accountId]),
     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
-    featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
+    featuredStatusIds: !filter ? state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()) : ImmutableList(),
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
     hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
     suspended: state.getIn(['accounts', accountId, 'suspended'], false),
@@ -52,7 +52,7 @@ class AccountTimeline extends ImmutablePureComponent {
     featuredStatusIds: ImmutablePropTypes.list,
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
-    withReplies: PropTypes.bool,
+    filter: PropTypes.string,
     isAccount: PropTypes.bool,
     suspended: PropTypes.bool,
     remote: PropTypes.bool,
@@ -61,24 +61,24 @@ class AccountTimeline extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    const { params: { accountId }, withReplies } = this.props;
+    const { params: { accountId }, filter } = this.props;
 
     this.props.dispatch(fetchAccount(accountId));
     this.props.dispatch(fetchAccountIdentityProofs(accountId));
-    if (!withReplies) {
+    if (!filter) {
       this.props.dispatch(expandAccountFeaturedTimeline(accountId));
     }
-    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
+    this.props.dispatch(expandAccountTimeline(accountId, { filter }));
   }
 
   componentWillReceiveProps (nextProps) {
-    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
+    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.filter !== this.props.filter) {
       this.props.dispatch(fetchAccount(nextProps.params.accountId));
       this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
-      if (!nextProps.withReplies) {
+      if (!nextProps.filter) {
         this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
       }
-      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
+      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { filter: nextProps.params.filter }));
     }
   }
 
@@ -87,7 +87,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
+    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, filter: this.props.filter }));
   }
 
   setRef = c => {
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index a7cb95222..e812ba982 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -63,6 +63,8 @@ class ComposeForm extends ImmutablePureComponent {
     layout: PropTypes.string,
     media: ImmutablePropTypes.list,
     sideArm: PropTypes.string,
+    sideArmWarning: PropTypes.bool,
+    privacyWarning: PropTypes.bool,
     sensitive: PropTypes.bool,
     spoilersAlwaysOn: PropTypes.bool,
     mediaDescriptionConfirmation: PropTypes.bool,
@@ -71,10 +73,12 @@ class ComposeForm extends ImmutablePureComponent {
     onChangeVisibility: PropTypes.func,
     onPaste: PropTypes.func,
     onMediaDescriptionConfirm: PropTypes.func,
+    clearTimeout: PropTypes.bool,
   };
 
   static defaultProps = {
     showSearch: false,
+    clearTimeout: null,
   };
 
   handleChange = (e) => {
@@ -149,6 +153,17 @@ class ComposeForm extends ImmutablePureComponent {
     this.handleSubmit(sideArm === 'none' ? null : sideArm);
   }
 
+  handleClearAll = () => {
+    if(!this.clearTimeout || this.clearTimeout === null) {
+      this.clearTimeout = window.setTimeout(() => {
+        this.clearTimeout = null;
+      }, 500);
+    } else {
+      this.clearTimeout = null;
+      this.props.onClearAll();
+    }
+  }
+
   //  Selects a suggestion from the autofill.
   onSuggestionSelected = (tokenStart, token, value) => {
     this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
@@ -256,6 +271,7 @@ class ComposeForm extends ImmutablePureComponent {
       handleSecondarySubmit,
       handleSelect,
       handleSubmit,
+      handleClearAll,
       handleRefTextarea,
     } = this;
     const {
@@ -273,19 +289,22 @@ class ComposeForm extends ImmutablePureComponent {
       onFetchSuggestions,
       onPaste,
       privacy,
+      privacyWarning,
       sensitive,
       showSearch,
       sideArm,
+      sideArmWarning,
       spoiler,
       spoilerText,
       suggestions,
       text,
       spoilersAlwaysOn,
+      clearTimeout,
     } = this.props;
 
     let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
 
-    const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`;
+    const countText = `${spoilerText}${countableText(text)}`;
 
     return (
       <div className='composer'>
@@ -356,8 +375,11 @@ class ComposeForm extends ImmutablePureComponent {
           disabled={disabledButton}
           onSecondarySubmit={handleSecondarySubmit}
           onSubmit={handleSubmit}
+          onClearAll={handleClearAll}
           privacy={privacy}
+          privacyWarning={privacyWarning}
           sideArm={sideArm}
+          sideArmWarning={sideArmWarning}
         />
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
index 97890f40d..d42c578aa 100644
--- a/app/javascript/flavours/glitch/features/compose/components/publisher.js
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -23,6 +23,10 @@ const messages = defineMessages({
     defaultMessage: '{publish}!',
     id: 'compose_form.publish_loud',
   },
+  clear: {
+    defaultMessage: 'Double-click to clear',
+    id: 'compose_form.clear',
+  },
 });
 
 export default @injectIntl
@@ -34,8 +38,11 @@ class Publisher extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     onSecondarySubmit: PropTypes.func,
     onSubmit: PropTypes.func,
+    onClearAll: PropTypes.func,
     privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+    privacyWarning: PropTypes.bool,
     sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+    sideArmWarning: PropTypes.bool,
   };
 
   handleSubmit = () => {
@@ -43,7 +50,7 @@ class Publisher extends ImmutablePureComponent {
   };
 
   render () {
-    const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props;
+    const { countText, disabled, intl, onClearAll, onSecondarySubmit, privacy, privacyWarning, sideArm, sideArmWarning } = this.props;
 
     const diff = maxChars - length(countText || '');
     const computedClass = classNames('composer--publisher', {
@@ -53,9 +60,20 @@ class Publisher extends ImmutablePureComponent {
 
     return (
       <div className={computedClass}>
+        <Button
+          className='clear'
+          onClick={onClearAll}
+          style={{ padding: null }}
+          title={intl.formatMessage(messages.clear)}
+          text={
+            <span>
+              <Icon id='trash-o' />
+            </span>
+          }
+        />
         {sideArm && sideArm !== 'none' ? (
           <Button
-            className='side_arm'
+            className={classNames('side_arm', {privacy_warning: sideArmWarning})}
             disabled={disabled || diff < 0}
             onClick={onSecondarySubmit}
             style={{ padding: null }}
@@ -75,7 +93,7 @@ class Publisher extends ImmutablePureComponent {
           />
         ) : null}
         <Button
-          className='primary'
+          className={classNames('primary', {privacy_warning: privacyWarning})}
           text={function () {
             switch (true) {
             case !!sideArm && sideArm !== 'none':
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
index fcd2caf1b..cf953ec3d 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -12,13 +12,14 @@ import {
   selectComposeSuggestion,
   submitCompose,
   uploadCompose,
+  resetCompose,
 } from 'flavours/glitch/actions/compose';
 import {
   openModal,
 } from 'flavours/glitch/actions/modal';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
 
-import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
+import { privacyPreference, order as privacyOrder } from 'flavours/glitch/util/privacy_preference';
 
 const messages = defineMessages({
   missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
@@ -57,7 +58,9 @@ function mapStateToProps (state) {
     media: state.getIn(['compose', 'media_attachments']),
     preselectDate: state.getIn(['compose', 'preselectDate']),
     privacy: state.getIn(['compose', 'privacy']),
+    privacyWarning: replyPrivacy && privacyOrder.indexOf(state.getIn(['compose', 'privacy'])) < privacyOrder.indexOf(replyPrivacy),
     sideArm: sideArmPrivacy,
+    sideArmWarning: sideArmPrivacy && sideArmRestrictedPrivacy && privacyOrder.indexOf(sideArmPrivacy) < privacyOrder.indexOf(sideArmRestrictedPrivacy),
     sensitive: state.getIn(['compose', 'sensitive']),
     showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
     spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
@@ -82,6 +85,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(submitCompose(routerHistory));
   },
 
+  onClearAll() {
+    dispatch(resetCompose());
+  },
+
   onClearSuggestions() {
     dispatch(clearComposeSuggestions());
   },
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index b4549fdf8..43d535ac5 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -46,7 +46,10 @@ const makeMapStateToProps = () => {
       return lists;
     }
 
-    return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+    return lists.toList().filter(item => !!item).sort((a, b) => {
+      const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0);
+      return r === 0 ? a.get('title').localeCompare(b.get('title')) : r;
+    });
   });
 
   const mapStateToProps = state => ({
diff --git a/app/javascript/flavours/glitch/features/list_adder/index.js b/app/javascript/flavours/glitch/features/list_adder/index.js
index cb8a15e8c..b7f3d1ef7 100644
--- a/app/javascript/flavours/glitch/features/list_adder/index.js
+++ b/app/javascript/flavours/glitch/features/list_adder/index.js
@@ -16,7 +16,10 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => {
     return lists;
   }
 
-  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+  return lists.toList().filter(item => !!item).sort((a, b) => {
+    const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0);
+    return r === 0 ? a.get('title').localeCompare(b.get('title')) : r;
+  });
 });
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/flavours/glitch/features/lists/index.js b/app/javascript/flavours/glitch/features/lists/index.js
index e384f301b..3863b8e25 100644
--- a/app/javascript/flavours/glitch/features/lists/index.js
+++ b/app/javascript/flavours/glitch/features/lists/index.js
@@ -24,7 +24,10 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => {
     return lists;
   }
 
-  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+  return lists.toList().filter(item => !!item).sort((a, b) => {
+    const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0);
+    return r === 0 ? a.get('title').localeCompare(b.get('title')) : r;
+  });
 });
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js
index 0d3162fc9..7b47f411b 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -84,11 +84,13 @@ export default class NotificationFollow extends ImmutablePureComponent {
               <Icon fixedWidth id='user-plus' />
             </div>
 
-            <FormattedMessage
-              id='notification.follow'
-              defaultMessage='{name} followed you'
-              values={{ name: link }}
-            />
+            <span title={notification.get('created_at')}>
+              <FormattedMessage
+                id='notification.follow'
+                defaultMessage='{name} followed you'
+                values={{ name: link }}
+              />
+            </span>
           </div>
 
           <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} />
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
index 73fc05dea..4cac53266 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
@@ -20,6 +20,10 @@ class NotificationsPermissionBanner extends React.PureComponent {
     intl: PropTypes.object.isRequired,
   };
 
+  handleClickDisable = () => {
+    window.location = '/settings/preferences/notifications';
+  }
+
   handleClick = () => {
     this.props.dispatch(requestBrowserPermission());
   }
@@ -39,7 +43,8 @@ class NotificationsPermissionBanner extends React.PureComponent {
 
         <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>
+        <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Request' /></Button>
+        <Button onClick={this.handleClickDisable}><FormattedMessage id='notifications_permission_banner.disable' defaultMessage='Disable' /></Button>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js
index 0f16d93fe..b2c8ac87f 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -11,6 +11,8 @@ import classNames from 'classnames';
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
   redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+  edit: { id: 'status.edit', defaultMessage: 'Edit' },
+  publish: { id: 'status.publish', defaultMessage: 'Publish' },
   direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
@@ -52,6 +54,8 @@ class ActionBar extends React.PureComponent {
     onMuteConversation: PropTypes.func,
     onBlock: PropTypes.func,
     onDelete: PropTypes.func.isRequired,
+    onEdit: PropTypes.func.isRequired,
+    onPublish: PropTypes.func.isRequired,
     onDirect: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
@@ -84,6 +88,14 @@ class ActionBar extends React.PureComponent {
     this.props.onDelete(this.props.status, this.context.router.history, true);
   }
 
+  handleEditClick = () => {
+    this.props.onEdit(this.props.status, this.context.router.history);
+  }
+
+  handlePublishClick = () => {
+    this.props.onPublish(this.props.status);
+  }
+
   handleDirectClick = () => {
     this.props.onDirect(this.props.status.get('account'), this.context.router.history);
   }
@@ -166,6 +178,11 @@ class ActionBar extends React.PureComponent {
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+
+      if (status.get('published') === false) {
+        menu.push({ text: intl.formatMessage(messages.publish), action: this.handlePublishClick });
+      }
     } 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 });
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 04d350bcb..d03a425eb 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -17,7 +17,7 @@ import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
 import classNames from 'classnames';
 import PollContainer from 'flavours/glitch/containers/poll_container';
 import Icon from 'flavours/glitch/components/icon';
-import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import { me } from 'flavours/glitch/util/initial_state';
 import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
 export default class DetailedStatus extends ImmutablePureComponent {
@@ -200,7 +200,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
       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>;
+    const visibilityLink = <React.Fragment><VisibilityIcon visibility={status.get('visibility')} /> · </React.Fragment>;
 
     if (status.get('visibility') === 'direct') {
       reblogIcon = 'envelope';
@@ -208,7 +208,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
       reblogIcon = 'lock';
     }
 
-    if (!['unlisted', 'public'].includes(status.get('visibility'))) {
+    if (status.getIn(['account', 'id']) !== me || !['unlisted', 'public'].includes(status.get('visibility'))) {
       reblogLink = null;
     } else if (this.context.router) {
       reblogLink = (
@@ -216,9 +216,6 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <React.Fragment> · </React.Fragment>
           <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
             <Icon id={reblogIcon} />
-            <span className='detailed-status__reblogs'>
-              <AnimatedNumber value={status.get('reblogs_count')} />
-            </span>
           </Link>
         </React.Fragment>
       );
@@ -228,37 +225,43 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <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) {
+    if (status.getIn(['account', 'id']) !== me) {
+      favouriteLink = null;
+    } else if (this.context.router) {
       favouriteLink = (
-        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
-          <Icon id='star' />
-          <span className='detailed-status__favorites'>
-            <AnimatedNumber value={status.get('favourites_count')} />
-          </span>
-        </Link>
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+            <Icon id='star' />
+          </Link>
+        </React.Fragment>
       );
     } 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>
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
+            <Icon id='star' />
+          </a>
+        </React.Fragment>
       );
     }
 
+    const selectorAttribs = {
+      'data-status-by': `@${status.getIn(['account', 'acct'])}`,
+      'data-nest-level': status.get('nest_level'),
+      'data-nest-deep': status.get('nest_level') >= 15,
+      'data-local-only': !!status.get('local_only'),
+    };
+
     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'])}>
+        <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, unpublished: status.get('published') === false })} {...selectorAttribs}>
           <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} />
@@ -275,13 +278,15 @@ export default class DetailedStatus extends ImmutablePureComponent {
             onUpdate={this.handleChildUpdate}
             tagLinks={settings.get('tag_misleading_links')}
             rewriteMentions={settings.get('rewrite_mentions')}
+            article
             disabled
           />
 
           <div className='detailed-status__meta'>
+            {visibilityLink}
             <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>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+            </a>{applicationLink}{reblogLink}{favouriteLink}
           </div>
         </div>
       </div>
diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
index 9d11f37e0..124de903a 100644
--- a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
+++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
@@ -17,7 +17,9 @@ import {
 import {
   muteStatus,
   unmuteStatus,
+  editStatus,
   deleteStatus,
+  publishStatus,
   hideStatus,
   revealStatus,
 } from 'flavours/glitch/actions/statuses';
@@ -34,6 +36,8 @@ const messages = defineMessages({
   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.' },
+  publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' },
+  publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' },
   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?' },
 });
@@ -118,6 +122,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onEdit (status, history) {
+    dispatch(editStatus(status, history));
+  },
+
+  onPublish (status) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.publishMessage),
+      confirm: intl.formatMessage(messages.publishConfirm),
+      onConfirm: () => dispatch(publishStatus(status.get('id'))),
+    }));
+  },
+
   onDirect (account, router) {
     dispatch(directCompose(account, router));
   },
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index b330adf3f..74d51f8f8 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -26,7 +26,7 @@ import {
   directCompose,
 } from 'flavours/glitch/actions/compose';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus, editStatus, publishStatus } 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';
@@ -50,6 +50,8 @@ const messages = defineMessages({
   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.' },
+  publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' },
+  publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' },
   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' },
@@ -306,6 +308,20 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleEditClick = (status, history) => {
+    this.props.dispatch(editStatus(status, history));
+  }
+
+  handlePublishClick = (status) => {
+    const { dispatch, intl } = this.props;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.publishMessage),
+      confirm: intl.formatMessage(messages.publishConfirm),
+      onConfirm: () => dispatch(publishStatus(status.get('id'))),
+    }));
+  }
+
   handleDirectClick = (account, router) => {
     this.props.dispatch(directCompose(account, router));
   }
@@ -591,6 +607,8 @@ class Status extends ImmutablePureComponent {
                   onReblog={this.handleReblogClick}
                   onBookmark={this.handleBookmarkClick}
                   onDelete={this.handleDeleteClick}
+                  onEdit={this.handleEditClick}
+                  onPublish={this.handlePublishClick}
                   onDirect={this.handleDirectClick}
                   onMention={this.handleMentionClick}
                   onMute={this.handleMuteClick}
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
index 4d7fc36c2..f8a61d2fb 100644
--- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -60,6 +60,7 @@ class LinkFooter extends React.PureComponent {
             id='getting_started.open_source_notice'
             defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
             values={{
+              monsterware: <span><a href='https://monsterware.dev/monsterpit/monsterpit-mastodon' rel='noopener noreferrer' target='_blank'>MonsterWare</a></span>,
               github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener noreferrer' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>,
               Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener noreferrer' target='_blank'>Mastodon</a> }}
           />
diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
index 354e35027..f351e2a01 100644
--- a/app/javascript/flavours/glitch/features/ui/components/list_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
@@ -13,7 +13,10 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => {
     return lists;
   }
 
-  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
+  return lists.toList().filter(item => !!item).sort((a, b) => {
+    const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0);
+    return r === 0 ? a.get('title').localeCompare(b.get('title')) : r;
+  });
 });
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
index 7d25db316..5970ceddb 100644
--- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
@@ -7,6 +7,7 @@ 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';
+import { toggleTimelinesOnly } from 'flavours/glitch/actions/mutes';
 
 const messages = defineMessages({
   minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
@@ -20,13 +21,14 @@ const mapStateToProps = state => {
     account: state.getIn(['mutes', 'new', 'account']),
     notifications: state.getIn(['mutes', 'new', 'notifications']),
     muteDuration: state.getIn(['mutes', 'new', 'duration']),
+    timelinesOnly: state.getIn(['mutes', 'new', 'timelines_only']),
   };
 };
 
 const mapDispatchToProps = dispatch => {
   return {
-    onConfirm(account, notifications, muteDuration) {
-      dispatch(muteAccount(account.get('id'), notifications, muteDuration));
+    onConfirm(account, notifications, timelinesOnly, muteDuration) {
+      dispatch(muteAccount(account.get('id'), notifications, timelinesOnly, muteDuration));
     },
 
     onClose() {
@@ -40,6 +42,10 @@ const mapDispatchToProps = dispatch => {
     onChangeMuteDuration(e) {
       dispatch(changeMuteDuration(e.target.value));
     },
+
+    onToggleTimelinesOnly() {
+      dispatch(toggleTimelinesOnly());
+    },
   };
 };
 
@@ -50,9 +56,11 @@ class MuteModal extends React.PureComponent {
   static propTypes = {
     account: PropTypes.object.isRequired,
     notifications: PropTypes.bool.isRequired,
+    timelinesOnly: PropTypes.bool.isRequired,
     onClose: PropTypes.func.isRequired,
     onConfirm: PropTypes.func.isRequired,
     onToggleNotifications: PropTypes.func.isRequired,
+    onTimelinesOnly: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     muteDuration: PropTypes.number.isRequired,
     onChangeMuteDuration: PropTypes.func.isRequired,
@@ -64,7 +72,7 @@ class MuteModal extends React.PureComponent {
 
   handleClick = () => {
     this.props.onClose();
-    this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly, this.props.muteDuration);
   }
 
   handleCancel = () => {
@@ -83,8 +91,12 @@ class MuteModal extends React.PureComponent {
     this.props.onChangeMuteDuration(e);
   }
 
+  toggleTimelinesOnly = () => {
+    this.props.onToggleTimelinesOnly();
+  }
+
   render () {
-    const { account, notifications, muteDuration, intl } = this.props;
+    const { account, notifications, timelinesOnly, muteDuration, intl } = this.props;
 
     return (
       <div className='modal-root__modal mute-modal'>
@@ -109,6 +121,13 @@ class MuteModal extends React.PureComponent {
             </label>
           </div>
           <div>
+            <label htmlFor='mute-modal__timelines-only-checkbox'>
+              <FormattedMessage id='mute_modal.timelines_only' defaultMessage='Hide from timelines only?' />
+              {' '}
+              <Toggle id='mute-modal__timelines-only-checkbox' checked={timelinesOnly} onChange={this.toggleTimelinesOnly} />
+            </label>
+          </div>
+          <div>
             <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
 
             {/* eslint-disable-next-line jsx-a11y/no-onchange */}
diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
index 9016b08d7..7473cfbe0 100644
--- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -30,7 +30,7 @@ const makeMapStateToProps = () => {
       account: getAccount(state, accountId),
       comment: state.getIn(['reports', 'new', 'comment']),
       forward: state.getIn(['reports', 'new', 'forward']),
-      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
     };
   };
 
@@ -70,12 +70,12 @@ class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { filter: ':replies' }));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { filter: ':replies' }));
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 61a34fd2b..fa18f84b3 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -215,8 +215,10 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
           <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
 
-          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} componentParams={{ filter: '' }} />
+          <WrappedRoute path='/accounts/:accountId/mentions' component={AccountTimeline} content={children} componentParams={{ filter: ':mentions' }} />
+          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ filter: ':replies' }} />
+          <WrappedRoute path='/accounts/:accountId/reblogs' component={AccountTimeline} content={children} componentParams={{ filter: ':reblogs' }} />
           <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
           <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
           <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
diff --git a/app/javascript/flavours/glitch/locales/en-MP.js b/app/javascript/flavours/glitch/locales/en-MP.js
new file mode 100644
index 000000000..a84552467
--- /dev/null
+++ b/app/javascript/flavours/glitch/locales/en-MP.js
@@ -0,0 +1,4 @@
+import messages from 'flavours/glitch/locales/en';
+import messages_mp from 'mastodon/locales/en-MP.json';
+
+export default Object.assign({}, messages, messages_mp);
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index e081c31ad..e0ab9f9ab 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -66,6 +66,7 @@ const initialState = ImmutableMap({
     do_not_federate: false,
     threaded_mode: false,
   }),
+  id: null,
   sensitive: false,
   elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
   spoiler: false,
@@ -149,6 +150,7 @@ function apiStatusToTextHashtags (state, status) {
 
 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);
@@ -286,7 +288,9 @@ 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')}`;
+    const selection = fragment.querySelector(`a[href="${mention.get('url')}"]`);
+    if (!selection) return;
+    selection.textContent = `@${mention.get('acct')}`;
   });
 
   return fragment.innerHTML;
@@ -403,9 +407,14 @@ export default function compose(state = initialState, action) {
       }
     });
   case COMPOSE_REPLY_CANCEL:
-    state = state.setIn(['advanced_options', 'threaded_mode'], false);
+    return state.withMutations(map => {
+      map.set('id', null);
+      map.set('in_reply_to', null);
+      map.set('idempotencyKey', uuid());
+    });
   case COMPOSE_RESET:
     return state.withMutations(map => {
+      map.set('id', null);
       map.set('in_reply_to', null);
       if (defaultContentType) map.set('content_type', defaultContentType);
       map.set('text', '');
@@ -505,6 +514,7 @@ export default function compose(state = initialState, action) {
     let text = action.raw_text || unescapeHTML(expandMentions(action.status));
     if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, '');
     return state.withMutations(map => {
+      map.set('id', action.inplace ? action.status.get('id') : null);
       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'));
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index ea37ae4aa..53360b8f0 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -10,18 +10,18 @@ const initialState = ImmutableMap({
   stretch   : true,
   navbar_under : false,
   swipe_to_change_columns: true,
-  side_arm  : 'none',
-  side_arm_reply_mode : 'keep',
-  show_reply_count : false,
-  always_show_spoilers_field: false,
-  confirm_missing_media_description: false,
+  side_arm  : 'private',
+  side_arm_reply_mode : 'restrict',
+  show_reply_count : true,
+  always_show_spoilers_field: true,
+  confirm_missing_media_description: true,
   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,
+  hicolor_privacy_icons: true,
+  show_content_type_choice: true,
   filtering_behavior: 'hide',
   tag_misleading_links: true,
   rewrite_mentions: 'no',
@@ -53,7 +53,7 @@ const initialState = ImmutableMap({
     pop_in_position  : 'right',
   }),
   notifications : ImmutableMap({
-    favicon_badge : false,
+    favicon_badge : true,
     tab_badge     : true,
     show_unread   : true,
   }),
diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js
index d346d9a78..f116e106a 100644
--- a/app/javascript/flavours/glitch/reducers/mutes.js
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -4,6 +4,7 @@ import {
   MUTES_INIT_MODAL,
   MUTES_TOGGLE_HIDE_NOTIFICATIONS,
   MUTES_CHANGE_DURATION,
+  MUTES_TOGGLE_TIMELINES_ONLY,
 } from 'flavours/glitch/actions/mutes';
 
 const initialState = Immutable.Map({
@@ -11,6 +12,7 @@ const initialState = Immutable.Map({
     account: null,
     notifications: true,
     duration: 0,
+    timelinesOnly: false,
   }),
 });
 
@@ -20,11 +22,14 @@ export default function mutes(state = initialState, action) {
     return state.withMutations((state) => {
       state.setIn(['new', 'account'], action.account);
       state.setIn(['new', 'notifications'], true);
+      state.setIn(['new', 'timelinesOnly'], false);
     });
   case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
     return state.updateIn(['new', 'notifications'], (old) => !old);
   case MUTES_CHANGE_DURATION:
     return state.setIn(['new', 'duration'], Number(action.duration));
+  case MUTES_TOGGLE_TIMELINES_ONLY:
+    return state.updateIn(['new', 'timelines_only'], (old) => !old);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index bf0545c48..64d13e18c 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -91,8 +91,9 @@ const initialState = ImmutableMap({
 
 const defaultColumns = fromJS([
   { id: 'COMPOSE', uuid: uuid(), params: {} },
-  { id: 'HOME', uuid: uuid(), params: {} },
   { id: 'NOTIFICATIONS', uuid: uuid(), params: {} },
+  { id: 'HOME', uuid: uuid(), params: {} },
+  { id: 'COMMUNITY', uuid: uuid(), params: {} },
 ]);
 
 const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val);
diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js
index 5db766b96..20822b4cb 100644
--- a/app/javascript/flavours/glitch/reducers/statuses.js
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -10,6 +10,7 @@ import {
 import {
   STATUS_MUTE_SUCCESS,
   STATUS_UNMUTE_SUCCESS,
+  STATUS_PUBLISH_SUCCESS,
 } from 'flavours/glitch/actions/statuses';
 import {
   TIMELINE_DELETE,
@@ -56,6 +57,8 @@ export default function statuses(state = initialState, action) {
     return state.setIn([action.id, 'muted'], true);
   case STATUS_UNMUTE_SUCCESS:
     return state.setIn([action.id, 'muted'], false);
+  case STATUS_PUBLISH_SUCCESS:
+    return state.setIn([action.id, 'published'], true);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   default:
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index bb9180d12..3571aea3e 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -141,6 +141,11 @@ export const makeGetStatus = () => {
         }
       }
 
+      if (statusReblog) {
+        statusReblog = statusReblog.set('reblogSpoilerPresent', statusBase.get('spoiler_text').length > 0);
+        statusReblog = statusReblog.set('reblogSpoilerHtml', statusBase.get('spoilerHtml'));
+      }
+
       return statusBase.withMutations(map => {
         map.set('reblog', statusReblog);
         map.set('account', accountBase);
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 2550f50f4..d90327211 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -696,12 +696,9 @@
 }
 
 .notifications-permission-banner {
-  padding: 30px;
+  padding: 14px;
   border-bottom: 1px solid lighten($ui-base-color, 8%);
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
+  text-align: center;
   position: relative;
 
   &__close {
@@ -711,7 +708,7 @@
   }
 
   h2 {
-    font-size: 16px;
+    font-size: 14px;
     font-weight: 500;
     margin-bottom: 15px;
     text-align: center;
@@ -722,4 +719,9 @@
     margin-bottom: 15px;
     text-align: center;
   }
+
+  .button {
+    margin-left: 7px;
+    margin-right: 7px;
+  }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index 460f75c1f..7db2dd2aa 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -654,6 +654,19 @@
     text-align: center;
   }
 
+  & > .privacy_warning {
+    background-color: $error-value-color;
+
+    &:hover {
+      background-color: lighten($error-value-color, 5%);
+    }
+
+    &:active,
+    &:focus {
+      background-color: darken($error-value-color, 5%);
+    }
+  }
+
   &.over {
     & > .count { color: $warning-red }
   }
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index bc0965864..d9d962e34 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -876,7 +876,7 @@
       width: 100%;
       border: none;
       padding: 10px;
-      font-family: 'mastodon-font-monospace', monospace;
+      font-family: 'roboto-mono', monospace;
       background: $ui-base-color;
       color: $primary-text-color;
       font-size: 14px;
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index d1c6c33d7..eab6e480c 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -208,6 +208,18 @@
     margin-bottom: 10px;
   }
 
+  @media screen and (max-width: 800px) {
+    .column-3 {
+      grid-column: 3 / 5;
+      grid-row: 3;
+    }
+
+    .column-4 {
+      grid-column: 1/3;
+      grid-row: 3;
+    }
+  }
+
   @media screen and (max-width: 738px) {
     grid-template-columns: minmax(0, 50%) minmax(0, 50%);
 
@@ -656,7 +668,7 @@
           box-sizing: border-box;
           flex: 0 0 auto;
           color: $darker-text-color;
-          padding: 10px;
+          margin: 15px 0px;
           border-right: 1px solid lighten($ui-base-color, 4%);
           cursor: default;
           text-align: center;
@@ -707,6 +719,7 @@
 
           .counter-label {
             font-size: 12px;
+            font-weight: bold;
             display: block;
           }
 
diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss
index af73feb89..c1ed4a6f1 100644
--- a/app/javascript/flavours/glitch/styles/index.scss
+++ b/app/javascript/flavours/glitch/styles/index.scss
@@ -1,6 +1,7 @@
 @import 'mixins';
 @import 'variables';
 @import 'styles/fonts/roboto';
+@import 'styles/fonts/opensans';
 @import 'styles/fonts/roboto-mono';
 @import 'styles/fonts/montserrat';
 
@@ -23,3 +24,5 @@
 @import 'accessibility';
 @import 'rtl';
 @import 'dashboard';
+
+@import 'monsterfork/index';
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/about.scss b/app/javascript/flavours/glitch/styles/monsterfork/about.scss
new file mode 100644
index 000000000..4ab9cfa7c
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/about.scss
@@ -0,0 +1,9 @@
+.box-widget {
+  .simple_form p.lead {
+    color: $darker-text-color;
+    font-size: 15px;
+    line-height: 20px;
+    font-weight: bold;
+    margin-bottom: 25px;
+  }
+}
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/composer.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/composer.scss
new file mode 100644
index 000000000..ba347b1cc
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/composer.scss
@@ -0,0 +1,11 @@
+.composer--publisher {
+  .clear {
+    background: darken($ui-base-color, 8%);
+    color: $secondary-text-color;
+    margin: 0 2px;
+    padding: 0;
+    width: 36px;
+    text-align: center;
+    float: left;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/formatting.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/formatting.scss
new file mode 100644
index 000000000..44df7efc9
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/formatting.scss
@@ -0,0 +1,175 @@
+.status__content__text,
+.reply-indicator__content,
+.composer--reply > .content,
+.account__header__content,
+.status__content > .e-content
+{
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -3px 0 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;
+  }
+
+  blockquote {
+    padding-left: 10px;
+    border-left: 3px solid $darker-text-color;
+    color: $darker-text-color;
+    white-space: normal;
+
+    p:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  b, strong {
+    font-weight: 700;
+  }
+
+  em, i {
+    font-style: italic;
+  }
+
+  sub {
+    font-size: smaller;
+    text-align: sub;
+  }
+
+  sup {
+    font-size: smaller;
+    vertical-align: super;
+  }
+
+  ul, ol {
+    margin-left: 1em;
+
+    p {
+      margin: 0;
+    }
+  }
+
+  ul {
+    list-style-type: disc;
+  }
+
+  ol {
+    list-style-type: decimal;
+  }
+
+  a {
+    color: $secondary-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+
+      .fa {
+        color: lighten($dark-text-color, 7%);
+      }
+    }
+
+    &.mention {
+      &:hover {
+        text-decoration: underline;
+
+        span {
+          text-decoration: none;
+        }
+      }
+    }
+
+    .fa {
+      color: $dark-text-color;
+    }
+  }
+
+  a.unhandled-link {
+    color: lighten($ui-highlight-color, 8%);
+
+    .link-origin-tag {
+      color: $gold-star;
+      font-size: 0.8em;
+    }
+  }
+
+  s { text-decoration: line-through; }
+  del { text-decoration: line-through; }
+  h6 { font-size: 8px; font-weight: bold; }
+  hr { border-color: lighten($dark-text-color, 10%); }
+  pre, code {
+    color: #6c6;
+    text-shadow: 0 0 4px #0f0;
+
+    background: linear-gradient(
+      to bottom,
+      #121 1px,
+      #232 1px
+    );
+    background-size: 100% 2px;
+  }
+  pre {
+    & > code {
+      background: transparent;
+    }
+    padding: 10px;
+    border: 2px solid darken($ui-base-color, 20%);
+  }
+  mark {
+    background-color: #ccff15;
+    color: black;
+  }
+  blockquote {
+    font-style: italic;
+  }
+  .center, .centered, center {
+    text-align: center;
+  }
+  summary {
+    color: lighten($primary-text-color, 33%);
+    font-weight: bold;
+
+    &:focus, &:active {
+      outline: none;
+    }
+  }
+  details > p, details > span {
+    padding-top: 5px;
+    padding-left: 10px;
+    border-left: 3px solid $darker-text-color;
+    color: $darker-text-color;
+    white-space: normal;
+
+    p:last-child {
+      margin-bottom: 0;
+    };
+  }
+  p[data-name="footer"] {
+    color: lighten($dark-text-color, 10%);
+    font-style: italic;
+    font-size: 12px;
+    text-align: right;
+    margin-top: 0px;
+  }
+}
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss
new file mode 100644
index 000000000..84da74f82
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss
@@ -0,0 +1,3 @@
+@import 'composer';
+@import 'status';
+@import 'formatting';
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
new file mode 100644
index 000000000..1d2f053c0
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
@@ -0,0 +1,243 @@
+.status__notice-wrapper:empty,
+.status__footers:empty {
+  display: none;
+}
+
+.status__notice {
+  display: flex;
+  align-items: center;
+
+  & > span, & > a {
+    display: inline-flex;
+    align-items: center;
+    line-height: normal;
+    font-style: italic;
+    font-weight: bold;
+    font-size: 12px;
+    padding-left: 8px;
+    height: 1.5em;
+  }
+
+  & > span {
+    color: $dark-text-color;
+
+    & > time:before {
+      content: " ";
+      white-space: pre;
+    }
+  }
+
+  & > i {
+    display: inline-flex;
+    align-items: center;
+    color: lighten($dark-text-color, 4%);
+    width: 1.1em;
+    height: 1.5em;
+  }
+}
+
+.status__footers {
+  font-size: 12px;
+  margin-top: 1em;
+
+  & > details {
+    & > summary {
+      &:focus, &:active {
+        outline: none;
+      }
+    }
+
+    & > summary > span,
+    & > ul > li > span,
+    & > ul > li > a {
+      color: lighten($dark-text-color, 4%);
+      padding-left: 8px;
+    }
+  }
+
+  .status__tags {
+    & > ul {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+    }
+
+    & > ul > li {
+      list-style: none;
+      display: inline-block;
+      width: 50%;
+    }
+
+    & > summary > i,
+    & > ul > li > i {
+      color: #669999;
+    }
+  }
+
+  .status__permissions {
+    & > summary > i {
+      color: #999966;
+    }
+
+    & > ul > li {
+      &.permission-status > i {
+        color: #99cccc;
+      }
+
+      &.permission-account > i {
+        color: #cc99cc;
+      }
+
+      & > span {
+        & > span, & > code {
+          color: lighten($primary-text-color, 30%);
+        }
+
+        & > span:first-child {
+          display: inline-block;
+          text-transform: capitalize;
+          min-width: 5em;
+        }
+      }
+    }
+  }
+}
+
+.status, .detailed-status {
+  &.unpublished {
+    background: darken($ui-base-color, 4%);
+
+    &:focus {
+      background: lighten($ui-base-color, 4%);
+    }
+  }
+
+  &[data-local-only="true"] {
+    background: lighten($ui-base-color, 4%);
+  }
+}
+
+div[data-nest-level] {
+  border-style: solid;
+}
+
+@for $i from 0 through 15 {
+  div[data-nest-level="#{$i}"] {
+    border-left-width: #{$i * 3}px;
+    border-left-color: darken($ui-base-color, 8%);
+  }
+}
+
+div[data-nest-deep="true"] {
+  border-left-width: 75px;
+  border-left-color: darken($ui-base-color, 8%);
+}
+
+.status__content {
+  .status__content__text,
+  .e-content {
+    img:not(.emojione) {
+      max-width: 100%;
+      margin: 1em auto;
+    }
+  }
+
+  p:first-child,
+  pre:first-child,
+  blockquote:first-child,
+  div.status__notice-wrapper + p {
+    margin-top: 0px;
+  }
+
+  p, pre, blockquote {
+    margin-top: 1em;
+    margin-bottom: 0px;
+  }
+
+  .status__content__spoiler--visible {
+    margin-top: 1em;
+    margin-bottom: 1em;
+  }
+
+  .spoiler {
+    & > i {
+      width: 1.1em;
+      color: lighten($dark-text-color, 4%);
+    }
+
+    & > span {
+      padding-left: 8px;
+    }
+  }
+
+  .reblog-spoiler {
+    font-style: italic;
+
+    & > span {
+      color: lighten($ui-highlight-color, 8%);
+    }
+  }
+}
+
+div.media-caption {
+  background: $ui-base-color;
+
+  strong {
+    font-weight: bold;
+  }
+
+  p {
+    font-size: 12px !important;
+    padding: 0px 10px;
+    text-align: center;
+  }
+  a {
+		color: $secondary-text-color;
+		text-decoration: none;
+		font-weight: bold;
+
+		&: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__prepend {
+  margin-left: 0px;
+
+  .status__prepend-icon-wrapper {
+    left: 4px;
+  }
+
+  & > span {
+    margin-left: 25px;
+  }
+}
+
+.embed .status__prepend,
+.public-layout .status__prepend {
+  margin: -10px 0px 0px 5px;
+}
+
+.public-layout .status__prepend-icon-wrapper {
+  left: unset;
+  right: 4px;
+}
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/index.scss b/app/javascript/flavours/glitch/styles/monsterfork/index.scss
new file mode 100644
index 000000000..9888adfe4
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/monsterfork/index.scss
@@ -0,0 +1,2 @@
+@import 'components/index';
+@import 'about';
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/styles/nightshade.scss b/app/javascript/flavours/glitch/styles/nightshade.scss
new file mode 100644
index 000000000..bc8069e59
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/nightshade.scss
@@ -0,0 +1,3 @@
+@import 'nightshade/variables';
+@import 'index';
+@import 'nightshade/diff';
diff --git a/app/javascript/flavours/glitch/styles/nightshade/diff.scss b/app/javascript/flavours/glitch/styles/nightshade/diff.scss
new file mode 100644
index 000000000..de1278114
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/nightshade/diff.scss
@@ -0,0 +1,440 @@
+// Notes!
+// Sass color functions, "darken" and "lighten" are automatically replaced.
+
+.glitch.local-settings {
+  background: darken($ui-base-color, 80%);
+
+  &__navigation {
+    background: darken($ui-base-color, 30%);
+  }
+
+  &__navigation__item {
+    background: darken($ui-base-color, 50%);
+
+    &:hover {
+      background: $ui-base-color;
+      color: $primary-text-color;
+    }
+  }
+}
+
+.notification__dismiss-overlay {
+  .wrappy {
+    box-shadow: unset;
+  }
+
+  .ckbox {
+    text-shadow: unset;
+  }
+}
+
+.status.status-direct:not(.read) {
+  background: darken($ui-base-color, 8%);
+  border-bottom-color: darken($ui-base-color, 12%);
+
+  &.collapsed> .status__content:after {
+    background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1));
+  }
+}
+
+.focusable:focus.status.status-direct:not(.read) {
+  background: darken($ui-base-color, 4%);
+
+  &.collapsed> .status__content:after {
+    background: linear-gradient(rgba(darken($ui-base-color, 4%), 0), rgba(darken($ui-base-color, 4%), 1));
+  }
+}
+
+// Change columns' default background colors
+.column {
+  > .scrollable {
+    background: darken($ui-base-color, 13%);
+  }
+}
+
+.status.collapsed .status__content:after {
+  background: linear-gradient(rgba(darken($ui-base-color, 13%), 0), rgba(darken($ui-base-color, 13%), 1));
+}
+
+.drawer__inner {
+  background: $ui-base-color;
+}
+
+.drawer__inner__mastodon {
+  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(darken($ui-base-color, 13%))}"/></svg>') no-repeat bottom / 100% auto !important;
+
+  .mastodon {
+    filter: contrast(75%) brightness(75%) !important;
+  }
+}
+
+// Change the default appearance of the content warning button
+.status__content {
+
+  .status__content__spoiler-link {
+
+    background: darken($ui-base-color, 30%);
+
+    &:hover {
+      background: lighten($ui-base-color, 35%);
+      color: $primary-text-color;
+      text-decoration: none;
+    }
+
+  }
+
+}
+
+// Change the background colors of media and video spoilers
+.media-spoiler,
+.video-player__spoiler,
+.account-gallery__item a {
+  background: $ui-base-color;
+}
+
+// Change the colors used in the dropdown menu
+.dropdown-menu {
+  background: $ui-base-color;
+}
+
+.dropdown-menu__arrow {
+
+  &.left {
+    border-left-color: $ui-base-color;
+  }
+
+  &.top {
+    border-top-color: $ui-base-color;
+  }
+
+  &.bottom {
+    border-bottom-color: $ui-base-color;
+  }
+
+  &.right {
+    border-right-color: $ui-base-color;
+  }
+
+}
+
+.dropdown-menu__item {
+  a {
+    background: $ui-base-color;
+    color: $ui-secondary-color;
+  }
+}
+
+// Change the default color of several parts of the compose form
+.composer {
+
+  .composer--spoiler input, .compose-form__autosuggest-wrapper textarea {
+    color: lighten($ui-base-color, 80%);
+
+    &:disabled { background: lighten($simple-background-color, 10%) }
+
+    &::placeholder {
+      color: lighten($ui-base-color, 70%);
+    }
+  }
+
+  .compose-form__modifiers {
+    background: darken($ui-base-color, 60%);
+
+    .autosuggest-input input, select {
+      background: darken($ui-base-color, 70%);
+    }
+  }
+
+  .composer--options-wrapper {
+    background: lighten($ui-base-color, 10%);
+  }
+
+  .composer--options > hr {
+    display: none;
+  }
+
+  .composer--options--dropdown--content--item {
+    color: $ui-primary-color;
+
+    strong {
+      color: $ui-primary-color;
+    }
+
+  }
+
+  header > .account.small {
+    color: $primary-text-color;
+  }
+
+  .composer--reply > .content {
+    color: $primary-text-color;
+  }
+}
+
+.composer--upload_form--actions .icon-button {
+  color: lighten($white, 7%);
+
+  &:active,
+  &:focus,
+  &:hover {
+    color: $white;
+  }
+}
+
+.composer--upload_form--item > div input {
+  color: lighten($white, 7%);
+
+  &::placeholder {
+    color: lighten($white, 10%);
+  }
+}
+
+.dropdown-menu__separator {
+  border-bottom-color: lighten($ui-base-color, 12%);
+}
+
+.status__content,
+.reply-indicator__content {
+  a {
+    color: $highlight-text-color;
+  }
+}
+
+.emoji-mart-bar {
+  border-color: darken($ui-base-color, 4%);
+
+  &:first-child {
+    background: lighten($ui-base-color, 10%);
+  }
+}
+
+.emoji-mart-search input {
+  background: rgba($ui-base-color, 0.3);
+  border-color: $ui-base-color;
+}
+
+.autosuggest-textarea__suggestions {
+  background: darken($ui-base-color, 40%)
+}
+
+.autosuggest-textarea__suggestions__item {
+  &:hover,
+  &:focus,
+  &:active,
+  &.selected {
+    background: darken($ui-base-color, 4%);
+    color: $primary-text-color;
+  }
+}
+
+.react-toggle-track {
+  background: $ui-secondary-color;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: lighten($ui-secondary-color, 10%);
+}
+
+.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: darken($ui-highlight-color, 10%);
+}
+
+// Change the background colors of modals
+.actions-modal,
+.boost-modal,
+.favourite-modal,
+.confirmation-modal,
+.mute-modal,
+.block-modal,
+.report-modal,
+.embed-modal,
+.error-modal,
+.onboarding-modal,
+.report-modal__comment .setting-text__wrapper,
+.report-modal__comment .setting-text {
+  background: $primary-text-color;
+  border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.report-modal__comment {
+  border-right-color: lighten($ui-base-color, 8%);
+}
+
+.report-modal__container {
+  border-top-color: lighten($ui-base-color, 8%);
+}
+
+.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, 20%);
+
+  .onboarding-modal__nav,
+  .error-modal__nav {
+    &:hover,
+    &:focus,
+    &:active {
+      background-color: darken($ui-base-color, 12%);
+    }
+  }
+}
+
+// Change the default color used for the text in an empty column or on the error column
+.empty-column-indicator,
+.error-column {
+  color: darken($ui-base-color, 60%);
+}
+
+// Change the default colors used on some parts of the profile pages
+.activity-stream-tabs {
+
+  background: $account-background-color;
+
+  a {
+    &.active {
+      color: $ui-primary-color;
+      }
+  }
+
+}
+
+.activity-stream {
+
+  .entry {
+    background: $account-background-color;
+  }
+
+  .status.light {
+
+    .status__content {
+      color: $primary-text-color;
+    }
+
+    .display-name {
+      strong {
+        color: $primary-text-color;
+      }
+    }
+
+  }
+
+}
+
+.accounts-grid {
+  .account-grid-card {
+
+    .controls {
+      .icon-button {
+        color: $ui-secondary-color;
+      }
+    }
+
+    .name {
+      a {
+        color: $primary-text-color;
+      }
+    }
+
+    .username {
+      color: $ui-secondary-color;
+    }
+
+    .account__header__content {
+      color: $primary-text-color;
+    }
+
+  }
+}
+
+.button.logo-button {
+  color: $white;
+
+  svg {
+    fill: $white;
+  }
+}
+
+.public-layout {
+  .header,
+  .public-account-header,
+  .public-account-bio {
+    box-shadow: none;
+  }
+
+  .header {
+    background: lighten($ui-base-color, 12%);
+  }
+
+  .public-account-header {
+    &__image {
+      background: lighten($ui-base-color, 12%);
+
+      &::after {
+        box-shadow: none;
+      }
+    }
+
+    &__tabs {
+      &__name {
+        h1,
+        h1 small {
+          color: $white;
+        }
+      }
+    }
+  }
+}
+
+.account__section-headline a.active::after {
+  border-color: transparent transparent $white;
+}
+
+.hero-widget,
+.box-widget,
+.contact-widget,
+.landing-page__information.contact-widget,
+.moved-account-widget,
+.memoriam-widget,
+.activity-stream,
+.nothing-here,
+.directory__tag > a,
+.directory__tag > div {
+  box-shadow: none;
+}
+
+.admin-wrapper {
+  .sidebar ul .simple-navigation-active-leaf a {
+    color: $black;
+  }
+}
+
+.simple_form button, .button {
+  color: $black;
+}
+
+.poll__input {
+  border: 1px solid pink;
+}
+
+.poll .button.button-secondary {
+  background: $primary-text-color;
+  color: $black;
+}
+
+button.icon-button {
+  color: $ui-secondary-color;
+}
+
+button.icon-button i.fa-retweet {
+  background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="22" height="209"><path d="M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z" fill="#{hex-color($ui-secondary-color)}" stroke-width="0"/><path d="M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z" fill="#{hex-color($ui-secondary-color)}" stroke-width="0"/></svg>');
+}
+
+button.icon-button.active 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="pink" 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="pink" stroke-width="0"/></svg>');
+  box-shadow: 0px 0px 5px pink, inset 0px 0px 5px pink;
+  border-radius: 20px;
+}
+
diff --git a/app/javascript/flavours/glitch/styles/nightshade/variables.scss b/app/javascript/flavours/glitch/styles/nightshade/variables.scss
new file mode 100644
index 000000000..46f055a8f
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/nightshade/variables.scss
@@ -0,0 +1,41 @@
+// Dependent colors
+$black: #000000;
+$white: #ffffff;
+
+$classic-base-color: #c8b7c1;
+$classic-primary-color: #4C3A45;
+$classic-secondary-color: #2C2028;
+$classic-highlight-color: #bca9b4;
+
+$ui-base-color: $classic-secondary-color !default;
+$ui-base-lighter-color: darken($ui-base-color, 57%);
+$ui-highlight-color: $classic-highlight-color !default;
+$ui-primary-color: $classic-primary-color !default;
+$ui-secondary-color: $classic-base-color !default;
+
+$primary-text-color: #e9e2e6 !default;
+$darker-text-color: $classic-base-color !default;
+$dark-text-color: #a68c9c;
+$action-button-color: #606984;
+
+$success-green: #80b38b;
+$error-red: #b38080;
+$warning-red: #b38c80;
+
+$base-overlay-background: $black !default;
+
+$inverted-text-color: #291822 !default;
+$lighter-text-color: $classic-base-color !default;
+$light-text-color: #6A5160;
+
+$account-background-color: #4C3A45 !default;
+
+@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/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss
index 6e242281b..f4e971996 100644
--- a/app/javascript/flavours/glitch/styles/variables.scss
+++ b/app/javascript/flavours/glitch/styles/variables.scss
@@ -51,11 +51,11 @@ $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: 415px;
+$no-gap-breakpoint: 700px;
 
-$font-sans-serif: 'mastodon-font-sans-serif' !default;
-$font-display: 'mastodon-font-display' !default;
-$font-monospace: 'mastodon-font-monospace' !default;
+$font-sans-serif: 'opensans' !default;
+$font-display: 'montserrat' !default;
+$font-monospace: 'roboto-mono' !default;
 
 // Avatar border size (8% default, 100% for rounded avatars)
 $ui-avatar-border-size: 8%;
diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index b397844a2..d2f4450fc 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -576,7 +576,6 @@ $fluid-breakpoint: $maximum-width + 20px;
 
 .table-of-contents {
   background: darken($ui-base-color, 4%);
-  min-height: 100%;
   font-size: 14px;
   border-radius: 4px;
 
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 911468e6f..05868970c 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -11,6 +11,8 @@ const initialState = element && function () {
 
 const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
 
+export const webPushEnabled = getMeta('web_push') === true;
+
 export const reduceMotion = getMeta('reduce_motion');
 export const autoPlayGif = getMeta('auto_play_gif');
 export const displaySensitiveMedia = getMeta('display_sensitive_media');
diff --git a/app/javascript/fonts/opensans/LICENSE.txt b/app/javascript/fonts/opensans/LICENSE.txt
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/app/javascript/fonts/opensans/LICENSE.txt
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/app/javascript/fonts/opensans/OpenSans-Bold.ttf b/app/javascript/fonts/opensans/OpenSans-Bold.ttf
new file mode 100644
index 000000000..efdd5e84a
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Bold.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Bold.woff2 b/app/javascript/fonts/opensans/OpenSans-Bold.woff2
new file mode 100644
index 000000000..e98487337
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Bold.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-BoldItalic.ttf b/app/javascript/fonts/opensans/OpenSans-BoldItalic.ttf
new file mode 100644
index 000000000..9bf9b4e97
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-BoldItalic.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-BoldItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-BoldItalic.woff2
new file mode 100644
index 000000000..68666ea6f
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-BoldItalic.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBold.ttf b/app/javascript/fonts/opensans/OpenSans-ExtraBold.ttf
new file mode 100644
index 000000000..67fcf0fb2
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-ExtraBold.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBold.woff2 b/app/javascript/fonts/opensans/OpenSans-ExtraBold.woff2
new file mode 100644
index 000000000..abdc7b7ca
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-ExtraBold.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.ttf b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.ttf
new file mode 100644
index 000000000..086722809
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.woff2
new file mode 100644
index 000000000..6e8337523
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Italic.ttf b/app/javascript/fonts/opensans/OpenSans-Italic.ttf
new file mode 100644
index 000000000..117856707
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Italic.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Italic.woff2 b/app/javascript/fonts/opensans/OpenSans-Italic.woff2
new file mode 100644
index 000000000..9398fd5da
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Italic.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Light.ttf b/app/javascript/fonts/opensans/OpenSans-Light.ttf
new file mode 100644
index 000000000..6580d3a16
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Light.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Light.woff2 b/app/javascript/fonts/opensans/OpenSans-Light.woff2
new file mode 100644
index 000000000..8496eb0f9
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Light.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-LightItalic.ttf b/app/javascript/fonts/opensans/OpenSans-LightItalic.ttf
new file mode 100644
index 000000000..1e0c33198
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-LightItalic.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-LightItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-LightItalic.woff2
new file mode 100644
index 000000000..3ccefa9cb
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-LightItalic.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Regular.ttf b/app/javascript/fonts/opensans/OpenSans-Regular.ttf
new file mode 100644
index 000000000..29bfd35a2
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Regular.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Regular.woff2 b/app/javascript/fonts/opensans/OpenSans-Regular.woff2
new file mode 100644
index 000000000..a8b531989
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-Regular.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBold.ttf b/app/javascript/fonts/opensans/OpenSans-SemiBold.ttf
new file mode 100644
index 000000000..54e7059cf
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-SemiBold.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBold.woff2 b/app/javascript/fonts/opensans/OpenSans-SemiBold.woff2
new file mode 100644
index 000000000..90d827308
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-SemiBold.woff2
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.ttf b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.ttf
new file mode 100644
index 000000000..aebcf1421
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.ttf
Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.woff2
new file mode 100644
index 000000000..ca7c2011a
--- /dev/null
+++ b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.woff2
Binary files differdiff --git a/app/javascript/locales/locale-data/en-MP.js b/app/javascript/locales/locale-data/en-MP.js
new file mode 100644
index 000000000..a2defe09a
--- /dev/null
+++ b/app/javascript/locales/locale-data/en-MP.js
@@ -0,0 +1,8 @@
+/*eslint eqeqeq: "off"*/
+/*eslint no-nested-ternary: "off"*/
+/*eslint quotes: "off"*/
+
+export default [{
+  locale: 'en-MP',
+  parentLocale: 'en',
+}];
\ No newline at end of file
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index dca44917a..d0a55538f 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -53,9 +53,12 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
+  const oldUpdatedAt = normalOldStatus ? normalOldStatus.updated_at || normalOldStatus.created_at : null;
+  const newUpdatedAt = normalStatus ? normalStatus.updated_at || normalStatus.created_at : null;
+
   // Only calculate these values when status first encountered
   // Otherwise keep the ones already in the reducer
-  if (normalOldStatus) {
+  if (normalOldStatus && oldUpdatedAt === newUpdatedAt) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index beb5c6a4a..1adc1b815 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -18,6 +18,7 @@ import {
 } from './announcements';
 import { fetchFilters } from './filters';
 import { getLocale } from '../locales';
+import { resetCompose } from '../actions/compose';
 
 const { messages } = getLocale();
 
@@ -96,6 +97,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'announcement.delete':
           dispatch(deleteAnnouncement(data.payload));
           break;
+        case 'refresh':
+          dispatch(resetCompose());
+          window.location.reload();
+          break;
         }
       },
     };
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 66b5a17ac..adcdb8a4e 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -240,10 +240,8 @@ class StatusActionBar extends ImmutablePureComponent {
     menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
     menu.push(null);
 
-    if (status.getIn(['account', 'id']) === me || withDismiss) {
-      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
-      menu.push(null);
-    }
+    menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+    menu.push(null);
 
     if (status.getIn(['account', 'id']) === me) {
       if (publicStatus) {
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 3200f2d82..df05d8515 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
+import RelativeTimestamp from './relative_timestamp';
 import classnames from 'classnames';
 import PollContainer from 'mastodon/containers/poll_container';
 import Icon from 'mastodon/components/icon';
@@ -180,6 +181,20 @@ export default class StatusContent extends React.PureComponent {
       return null;
     }
 
+    const edited = (status.get('edited') === 0) ? null : (
+      <div className='status__edit-notice'>
+        <FormattedMessage
+          id='status.edited'
+          defaultMessage='{count, plural, one {# edit} other {# edits}} · last update: {updated_at}'
+          key={`edit-${status.get('id')}`}
+          values={{
+            count: status.get('edited'),
+            updated_at: <RelativeTimestamp timestamp={status.get('updated_at')} />,
+          }}
+        />
+      </div>
+    );
+
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
     const renderReadMore = this.props.onClick && status.get('collapsed');
     const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
@@ -232,6 +247,7 @@ export default class StatusContent extends React.PureComponent {
             <button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button>
           </p>
 
+          {edited}
           {mentionsPlaceholder}
 
           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
@@ -244,6 +260,8 @@ export default class StatusContent extends React.PureComponent {
     } else if (this.props.onClick) {
       const output = [
         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
+          {edited}
+
           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
 
           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
@@ -260,6 +278,8 @@ export default class StatusContent extends React.PureComponent {
     } else {
       return (
         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
+          {edited}
+
           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
 
           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
index 88894ae59..72ffeff09 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ b/app/javascript/mastodon/features/compose/components/poll_form.js
@@ -89,7 +89,7 @@ class Option extends React.PureComponent {
 
           <AutosuggestInput
             placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
-            maxLength={100}
+            maxlength={202}
             value={title}
             onChange={this.handleOptionTitleChange}
             suggestions={this.props.suggestions}
@@ -157,7 +157,7 @@ class PollForm extends ImmutablePureComponent {
         </ul>
 
         <div className='poll__footer'>
-          <button disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
+          <button disabled={options.size >= 33} 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/locales/en-MP.json b/app/javascript/mastodon/locales/en-MP.json
new file mode 100644
index 000000000..3e021a5c1
--- /dev/null
+++ b/app/javascript/mastodon/locales/en-MP.json
@@ -0,0 +1,181 @@
+{
+  "account.add_account_note": "Add note for @{name}",
+  "account.disclaimer_full": "You're viewing the cached version of a profile from another server.",
+  "account.followers.empty": "No one follows this creature yet.",
+  "account.follows.empty": "This creature doesn't follow anyone yet.",
+  "account.follows": "Follows",
+  "account.locked_info": "This creature manually reviews who can follow them.",
+  "account.media": "Media",
+  "account.mentions": "Mentions",
+  "account.posts_with_replies": "Replies",
+  "account.posts": "Blog",
+  "account.reblogs": "Boosts",
+  "account.statuses_counter": "{count, plural, one {{counter} Roar} other {{counter} Roars}}",
+  "account.threads": "Threads",
+  "account.view_full_profile": "View the original",
+  "advanced_options.local-only.long": "Do not post to other servers",
+  "column_header.profile": "Creature",
+  "column.blocks": "Blocked creatures",
+  "column.community": "Monsterpit",
+  "column.directory": "Creature directory",
+  "column.favourites": "Admirations",
+  "column.mutes": "Muted creatures",
+  "column.pins": "Pins",
+  "column.public": "Tavern",
+  "column.toot": "Roars & Growls",
+  "community.column_settings.local_only": "Monsterpit only",
+  "community.column_settings.remote_only": "Rowdy Tavern mode",
+  "compose_form.clear": "Double-click to clear",
+  "compose_form.direct_message_warning": "This roar will only be sent to the mentioned creatures.",
+  "compose_form.hashtag_warning": "This roar won't be listed under any hashtag as it is unlisted. Only public roars can be searched by hashtag.",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.placeholder": "Roar shamelessly!",
+  "compose_form.publish": "Roar",
+  "compose_form.spoiler_placeholder": "Enter content notes here",
+  "compose_form.spoiler.marked": "Text is hidden behind content notes",
+  "compose_form.spoiler": "Enter content notes here",
+  "confirmations.delete.message": "Are you sure you want to delete this roar?",
+  "confirmations.mute.explanation": "This will hide roars from them and roars mentioning them, but it will still allow them to see your roars and follow you.",
+  "confirmations.publish.confirm": "Publish",
+  "confirmations.publish.message": "Are you ready to publish your roar?",
+  "confirmations.redraft.message": "Are you sure you want to delete and redraft this roar? Admirations and boosts will be lost, and replies to the original roar will be orphaned.",
+  "confirmations.domain_block.message": "You are about to block and defederate from the {domain} server.  If you proceed, Monsterpit will ask this server to permanently delete any data associated with your account.",
+  "content-type.change": "Content type",
+  "directory.federated": "From Tavern",
+  "directory.local": "From Monsterpit",
+  "embed.instructions": "Embed this roar on your website by copying the code below.",
+  "empty_column.account_timeline": "No roars here!",
+  "empty_column.blocks": "You haven't blocked any creatures yet.",
+  "empty_column.bookmarked_statuses": "You don't have any bookmarked roars yet. When you bookmark one, it will show up here.",
+  "empty_column.community": "The Monsterpit timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.favourited_statuses": "You don't have any admired roars yet. When you admire one, it will show up here.",
+  "empty_column.favourites": "No one has admired this roar yet. When someone does, they will show up here.",
+  "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other creatures.",
+  "empty_column.list": "There is nothing in this list yet. When members of this list post new roars, they will appear here.",
+  "empty_column.mutes": "You haven't muted any creatures yet.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow creatures from other servers to fill it up",
+  "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Monsterpit through a different browser or native app.",
+  "follow_request.authorize": "Accept",
+  "getting_started.directory": "Creature directory",
+  "getting_started.invite": "Invite creatures",
+  "getting_started.open_source_notice": "Monsterfork is open source software.  If you'd like to explore its code, you may visit the repository on {monsterware}.",
+  "introduction.federation.federated.headline": "Tavern",
+  "introduction.federation.federated.text": "Public roars from other servers will appear in the Tavern timeline.",
+  "introduction.federation.home.text": "Roars from creatures you follow will appear in your home feed.",
+  "introduction.federation.local.headline": "Monsterpit",
+  "introduction.federation.local.text": "Public roars from people on Monsterpit will appear in the Monsterpit timeline.",
+  "introduction.interactions.action": "Finish tutorial",
+  "introduction.interactions.favourite.headline": "Admire",
+  "introduction.interactions.favourite.text": "You can save a roar for later, and let the author know that you liked it, by admiring it.",
+  "introduction.interactions.reblog.text": "You can share other creature's roars with your followers by boosting them.",
+  "introduction.interactions.reply.text": "You can reply to other creature's and your own roars, which will chain them together in a conversation.",
+  "keyboard_shortcuts.blocked": "to open blocked creatures list",
+  "keyboard_shortcuts.column": "to focus a roar in one of the columns",
+  "keyboard_shortcuts.enter": "to open roar",
+  "keyboard_shortcuts.favourite": "to admire",
+  "keyboard_shortcuts.favourites": "to open admirations list",
+  "keyboard_shortcuts.federated": "to open Tavern timeline",
+  "keyboard_shortcuts.local": "to open Monsterpit timeline",
+  "keyboard_shortcuts.muted": "to open muted creatures list",
+  "keyboard_shortcuts.pinned": "to open pinned roars list",
+  "keyboard_shortcuts.spoilers": "to show/hide content note field",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind content notes",
+  "keyboard_shortcuts.toot": "to start a new roar",
+  "lists.search": "Search among creatures you follow",
+  "mute_modal.hide_notifications": "Hide notifications from this creature?",
+  "navigation_bar.blocks": "Blocked creatures",
+  "navigation_bar.community_timeline": "Monsterpit",
+  "navigation_bar.compose": "Compose new roar",
+  "navigation_bar.favourites": "Admirations",
+  "navigation_bar.logout": "Sleep",
+  "navigation_bar.mutes": "Muted creatures",
+  "navigation_bar.pins": "Pins",
+  "navigation_bar.public_timeline": "Tavern",
+  "notification_purge.start": "Enter notification cleaning mode",
+  "notification.favourite": "{name} admired your roar",
+  "notification.follow_request": "{name} wants to follow you",
+  "notification.follow": "{name} followed you",
+  "notification.mention": "{name} mentioned you",
+  "notification.own_poll": "Your poll has ended",
+  "notification.poll": "A poll you have voted in has ended",
+  "notification.reblog": "{name} boosted your roar",
+  "notifications.clear": "Clear notifications",
+  "notifications.column_settings.favourite": "Admirations:",
+  "notifications.filter.favourites": "Admirations",
+  "notifications_permission_banner.title": "You have desktop notifications enabled.",
+  "notifications_permission_banner.enable": "Request",
+  "notifications_permission_banner.disable": "Disable",
+  "notifications_permission_banner.how_to_control": "Permission is needed from your Web browser to use this feature.",
+  "poll.total_people": "{count, plural, one {# creature} other {# creatures}}",
+  "privacy.change": "Adjust roar privacy",
+  "privacy.direct.long": "Visible for mentioned creatures only",
+  "report.forward_hint": "The creature is from another server. Send an anonymized copy of the report there as well?",
+  "report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this creature below:",
+  "search_popout.tips.full_text": "Simple text returns roars you have written, admired, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
+  "search_popout.tips.status": "roar",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "creature",
+  "search_results.accounts": "Creatures",
+  "search_results.statuses_fts_disabled": "Searching roars by their content is not enabled on this Mastodon server.",
+  "search_results.statuses": "Roars",
+  "settings.always_show_spoilers_field": "Always show content notes field",
+  "settings.auto_collapse_lengthy": "Lengthy roars",
+  "settings.auto_collapse_media": "Media",
+  "settings.collapsed_statuses": "Collapsed roars",
+  "settings.confirm_boost_missing_media_description": "Show confirmation dialog before boosting roars lacking media descriptions",
+  "settings.confirm_missing_media_description": "Show confirmation dialog before sending roars lacking media descriptions",
+  "settings.content_warnings_filter": "Avoid expanding roars with content notes containing:",
+  "settings.content_warnings": "Content notes",
+  "settings.enable_collapsed": "Enable collapsed roars",
+  "settings.enable_content_warnings_auto_unfold": "Auto-expand roars with content notes",
+  "settings.filtering_behavior.cw": "Add the filtered phrase to the roar's content notes",
+  "settings.image_backgrounds_media": "Preview collapsed media",
+  "settings.image_backgrounds_users": "Give collapsed roars an image background",
+  "settings.prepend_cw_re": "Prepend \"re:\" to content notes when replying",
+  "settings.rewrite_mentions": "Rewrite mentions in roars:",
+  "settings.show_action_bar": "Show action buttons in collapsed roars",
+  "settings.show_content_type_choice": "Show content-type choice when authoring roars",
+  "settings.side_arm_reply_mode.copy": "Copy privacy setting of the roar being replied to",
+  "settings.side_arm_reply_mode.keep": "Keep secondary roar button to set privacy",
+  "settings.side_arm_reply_mode.restrict": "Restrict privacy setting to that of the roar being replied to",
+  "settings.side_arm_reply_mode": "When replying to a roar:",
+  "settings.side_arm": "Secondary roar button:",
+  "status.admin_account": "Moderate @{name}",
+  "status.admin_status": "Moderate roar",
+  "status.article": "Article",
+  "status.cannot_reblog": "This roar cannot be boosted",
+  "status.copy": "Copy link to roar",
+  "status.edit": "Edit",
+  "status.edited": "{count, plural, one {# edit} other {# edits}} · last update: {updated_at}",
+  "status.favourite": "Admire",
+  "status.has_pictures": "Features attached pictures",
+  "status.in_reply_to": "This roar is a reply",
+  "status.is_poll": "This roar is a poll",
+  "status.local_only": "Monsterpit-only",
+  "status.media.description": "Attachment #{index}: ",
+  "status.media.descriptions": "Attachments {list}: ",
+  "status.open": "Open this roar",
+  "status.permissions.title": "Show extended permissions...",
+  "status.permissions.visibility.account": "{visibility} 🡲 {domain}",
+  "status.permissions.visibility.status": "{visibility} 🡲 {domain}",
+  "status.pinned": "Pinned",
+  "status.publish": "Publish",
+  "status.reblogged_by": "{name}",
+  "status.reblogs.empty": "No one has boosted this roar yet. When someone does, they will show up here.",
+  "status.show_article": "Show article",
+  "status.show_less_all": "Hide all",
+  "status.show_less": "Hide",
+  "status.show_more_all": "Reveal all",
+  "status.show_more": "Reveal",
+  "status.show_thread": "Reveal thread",
+  "status.tags": "Show all tags...",
+  "status.unpublished": "Unpublished",
+  "tabs_bar.federated_timeline": "Tavern",
+  "tabs_bar.local_timeline": "Monsterpit",
+  "timeline_hint.resources.statuses": "Older roars",
+  "trends.counter_by_accounts": "{count, plural, one {{counter} creature} other {{counter} creatures}} talking",
+  "ui.beforeunload": "Your draft will be lost if you leave the web page.",
+  "upload_form.edit": "Add description text",
+  "upload_modal.edit_media": "Add description text",
+  "video.expand": "Open video"
+}
diff --git a/app/javascript/mastodon/locales/locale-data/en-MP.js b/app/javascript/mastodon/locales/locale-data/en-MP.js
new file mode 100644
index 000000000..a2defe09a
--- /dev/null
+++ b/app/javascript/mastodon/locales/locale-data/en-MP.js
@@ -0,0 +1,8 @@
+/*eslint eqeqeq: "off"*/
+/*eslint no-nested-ternary: "off"*/
+/*eslint quotes: "off"*/
+
+export default [{
+  locale: 'en-MP',
+  parentLocale: 'en',
+}];
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_en-MP.json b/app/javascript/mastodon/locales/whitelist_en-MP.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_en-MP.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 4c0ba1c36..67ce96feb 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -205,7 +205,9 @@ 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')}`;
+    const selection = fragment.querySelector(`a[href="${mention.get('url')}"]`);
+    if (!selection) return;
+    selection.textContent = `@${mention.get('acct')}`;
   });
 
   return fragment.innerHTML;
diff --git a/app/javascript/skins/glitch/nightshade/common.scss b/app/javascript/skins/glitch/nightshade/common.scss
new file mode 100644
index 000000000..ada0fd156
--- /dev/null
+++ b/app/javascript/skins/glitch/nightshade/common.scss
@@ -0,0 +1 @@
+@import 'flavours/glitch/styles/nightshade';
diff --git a/app/javascript/skins/glitch/nightshade/names.yml b/app/javascript/skins/glitch/nightshade/names.yml
new file mode 100644
index 000000000..db7010ec5
--- /dev/null
+++ b/app/javascript/skins/glitch/nightshade/names.yml
@@ -0,0 +1,5 @@
+en:
+  skins:
+    glitch:
+      nightshade: Nightshade
+
diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss
index 80c2329b0..103dee529 100644
--- a/app/javascript/styles/fonts/montserrat.scss
+++ b/app/javascript/styles/fonts/montserrat.scss
@@ -1,5 +1,5 @@
 @font-face {
-  font-family: 'mastodon-font-display';
+  font-family: 'montserrat';
   src: local('Montserrat'),
     url('~fonts/montserrat/Montserrat-Regular.woff2') format('woff2'),
     url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'),
@@ -9,7 +9,7 @@
 }
 
 @font-face {
-  font-family: 'mastodon-font-display';
+  font-family: 'montserrat';
   src: local('Montserrat Medium'),
     url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
   font-weight: 500;
diff --git a/app/javascript/styles/fonts/opensans.scss b/app/javascript/styles/fonts/opensans.scss
new file mode 100644
index 000000000..6da41e30a
--- /dev/null
+++ b/app/javascript/styles/fonts/opensans.scss
@@ -0,0 +1,134 @@
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans ExtraBold'),
+    url('~fonts/opensans/OpenSans-ExtraBold.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-ExtraBold.ttf') format('truetype');
+  font-weight: bolder;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Bold'),
+    url('~fonts/opensans/OpenSans-Bold.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Bold.ttf') format('truetype');
+  font-weight: bold;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Bold Italic'),
+    url('~fonts/opensans/OpenSans-BoldItalic.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-BoldItalic.ttf') format('truetype');
+  font-weight: bold;
+  font-style: italic;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans SemiBold'),
+    url('~fonts/opensans/OpenSans-SemiBold.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-SemiBold.ttf') format('truetype');
+  font-weight: 500;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans SemiBold Italic'),
+    url('~fonts/opensans/OpenSans-SemiBoldItalic.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-SemiBoldItalic.ttf') format('truetype');
+  font-weight: 500;
+  font-style: italic;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Regular'),
+    url('~fonts/opensans/OpenSans-Regular.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Regular.ttf') format('truetype');
+  font-weight: normal;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Italic'),
+    url('~fonts/opensans/OpenSans-Italic.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Italic.ttf') format('truetype');
+  font-weight: normal;
+  font-style: italic;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Regular'),
+    url('~fonts/opensans/OpenSans-Regular.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Regular.ttf') format('truetype');
+  font-weight: lighter;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Italic'),
+    url('~fonts/opensans/OpenSans-Italic.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Italic.ttf') format('truetype');
+  font-weight: lighter;
+  font-style: italic;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Light'),
+    url('~fonts/opensans/OpenSans-Light.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Light.ttf') format('truetype');
+  font-weight: 300;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Light Italic'),
+    url('~fonts/opensans/OpenSans-LightItalic.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-LightItalic.ttf') format('truetype');
+  font-weight: 300;
+  font-style: italic;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Light'),
+    url('~fonts/opensans/OpenSans-Light.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Light.ttf') format('truetype');
+  font-weight: 200;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Light Italic'),
+    url('~fonts/opensans/OpenSans-LightItalic.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-LightItalic.ttf') format('truetype');
+  font-weight: 200;
+  font-style: italic;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Light'),
+    url('~fonts/opensans/OpenSans-Light.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-Light.ttf') format('truetype');
+  font-weight: 100;
+  font-style: normal;
+}
+
+@font-face {
+  font-family: 'opensans';
+  src: local('Open Sans Light Italic'),
+    url('~fonts/opensans/OpenSans-LightItalic.woff2') format('woff2'),
+    url('~fonts/opensans/OpenSans-LightItalic.ttf') format('truetype');
+  font-weight: 100;
+  font-style: italic;
+}
\ No newline at end of file
diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss
index c793aa6ed..b689c87fe 100644
--- a/app/javascript/styles/fonts/roboto-mono.scss
+++ b/app/javascript/styles/fonts/roboto-mono.scss
@@ -1,5 +1,5 @@
 @font-face {
-  font-family: 'mastodon-font-monospace';
+  font-family: 'roboto-mono';
   src: local('Roboto Mono'),
     url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'),
     url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'),
diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss
index b75fdb927..a34cc693c 100644
--- a/app/javascript/styles/fonts/roboto.scss
+++ b/app/javascript/styles/fonts/roboto.scss
@@ -1,5 +1,5 @@
 @font-face {
-  font-family: 'mastodon-font-sans-serif';
+  font-family: 'roboto';
   src: local('Roboto Italic'),
     url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
     url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'),
@@ -10,7 +10,7 @@
 }
 
 @font-face {
-  font-family: 'mastodon-font-sans-serif';
+  font-family: 'roboto';
   src: local('Roboto Bold'),
     url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
     url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'),
@@ -21,7 +21,7 @@
 }
 
 @font-face {
-  font-family: 'mastodon-font-sans-serif';
+  font-family: 'roboto';
   src: local('Roboto Medium'),
     url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
     url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'),
@@ -32,7 +32,7 @@
 }
 
 @font-face {
-  font-family: 'mastodon-font-sans-serif';
+  font-family: 'roboto';
   src: local('Roboto'),
     url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'),
     url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'),
diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index e25a80c04..3b3ca000d 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -1,5 +1,7 @@
 @import 'mastodon/variables';
+@import 'fonts/opensans';
 @import 'fonts/roboto';
+@import 'fonts/roboto-mono';
 
 table,
 td,
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
index f463419c8..4d232ad12 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -53,6 +53,6 @@ $media-modal-media-max-height: 80%;
 
 $no-gap-breakpoint: 415px;
 
-$font-sans-serif: 'mastodon-font-sans-serif' !default;
-$font-display: 'mastodon-font-display' !default;
-$font-monospace: 'mastodon-font-monospace' !default;
+$font-sans-serif: 'roboto' !default;
+$font-display: 'montserrat' !default;
+$font-monospace: 'roboto-mono' !default;
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 2b5d3ffc2..968dd3f67 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -4,8 +4,8 @@ class ActivityPub::Activity
   include JsonLdHelper
   include Redisable
 
-  SUPPORTED_TYPES = %w(Note Question).freeze
-  CONVERTED_TYPES = %w(Image Audio Video Article Page Event).freeze
+  SUPPORTED_TYPES = %w(Note Question Article).freeze
+  CONVERTED_TYPES = %w(Image Audio Video Page Event).freeze
 
   def initialize(json, account, **options)
     @json    = json
@@ -190,7 +190,7 @@ class ActivityPub::Activity
   end
 
   def first_local_follower
-    @account.followers.local.first
+    @account.followers.local.random.first
   end
 
   def follow_request_from_object
@@ -204,9 +204,9 @@ class ActivityPub::Activity
   def fetch_remote_original_status
     if object_uri.start_with?('http')
       return if ActivityPub::TagManager.instance.local_uri?(object_uri)
-      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first)
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: signed_fetch_account)
     elsif @object['url'].present?
-      ::FetchRemoteStatusService.new.call(@object['url'])
+      ::FetchRemoteStatusService.new.call(@object['url'], nil, signed_fetch_account)
     end
   end
 
diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb
index 688ab00b3..03b584302 100644
--- a/app/lib/activitypub/activity/add.rb
+++ b/app/lib/activitypub/activity/add.rb
@@ -9,6 +9,6 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
 
     return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
 
-    StatusPin.create!(account: @account, status: status)
+    StatusPin.create(account: @account, status: status)
   end
 end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 349e8f77e..21fe556af 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -2,7 +2,7 @@
 
 class ActivityPub::Activity::Announce < ActivityPub::Activity
   def perform
-    return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
+    return reject_payload! if delete_arrived_first?(@json['id'])
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
@@ -44,13 +44,13 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
 
   def visibility_from_audience
     if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
-      :public
+      @account.private? ? :private : :public
     elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
-      :unlisted
+      @account.private? ? :private : :unlisted
     elsif audience_to.include?(@account.followers_url)
       :private
     else
-      :direct
+      :limited
     end
   end
 
@@ -58,18 +58,6 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
     status.account_id == @account.id || status.distributable?
   end
 
-  def related_to_local_activity?
-    followed_by_local_accounts? || requested_through_relay? || reblog_of_local_status?
-  end
-
-  def requested_through_relay?
-    super || Relay.find_by(inbox_url: @account.inbox_url)&.enabled?
-  end
-
-  def reblog_of_local_status?
-    status_from_uri(object_uri)&.account&.local?
-  end
-
   def lock_options
     { redis: Redis.current, key: "announce:#{@object['id']}" }
   end
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
index 90477bf33..d8ca9951e 100644
--- a/app/lib/activitypub/activity/block.rb
+++ b/app/lib/activitypub/activity/block.rb
@@ -11,7 +11,7 @@ class ActivityPub::Activity::Block < ActivityPub::Activity
       return
     end
 
-    UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
+    BlockService.new.call(target_account, @account)
 
     @account.block!(target_account, uri: @json['id']) unless delete_arrived_first?(@json['id'])
   end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index d56d47a2d..3053b1bb3 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
+# rubocop:disable Metrics/ClassLength
 class ActivityPub::Activity::Create < ActivityPub::Activity
+  include ImgProxyHelper
+  include DomainControlHelper
+
   def perform
     dereference_object!
 
@@ -43,7 +47,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def create_status
-    return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity?
+    return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity? || twitter_retweet?
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
@@ -51,7 +55,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
         @status = find_existing_status
 
-        if @status.nil?
+        if @status.nil? || @options[:update]
           process_status
         elsif @options[:delivered_to_account_id].present?
           postprocess_audience_and_deliver
@@ -72,22 +76,38 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
   end
 
+  def object_uri
+    @object['id'] || super
+  end
+
   def process_status
     @tags     = []
     @mentions = []
     @params   = {}
 
-    process_status_params
+    unless @status.nil?
+      reblog_uri.blank? ? process_status_update_params : process_reblog_update_params
+      process_tags
+      process_audience
+
+      @status = UpdateStatusService.new.call(@status, @params, @mentions, @tags)
+      resolve_thread(@status)
+      fetch_replies(@status) unless @account.silenced?
+      return @status
+    end
+
+    reblog_uri.blank? ? process_status_params : process_reblog_params
     process_tags
     process_audience
 
     ApplicationRecord.transaction do
       @status = Status.create!(@params)
+      process_inline_images!
       attach_tags(@status)
     end
 
     resolve_thread(@status)
-    fetch_replies(@status)
+    fetch_replies(@status) unless @account.silenced?
     check_for_spam
     distribute(@status)
     forward_for_reply
@@ -108,7 +128,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         text: text_from_content || '',
         language: detected_language,
         spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
+        title: text_from_title,
+        reblog: reblogged_status,
         created_at: @object['published'],
+        expires_at: @object['expires'],
         override_timestamps: @options[:override_timestamps],
         reply: @object['inReplyTo'].present?,
         sensitive: @account.sensitized? || @object['sensitive'] || false,
@@ -121,9 +144,61 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     end
   end
 
+  def process_status_update_params
+    @params = begin
+      {
+        text: text_from_content || '',
+        language: detected_language,
+        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
+        title: text_from_title,
+        sensitive: @object['sensitive'] || false,
+        visibility: visibility_from_audience,
+        expires_at: @object['expires'],
+        media_attachment_ids: process_attachments.take(4).map(&:id),
+      }
+    end
+  end
+
+  def process_reblog_params
+    @params = begin
+      {
+        uri: object_uri,
+        url: object_url || object_uri,
+        account: @account,
+        text: text_from_content || '',
+        language: detected_language,
+        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
+        title: text_from_title,
+        reblog: reblogged_status,
+        created_at: @object['published'],
+        override_timestamps: @options[:override_timestamps],
+        reply: @object['inReplyTo'].present?,
+        sensitive: @object['sensitive'] || false,
+        visibility: visibility_from_audience,
+        thread: replied_to_status,
+      }
+    end
+  end
+
+  def process_reblog_update_params
+    @params = begin
+      {
+        text: text_from_content || '',
+        language: detected_language,
+        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
+        title: text_from_title,
+        sensitive: @object['sensitive'] || false,
+        visibility: visibility_from_audience,
+      }
+    end
+  end
+
   def process_audience
+    @params[:visibility] = :unlisted if @account.silenced? && @params[:visibility] == :public
+
     (audience_to + audience_cc).uniq.each do |audience|
       next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
+      next (@params[:visibility] = :limited) if domain_not_allowed?(audience)
 
       # Unlike with tags, there is no point in resolving accounts we don't already
       # know here, because silent mentions would only be used for local access
@@ -133,15 +208,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id }
 
       @mentions << Mention.new(account: account, silent: true)
+      @params[:visibility] = :unlisted if account.silenced? && @params[:visibility] == :public
 
       # 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
-      next unless @params[:visibility] == :direct && direct_message.nil?
+      next unless account.suspended? || (@params[:visibility] == :direct && direct_message.nil?)
 
       @params[:visibility] = :limited
     end
 
+    @params[:visibility] = :limited if @params[:reply] && @params[:visibility] == :private && @mentions.pluck(:account_id).without(@account.id).present?
+
     # If the payload was delivered to a specific inbox, the inbox owner must have
     # access to it, unless they already have access to it anyway
     return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] }
@@ -204,11 +282,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def process_mention(tag)
     return if tag['href'].blank?
+    return (@params[:visibility] = :limited) if domain_not_allowed?(tag['href'])
 
     account = account_from_uri(tag['href'])
     account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil?
 
-    return if account.nil?
+    return (@params[:visibility] = :limited) if account.nil? || account.suspended?
 
     @mentions << Mention.new(account: account, silent: false)
   end
@@ -240,7 +319,21 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
       begin
         href             = Addressable::URI.parse(attachment['url']).normalize.to_s
-        media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
+        media_attachment = MediaAttachment.find_by(account: @account, remote_url: href)
+
+        if media_attachment.nil?
+          media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
+        else
+          updated_description = attachment['summary'].presence || media_attachment[:description].presence || attachment['name'].presence || media_attachment[:name].presence
+          updated_focus = attachment['focalPoint'].presence || media_attachment['focalPoint'].presence
+          updated_blurhash = supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : media_attachment[:blurhash]
+
+          media_attachment.update(description: updated_description, focus: updated_focus, blurhash: updated_blurhash)
+
+          media_attachments << media_attachment
+          next
+        end
+
         media_attachments << media_attachment
 
         next if unsupported_media_type?(attachment['mediaType']) || skip_download?
@@ -330,22 +423,38 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def fetch_replies(status)
+    FetchReplyWorker.perform_async(@object['root']) unless invalid_root_uri?
+
     collection = @object['replies']
     return if collection.nil?
 
-    replies = ActivityPub::FetchRepliesService.new.call(status, collection, false)
-    return unless replies.nil?
-
-    uri = value_or_id(collection)
-    ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil?
+    if collection.is_a?(Hash)
+      ActivityPub::FetchRepliesService.new.call(status, collection)
+    else
+      uri = value_or_id(collection)
+      ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil?
+    end
   end
 
   def conversation_from_uri(uri)
     return nil if uri.nil?
-    return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
+
+    conversation = OStatus::TagManager.instance.local_id?(uri) ? Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) : nil
 
     begin
-      Conversation.find_or_create_by!(uri: uri)
+      conversation = Conversation.find_by(uri: uri) if conversation.blank?
+
+      if @object['inReplyTo'].blank? && replied_to_status.blank?
+        if conversation.blank?
+          conversation = Conversation.create!(uri: uri, root: object_uri)
+        elsif conversation.root.blank?
+          conversation.update!(uri: uri, root: object_uri)
+        end
+      elsif conversation.blank?
+        conversation = Conversation.create!(uri: uri)
+      end
+
+      conversation
     rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
       retry
     end
@@ -353,9 +462,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def visibility_from_audience
     if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
-      :public
+      @account.private? ? :private : :public
     elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
-      :unlisted
+      @account.private? ? :private : :unlisted
     elsif audience_to.include?(@account.followers_url)
       :private
     elsif direct_message == false
@@ -377,7 +486,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def replied_to_status
     return @replied_to_status if defined?(@replied_to_status)
 
-    if in_reply_to_uri.blank?
+    if in_reply_to_uri.blank? || in_reply_to_uri == object_uri
       @replied_to_status = nil
     else
       @replied_to_status   = status_from_uri(in_reply_to_uri)
@@ -390,13 +499,28 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     value_or_id(@object['inReplyTo'])
   end
 
+  def reblogged_status
+    FetchRemoteStatusService.new.call(reblog_uri) if reblog_uri.present?
+  end
+
+  def reblog_uri
+    return @reblog_uri if defined?(@reblog_uri)
+
+    @reblog_uri = @object['reblog'].presence || @object['_misskey_quote'].presence
+  end
+
+  def twitter_retweet?
+    text_from_content.present? && (text_from_content.include?('<p>🐦🔗') || text_from_content.include?('<p>RT @'))
+  end
+
   def text_from_content
-    return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
+    return @status_text if defined?(@status_text)
+    return @status_text = Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
 
     if @object['content'].present?
-      @object['content']
+      @status_text = @object['type'] == 'Article' ? Formatter.instance.format_article(@object['content']) : @object['content']
     elsif content_language_map?
-      @object['contentMap'].values.first
+      @status_text = @object['contentMap'].values.first
     end
   end
 
@@ -408,6 +532,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     end
   end
 
+  def text_from_title
+    if @object['title'].present?
+      @object['title']
+    elsif title_language_map?
+      @object['titleMap'].values.first
+    end
+  end
+
   def text_from_name
     if @object['name'].present?
       @object['name']
@@ -444,6 +576,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
   end
 
+  def title_language_map?
+    @object['titleMap'].is_a?(Hash) && !@object['titleMap'].empty?
+  end
+
   def content_language_map?
     @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
   end
@@ -490,6 +626,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     Account.local.where(username: local_usernames).exists?
   end
 
+  def invalid_root_uri?
+    @object['root'].blank? || [object_uri, @object['url']].include?(@object['root']) || status_from_uri(@object['root'])
+  end
+
   def tombstone_exists?
     Tombstone.exists?(uri: object_uri)
   end
@@ -524,3 +664,4 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     { redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" }
   end
 end
+# rubocop:enable Metrics/ClassLength
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 2e5293b83..71852b379 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -51,15 +51,12 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 
   def replied_to_status
     return @replied_to_status if defined?(@replied_to_status)
-    @replied_to_status = @status.thread
-  end
 
-  def reply_to_local?
-    !replied_to_status.nil? && replied_to_status.account.local?
+    @replied_to_status = @status.thread
   end
 
   def forward_for_reply
-    return unless @json['signature'].present? && reply_to_local?
+    return if @json['signature'].blank? || replied_to_status.blank?
 
     inboxes = replied_to_status.account.followers.inboxes - [@account.preferred_inbox_url]
 
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
index 018e2df54..d1dba5196 100644
--- a/app/lib/activitypub/activity/update.rb
+++ b/app/lib/activitypub/activity/update.rb
@@ -2,6 +2,7 @@
 
 class ActivityPub::Activity::Update < ActivityPub::Activity
   SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
+  SUPPORTED_OBJECT_TYPES = (ActivityPub::Activity::SUPPORTED_TYPES + ActivityPub::Activity::CONVERTED_TYPES).freeze
 
   def perform
     dereference_object!
@@ -10,6 +11,9 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
       update_account
     elsif equals_or_includes_any?(@object['type'], %w(Question))
       update_poll
+    elsif equals_or_includes_any?(@object['type'], SUPPORTED_OBJECT_TYPES)
+      @options[:update] = true
+      ActivityPub::Activity::Create.new(@json, @account, @options).perform
     end
   end
 
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index ef00a4e2e..6fcf219be 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -8,6 +8,16 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 
   CONTEXT_EXTENSION_MAP = {
     direct_message: { 'litepub': 'http://litepub.social/ns#', 'directMessage': 'litepub:directMessage' },
+    edited: { 'mp' => 'https://the.monsterpit.net/ns#', 'edited' => 'mp:edited' },
+    show_replies: { 'mp' => 'https://the.monsterpit.net/ns#', 'showReplies' => 'mp:showReplies' },
+    show_unlisted: { 'mp' => 'https://the.monsterpit.net/ns#', 'showUnlisted' => 'mp:showUnlisted' },
+    private: { 'mp' => 'https://the.monsterpit.net/ns#', 'private' => 'mp:private' },
+    metadata: { 'mp' => 'https://the.monsterpit.net/ns#', 'metadata' => { '@id' => 'mp:metadata', '@type' => '@id' } },
+    server_metadata: { 'mp' => 'https://the.monsterpit.net/ns#', 'serverMetadata' => { '@id' => 'mp:serverMetadata', '@type' => '@id' } },
+    root: { 'mp' => 'https://the.monsterpit.net/ns#', 'root' => { '@id' => 'mp:root', '@type' => '@id' } },
+    reblog: { 'mp' => 'https://the.monsterpit.net/ns#', 'reblog' => { '@id' => 'mp:reblog', '@type' => '@id' },
+              'misskey' => 'https://misskey.io/ns#', '_misskey_quote' => { '@id' => 'misskey:_misskey_quote', '@type' => '@id' } },
+    expires: { 'mp' => 'https://the.monsterpit.net/ns#', 'expires' => 'mp:expires' },
     manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' },
     sensitive: { 'sensitive' => 'as:sensitive' },
     hashtag: { 'Hashtag' => 'as:Hashtag' },
@@ -15,7 +25,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     also_known_as: { 'alsoKnownAs' => { '@id' => 'as:alsoKnownAs', '@type' => '@id' } },
     emoji: { 'toot' => 'http://joinmastodon.org/ns#', 'Emoji' => 'toot:Emoji' },
     featured: { 'toot' => 'http://joinmastodon.org/ns#', 'featured' => { '@id' => 'toot:featured', '@type' => '@id' }, 'featuredTags' => { '@id' => 'toot:featuredTags', '@type' => '@id' } },
-    property_value: { 'schema' => 'http://schema.org#', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' },
+    property_value: { 'schema' => 'http://schema.org', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' },
     atom_uri: { 'ostatus' => 'http://ostatus.org#', 'atomUri' => 'ostatus:atomUri' },
     conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb
index 7f716f862..7f31fabda 100644
--- a/app/lib/activitypub/case_transform.rb
+++ b/app/lib/activitypub/case_transform.rb
@@ -14,8 +14,10 @@ module ActivityPub::CaseTransform
       when String
         camel_lower_cache[value] ||= if value.start_with?('_:')
                                        '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
-                                     else
+                                     elsif value != '_misskey_quote'
                                        value.underscore.camelize(:lower)
+                                     else
+                                       value
                                      end
       else value
       end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 3f2ae1106..383f182d2 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -64,29 +64,21 @@ class ActivityPub::TagManager
   # Public statuses go out to primarily the public collection
   # Unlisted and private statuses go out primarily to the followers collection
   # Others go out only to the people they mention
-  def to(status)
-    case status.visibility
-    when 'public'
-      [COLLECTIONS[:public]]
-    when 'unlisted', 'private'
-      [account_followers_url(status.account)]
-    when 'direct', 'limited'
-      if status.account.silenced?
-        # Only notify followers if the account is locally silenced
-        account_ids = status.active_mentions.pluck(:account_id)
-        to = status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
-          result << uri_for(account)
-          result << account_followers_url(account) if account.group?
-        end
-        to.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
-          result << uri_for(request.account)
-          result << account_followers_url(request.account) if request.account.group?
-        end)
-      else
-        status.active_mentions.each_with_object([]) do |mention, result|
-          result << uri_for(mention.account)
-          result << account_followers_url(mention.account) if mention.account.group?
-        end
+  def to(status, domain)
+    visibility = status.visibility_for_domain(domain)
+    case visibility
+    when 'public', 'unlisted'
+      [status.tags.present? ? COLLECTIONS[:public] : account_followers_url(status.account)]
+    else
+      account_ids = status.active_mentions.pluck(:account_id)
+      account_ids |= status.account.follower_ids if visibility == 'private'
+
+      accounts = status.account.silenced? ? status.account.followers.where(id: account_ids) : Account.where(id: account_ids)
+      accounts = accounts.where(domain: domain) if domain.present?
+
+      accounts.each_with_object([]) do |account, result|
+        result << uri_for(account)
+        result << account_followers_url(account) if account.group?
       end
     end
   end
@@ -96,36 +88,32 @@ class ActivityPub::TagManager
   # Unlisted statuses go to the public as well
   # Both of those and private statuses also go to the people mentioned in them
   # Direct ones don't have a secondary audience
-  def cc(status)
+  def cc(status, domain)
     cc = []
-
     cc << uri_for(status.reblog.account) if status.reblog?
 
-    case status.visibility
-    when 'public'
-      cc << account_followers_url(status.account)
-    when 'unlisted'
-      cc << COLLECTIONS[:public]
+    visibility = status.visibility_for_domain(domain)
+
+    case visibility
+    when 'public', 'unlisted'
+      cc << (status.tags.present? ? account_followers_url(status.account) : COLLECTIONS[:public])
+      account_ids = status.active_mentions.pluck(:account_id)
+    when 'private', 'limited'
+      # Work around Mastodon visibility heuritic bug by addressing instance actor.
+      cc << instance_actor_url
+      account_ids = status.silent_mentions.pluck(:account_id)
+    else
+      account_ids = []
     end
 
-    unless status.direct_visibility? || status.limited_visibility?
-      if status.account.silenced?
-        # Only notify followers if the account is locally silenced
-        account_ids = status.active_mentions.pluck(:account_id)
-        cc.concat(status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
-          result << uri_for(account)
-          result << account_followers_url(account) if account.group?
-        end)
-        cc.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
-          result << uri_for(request.account)
-          result << account_followers_url(request.account) if request.account.group?
-        end)
-      else
-        cc.concat(status.active_mentions.each_with_object([]) do |mention, result|
-          result << uri_for(mention.account)
-          result << account_followers_url(mention.account) if mention.account.group?
-        end)
-      end
+    if account_ids.present?
+      accounts = status.account.silenced? ? status.account.followers.where(id: account_ids) : Account.where(id: account_ids)
+      accounts = accounts.where(domain: domain) if domain.present?
+
+      cc.concat(accounts.each_with_object([]) do |account, result|
+        result << uri_for(account)
+        result << account_followers_url(account) if account.group?
+      end)
     end
 
     cc
diff --git a/app/lib/command_tag/commands.rb b/app/lib/command_tag/commands.rb
new file mode 100644
index 000000000..068c8bb66
--- /dev/null
+++ b/app/lib/command_tag/commands.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands
+  def self.included(base)
+    Dir[File.join(__dir__, 'commands', '*.rb')].sort.each do |file|
+      require file
+      base.include(CommandTag::Commands.const_get(File.basename(file).gsub('.rb', '').split('_').map(&:capitalize).join))
+    end
+  end
+end
diff --git a/app/lib/command_tag/commands/account_tools.rb b/app/lib/command_tag/commands/account_tools.rb
new file mode 100644
index 000000000..d1c652dee
--- /dev/null
+++ b/app/lib/command_tag/commands/account_tools.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+module CommandTag::Commands::AccountTools
+  def handle_account_at_start(args)
+    return if args[0].blank?
+
+    case args[0].downcase
+    when 'set'
+      handle_account_set(args[1..-1])
+    end
+  end
+
+  alias handle_acct_at_start handle_account_at_start
+
+  private
+
+  def handle_account_set(args)
+    return if args[0].blank?
+
+    case args[0].downcase
+    when 'v', 'p', 'visibility', 'privacy', 'default-visibility', 'default-privacy'
+      args[1] = read_visibility_from(args[1])
+      return if args[1].blank?
+
+      if args[2].blank?
+        @account.user.settings.default_privacy = args[1]
+      elsif args[1] == 'public'
+        domains = args[2..-1].map { |domain| normalize_domain(domain) unless domain == '*' }.uniq.compact
+        @account.domain_permissions.where(domain: domains, sticky: false).destroy_all if domains.present?
+      elsif args[1] != 'cc'
+        args[2..-1].flat_map(&:split).uniq.each do |domain|
+          domain = normalize_domain(domain) unless domain == '*'
+          @account.domain_permissions.create_or_update(domain: domain, visibility: args[1]) if domain.present?
+        end
+      end
+    end
+  end
+end
diff --git a/app/lib/command_tag/commands/footer_tools.rb b/app/lib/command_tag/commands/footer_tools.rb
new file mode 100644
index 000000000..b7895d3af
--- /dev/null
+++ b/app/lib/command_tag/commands/footer_tools.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+module CommandTag::Commands::FooterTools
+  def handle_999_footertools_startup
+    @status.footer = var('persist:footer:default')[0]
+  end
+
+  def handle_footer_before_save(args)
+    return if args.blank?
+
+    name = normalize(args.shift)
+    return (@status.footer = nil) if read_falsy_from(name)
+
+    var_name = "persist:footer:#{name}"
+    return @status.footer = var(var_name)[0] if args.blank?
+
+    if read_falsy_from(normalize(args[0]))
+      @status.footer = nil if ['default', var(var_name)[0]].include?(name)
+      @vars.delete(var_name)
+      return
+    end
+
+    if name == 'default'
+      name = normalize(args.shift)
+      var_name = "persist:footer:#{name}"
+      @vars[var_name] = [args.join(' ').strip] if args.present?
+      @vars['persist:footer:default'] = var(var_name)
+    elsif %w(default DEFAULT).include?(args[0])
+      @vars['persist:footer:default'] = var(var_name)
+    else
+      @vars[var_name] = [args.join(' ').strip]
+    end
+
+    @status.footer = var(var_name)[0]
+  end
+
+  # Monsterfork v1 familiarity.
+  def handle_i_before_save(args)
+    return if args.blank?
+
+    handle_footer_before_save(args[1..-1]) if %w(am are).include?(normalize(args[0]))
+  end
+
+  alias handle_we_before_save           handle_i_before_save
+  alias handle_signature_before_save    handle_footer_before_save
+  alias handle_signed_before_save       handle_footer_before_save
+  alias handle_sign_before_save         handle_footer_before_save
+  alias handle_sig_before_save          handle_footer_before_save
+  alias handle_am_before_save           handle_footer_before_save
+  alias handle_are_before_save          handle_footer_before_save
+end
diff --git a/app/lib/command_tag/commands/hello_world.rb b/app/lib/command_tag/commands/hello_world.rb
new file mode 100644
index 000000000..cc770ef80
--- /dev/null
+++ b/app/lib/command_tag/commands/hello_world.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands::HelloWorld
+  def handle_helloworld_startup
+    @vars['hello_world'] = ['Hello, world!']
+  end
+
+  def handle_hello_world_with_return(_)
+    'Hello, world!'
+  end
+end
diff --git a/app/lib/command_tag/commands/parent_status_tools.rb b/app/lib/command_tag/commands/parent_status_tools.rb
new file mode 100644
index 000000000..edaa21b6a
--- /dev/null
+++ b/app/lib/command_tag/commands/parent_status_tools.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+module CommandTag::Commands::ParentStatusTools
+  def handle_publish_once_at_end(_)
+    is_blank = status_text_blank?
+    return PublishStatusService.new.call(@status) if @parent.blank? || !is_blank
+    return unless is_blank && author_of_parent? && !@parent.published?
+
+    PublishStatusService.new.call(@parent)
+  end
+
+  alias handle_publish_post_once_at_end                   handle_publish_once_at_end
+  alias handle_publish_roar_once_at_end                   handle_publish_once_at_end
+  alias handle_publish_toot_once_at_end                   handle_publish_once_at_end
+
+  def handle_edit_once_before_save(_)
+    return unless author_of_parent?
+
+    params = @parent.slice(*UpdateStatusService::ALLOWED_ATTRIBUTES).with_indifferent_access.compact
+    params[:text] = @text
+    UpdateStatusService.new.call(@parent, params)
+    destroy_status!
+  end
+
+  alias handle_edit_post_once_before_save                 handle_edit_once_before_save
+  alias handle_edit_roar_once_before_save                 handle_edit_once_before_save
+  alias handle_edit_toot_once_before_save                 handle_edit_once_before_save
+  alias handle_edit_parent_once_before_save               handle_edit_once_before_save
+
+  def handle_mute_once_at_end(_)
+    return if author_of_parent?
+
+    MuteStatusService.new.call(@account, @parent)
+  end
+
+  alias handle_mute_post_once_at_end                      handle_mute_once_at_end
+  alias handle_mute_roar_once_at_end                      handle_mute_once_at_end
+  alias handle_mute_toot_once_at_end                      handle_mute_once_at_end
+  alias handle_mute_parent_once_at_end                    handle_mute_once_at_end
+  alias handle_hide_once_at_end                           handle_mute_once_at_end
+  alias handle_hide_post_once_at_end                      handle_mute_once_at_end
+  alias handle_hide_roar_once_at_end                      handle_mute_once_at_end
+  alias handle_hide_toot_once_at_end                      handle_mute_once_at_end
+  alias handle_hide_parent_once_at_end                    handle_mute_once_at_end
+
+  def handle_unmute_once_at_end(_)
+    return if author_of_parent?
+
+    @account.unmute_status!(@parent)
+  end
+
+  alias handle_unmute_post_once_at_end                    handle_unmute_once_at_end
+  alias handle_unmute_roar_once_at_end                    handle_unmute_once_at_end
+  alias handle_unmute_toot_once_at_end                    handle_unmute_once_at_end
+  alias handle_unmute_parent_once_at_end                  handle_unmute_once_at_end
+  alias handle_unhide_once_at_end                         handle_unmute_once_at_end
+  alias handle_unhide_post_once_at_end                    handle_unmute_once_at_end
+  alias handle_unhide_roar_once_at_end                    handle_unmute_once_at_end
+  alias handle_unhide_toot_once_at_end                    handle_unmute_once_at_end
+  alias handle_unhide_parent_once_at_end                  handle_unmute_once_at_end
+
+  def handle_mute_thread_once_at_end(_)
+    return if author_of_parent?
+
+    MuteConversationService.new.call(@account, @conversation)
+  end
+
+  alias handle_mute_conversation_once_at_end              handle_mute_thread_once_at_end
+  alias handle_hide_thread_once_at_end                    handle_mute_thread_once_at_end
+  alias handle_hide_conversation_once_at_end              handle_mute_thread_once_at_end
+
+  def handle_unmute_thread_once_at_end(_)
+    return if author_of_parent? || @conversation.blank?
+
+    @account.unmute_conversation!(@conversation)
+  end
+
+  alias handle_unmute_conversation_once_at_end            handle_unmute_thread_once_at_end
+  alias handle_unhide_thread_once_at_end                  handle_unmute_thread_once_at_end
+  alias handle_unhide_conversation_once_at_end            handle_unmute_thread_once_at_end
+end
diff --git a/app/lib/command_tag/commands/status_tools.rb b/app/lib/command_tag/commands/status_tools.rb
new file mode 100644
index 000000000..5b9f52234
--- /dev/null
+++ b/app/lib/command_tag/commands/status_tools.rb
@@ -0,0 +1,239 @@
+# frozen_string_literal: true
+module CommandTag::Commands::StatusTools
+  def handle_boost_once_at_start(args)
+    return unless @parent.present? && StatusPolicy.new(@account, @parent).reblog?
+
+    status = ReblogService.new.call(
+      @account, @parent,
+      visibility: @status.visibility,
+      spoiler_text: args.join(' ').presence || @status.spoiler_text
+    )
+  end
+
+  alias handle_reblog_at_start handle_boost_once_at_start
+  alias handle_rb_at_start handle_boost_once_at_start
+  alias handle_rt_at_start handle_boost_once_at_start
+
+  def handle_article_before_save(args)
+    return unless author_of_status? && args.present?
+
+    case args.shift.downcase
+    when 'title', 'name', 't'
+      status.title = args.join(' ')
+    when 'summary', 'abstract', 'cw', 'cn', 's', 'a'
+      @status.title = @status.spoiler_text if @status.title.blank?
+      @status.spoiler_text = args.join(' ')
+    end
+  end
+
+  def handle_title_before_save(args)
+    args.unshift('title')
+    handle_article_before_save(args)
+  end
+
+  def handle_summary_before_save(args)
+    args.unshift('summary')
+    handle_article_before_save(args)
+  end
+
+  alias handle_abstract_before_save handle_summary_before_save
+
+  def handle_visibility_before_save(args)
+    return unless author_of_status? && args[0].present?
+
+    args[0] = read_visibility_from(args[0])
+    return if args[0].blank?
+
+    if args[1].blank?
+      @status.visibility = args[0].to_sym
+    elsif args[0] == @status.visibility.to_s
+      domains = args[1..-1].map { |domain| normalize_domain(domain) unless domain == '*' }.uniq.compact
+      @status.domain_permissions.where(domain: domains).destroy_all if domains.present?
+    elsif args[0] == 'cc'
+      expect_list = false
+      args[1..-1].uniq.each do |target|
+        if expect_list
+          expect_list = false
+          address_to_list(target)
+        elsif %w(list list:).include?(target.downcase)
+          expect_list = true
+        else
+          mention(resolve_mention(target))
+        end
+      end
+    elsif args[0] == 'community'
+      @status.visibility = :public
+      @status.domain_permissions.create_or_update(domain: '*', visibility: :unlisted)
+    else
+      args[1..-1].flat_map(&:split).uniq.each do |domain|
+        domain = normalize_domain(domain) unless domain == '*'
+        @status.domain_permissions.create_or_update(domain: domain, visibility: args[0]) if domain.present?
+      end
+    end
+  end
+
+  alias handle_v_before_save                      handle_visibility_before_save
+  alias handle_p_before_save                      handle_visibility_before_save
+  alias handle_privacy_before_save                handle_visibility_before_save
+
+  def handle_local_only_before_save(args)
+    @status.local_only = args.present? ? read_boolean_from(args[0]) : true
+    @status.originally_local_only = @status.local_only?
+  end
+
+  def handle_federate_before_save(args)
+    @status.local_only = args.present? ? !read_boolean_from(args[0]) : false
+    @status.originally_local_only = @status.local_only?
+  end
+
+  def handle_notify_before_save(args)
+    return if args[0].blank?
+
+    @status.notify = read_boolean_from(args[0])
+  end
+
+  alias handle_notice_before_save handle_notify_before_save
+
+  def handle_tags_before_save(args)
+    return if args.blank?
+
+    cmd = args.shift.downcase
+    args.select! { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
+
+    case cmd
+    when 'add', 'a', '+'
+      ProcessHashtagsService.new.call(@status, args)
+    when 'del', 'remove', 'rm', 'r', 'd', '-'
+      RemoveHashtagsService.new.call(@status, args)
+    end
+  end
+
+  def handle_tag_before_save(args)
+    args.unshift('add')
+    handle_tags_before_save(args)
+  end
+
+  def handle_untag_before_save(args)
+    args.unshift('del')
+    handle_tags_before_save(args)
+  end
+
+  def handle_delete_before_save(args)
+    unless args
+      RemovalWorker.perform_async(@parent.id, immediate: true) if author_of_parent? && status_text_blank?
+      return
+    end
+
+    args.flat_map(&:split).uniq.each do |id|
+      if id.match?(/\A\d+\z/)
+        object = @account.statuses.find_by(id: id)
+      elsif id.start_with?('https://')
+        begin
+          object = ActivityPub::TagManager.instance.uri_to_resource(id, Status)
+          if object.blank? && ActivityPub::TagManager.instance.local_uri?(id)
+            id = Addressable::URI.parse(id)&.normalized_path&.sub(/\A.*\/([^\/]*)\/*/, '\1')
+            next unless id.present? && id.match?(/\A\d+\z/)
+
+            object = find_status_or_create_stub(id)
+          end
+        rescue Addressable::URI::InvalidURIError
+          next
+        end
+      end
+
+      next if object.blank? || object.account_id != @account.id
+
+      RemovalWorker.perform_async(object.id, immediate: true, unpublished: true)
+    end
+  end
+
+  alias handle_destroy_before_save handle_delete_before_save
+  alias handle_redraft_before_save handle_delete_before_save
+
+  def handle_expires_before_save(args)
+    return if args.blank?
+
+    @status.expires_at = Time.now.utc + to_datetime(args)
+  end
+
+  alias handle_expires_in_before_save handle_expires_before_save
+  alias handle_delete_in_before_save handle_expires_before_save
+  alias handle_unpublish_in_before_save handle_expires_before_save
+
+  def handle_publish_before_save(args)
+    return if args.blank?
+
+    @status.published = false
+    @status.publish_at = Time.now.utc + to_datetime(args)
+  end
+
+  alias handle_publish_in_before_save handle_publish_before_save
+
+  private
+
+  def resolve_mention(mention_text)
+    return unless (match = mention_text.match(Account::MENTION_RE))
+
+    username, domain  = match[1].split('@')
+    domain            = begin
+                          if TagManager.instance.local_domain?(domain)
+                            nil
+                          else
+                            TagManager.instance.normalize_domain(domain)
+                          end
+                        end
+
+    Account.find_remote(username, domain)
+  end
+
+  def mention(target_account)
+    return if target_account.blank? || target_account.mentions.where(status: @status).exists?
+
+    target_account.mentions.create(status: @status, silent: true)
+  end
+
+  def address_to_list(list_name)
+    return if list_name.blank?
+
+    list_accounts = ListAccount.joins(:list).where(lists: { account: @account }).where('LOWER(lists.title) = ?', list_name.mb_chars.downcase).includes(:account).map(&:account)
+    list_accounts.each { |target_account| mention(target_account) }
+  end
+
+  def find_status_or_create_stub(id)
+    status_params = {
+      id: id,
+      account: @account,
+      text: '(Deleted)',
+      local: true,
+      visibility: :public,
+      local_only: false,
+      published: false,
+    }
+    Status.where(id: id).first_or_create(status_params)
+  end
+
+  def to_datetime(args)
+    total = 0.seconds
+    args.reject { |arg| arg.blank? || %w(in at , and).include?(arg) }.in_groups_of(2) { |i, unit| total += to_duration(i.to_i, unit) }
+    total
+  end
+
+  def to_duration(amount, unit)
+    case unit
+    when nil, 's', 'sec', 'secs', 'second', 'seconds'
+      amount.seconds
+    when 'm', 'min', 'mins', 'minute', 'minutes'
+      amount.minutes
+    when 'h', 'hr', 'hrs', 'hour', 'hours'
+      amount.hours
+    when 'd', 'day', 'days'
+      amount.days
+    when 'w', 'wk', 'wks', 'week', 'weeks'
+      amount.weeks
+    when 'mo', 'mos', 'mn', 'mns', 'month', 'months'
+      amount.months
+    when 'y', 'yr', 'yrs', 'year', 'years'
+      amount.years
+    end
+  end
+end
diff --git a/app/lib/command_tag/commands/text_tools.rb b/app/lib/command_tag/commands/text_tools.rb
new file mode 100644
index 000000000..1a0d53480
--- /dev/null
+++ b/app/lib/command_tag/commands/text_tools.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands::TextTools
+  def handle_code_at_start(args)
+    return if args.count < 2
+
+    name = normalize(args[0])
+    value = args.last.presence || ''
+    @vars[name] = case @status.content_type
+                  when 'text/markdown'
+                    ["```\n#{value}\n```"]
+                  when 'text/html'
+                    ["<pre><code>#{html_encode(value).gsub("\n", '<br/>')}</code></pre>"]
+                  else
+                    ["----------\n#{value}\n----------"]
+                  end
+  end
+
+  def handle_code_with_return(args)
+    return if args.count > 1
+
+    value = args.last.presence || ''
+    case @status.content_type
+    when 'text/markdown'
+      ["```\n#{value}\n```"]
+    when 'text/html'
+      ["<pre><code>#{html_encode(value).gsub("\n", '<br/>')}</code></pre>"]
+    else
+      ["----------\n#{value}\n----------"]
+    end
+  end
+
+  def handle_prepend_before_save(args)
+    args.each { |arg| @text = "#{arg}\n#{text}" }
+  end
+
+  def handle_append_before_save(args)
+    args.each { |arg| @text << "\n#{arg}" }
+  end
+
+  def handle_replace_before_save(args)
+    @text.gsub!(args[0], args[1] || '')
+  end
+
+  alias handle_sub_before_save handle_replace_before_save
+
+  def handle_regex_replace_before_save(args)
+    flags     = normalize(args[2])
+    re_opts   = (flags.include?('i') ? Regexp::IGNORECASE : 0)
+    re_opts  |= (flags.include?('x') ? Regexp::EXTENDED : 0)
+    re_opts  |= (flags.include?('m') ? Regexp::MULTILINE : 0)
+
+    @text.gsub!(Regexp.new(args[0], re_opts), args[1] || '')
+  end
+
+  alias handle_resub_before_save handle_replace_before_save
+  alias handle_regex_sub_before_save handle_replace_before_save
+
+  def handle_keysmash_with_return(args)
+    keyboard = [
+      'asdf', 'jkl;',
+      'gh', "'",
+      'we', 'io',
+      'r', 'u',
+      'cv', 'nm',
+      't', 'x', ',',
+      'q', 'z',
+      'y', 'b',
+      'p', '.',
+      '[', ']'
+    ]
+
+    min_size = [[5, args[1].to_i].max, 100].min
+    max_size = [args[0].to_i, 100].min
+    max_size = 33 unless max_size.positive?
+
+    min_size, max_size = [max_size, min_size] if min_size > max_size
+
+    chunk = rand(min_size..max_size).times.map do
+      keyboard[(keyboard.size * (rand**3)).floor].split('').sample
+    end
+
+    chunk.join
+  end
+
+  def transform_keysmash_template_return(_, args)
+    handle_keysmash_with_return([args[0], args[2]])
+  end
+end
diff --git a/app/lib/command_tag/commands/variables.rb b/app/lib/command_tag/commands/variables.rb
new file mode 100644
index 000000000..36ba0abd3
--- /dev/null
+++ b/app/lib/command_tag/commands/variables.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands::Variables
+  def handle_000_variables_startup
+    @vars.merge!(persistent_vars_from(@account.metadata.fields)) if @account.metadata.present?
+  end
+
+  def handle_999_variables_shutdown
+    @account.metadata.update!(fields: nonpersistent_vars_from(@account.metadata.fields).merge(persistent_vars_from(@vars)))
+  end
+
+  def handle_set_at_start(args)
+    return if args.blank?
+
+    args[0] = normalize(args[0])
+
+    case args.count
+    when 1
+      @vars.delete(args[0])
+    else
+      @vars[args[0]] = args[1..-1]
+    end
+  end
+
+  def do_unset_at_start(args)
+    args.each do |arg|
+      @vars.delete(normalize(arg))
+    end
+  end
+
+  private
+
+  def persistent_vars_from(vars)
+    vars.select { |key, value| key.start_with?('persist:') && value.present? && value.is_a?(Array) }
+  end
+
+  def nonpersistent_vars_from(vars)
+    vars.reject { |key, value| key.start_with?('persist:') || value.blank? }
+  end
+end
diff --git a/app/lib/command_tag/processor.rb b/app/lib/command_tag/processor.rb
new file mode 100644
index 000000000..77be29eba
--- /dev/null
+++ b/app/lib/command_tag/processor.rb
@@ -0,0 +1,343 @@
+# frozen_string_literal: true
+
+#                  .~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.                  #
+###################              Cthulhu Code!              ###################
+#                  `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`                  #
+# - Interprets and executes user input.  THIS CAN BE VERY DANGEROUS!          #
+# - Has a high complexity level and needs tests.                              #
+# - May destroy objects passed to it.                                         #
+# - Incurs a high performance penalty.                                        #
+#                                                                             #
+###############################################################################
+
+require_relative 'commands'
+
+class CommandTag::Break < Mastodon::Error
+  def initialize(msg = 'A handler stopped execution.')
+    super
+  end
+end
+
+class CommandTag::Processor
+  include Redisable
+  include ImgProxyHelper
+  include CommandTag::Commands
+
+  MENTIONS_OR_HASHTAGS_RE = /(?:(?:#{Account::MENTION_RE}|#{Tag::HASHTAG_RE})\s*)+/.freeze
+  PARSEABLE_RE = /^\s*(?:#{MENTIONS_OR_HASHTAGS_RE})?#!|%%.+?%%/.freeze
+  STATEMENT_RE = /^\s*#!\s*[^\n]+ (?:start|begin|do)$.*?\n\s*#!\s*(?:end|stop|done)\s*$|^\s*#!\s*.*?\s*$/im.freeze
+  STATEMENT_PARSE_RE = /'([^']*)'|"([^"]*)"|(\S+)|\s+(?:start|begin|do)\s*$\n+(.*)\n\s*#!\s*(?:end|stop|done)\s*\z/im.freeze
+  TEMPLATE_RE = /%%\s*(\S+.*?)\s*%%/.freeze
+  ESCAPE_MAP = {
+    '\n' => "\n",
+    '\r' => "\r",
+    '\t' => "\t",
+    '\\\\' => '\\',
+    '\%' => '%',
+  }.freeze
+
+  def initialize(account, status)
+    @account      = account
+    @status       = status
+    @parent       = status.thread
+    @conversation = status.conversation
+    @text         = status.text
+    @run_once     = Set[]
+    @vars         = { 'statement_uuid' => [nil] }
+    @statements   = {}
+
+    return unless @account.present? && @account.local? && @status.present?
+  end
+
+  def process!
+    reset_status_caches
+    all_handlers!(:startup)
+
+    unless @text.match?(PARSEABLE_RE)
+      process_inline_images!
+      @status.save!
+      return
+    end
+
+    @text = parse_statements_from!(@text, @statements)
+
+    execute_statements(:at_start)
+    execute_statements(:with_return, true)
+    @text = replace_templates(@text)
+    execute_statements(:before_save)
+
+    if status_text_blank?
+      execute_statements(:when_blank)
+
+      unless (@status.published? && !@status.edited.zero?) || @text.present?
+        execute_statements(:before_destroy)
+        @status.update(published: false)
+        @status.destroy
+        execute_statements(:after_destroy)
+      end
+    elsif @status.destroyed?
+      execute_statements(:after_destroy)
+    else
+      @status.text = @text
+      process_inline_images!
+      if @status.save
+        execute_statements(:after_save)
+      else
+        execute_statements(:after_save_fail)
+      end
+    end
+
+    execute_statements(:at_end)
+    all_handlers!(:shutdown)
+  rescue CommandTag::Break
+    nil
+  rescue StandardError
+    @status.update(published: false)
+    @status.destroy
+    raise
+  ensure
+    reset_status_caches
+  end
+
+  private
+
+  def all_handlers!(affix)
+    self.class.instance_methods.grep(/\Ahandle_\w+_#{affix}\z/).sort.each do |name|
+      public_send(name)
+    end
+  end
+
+  # Calls an arbitary public method (if it exists) on a given value and returns the result.
+  def transform_using(name, value, args = [])
+    respond_to?(name) ? public_send(name, value, args) : value
+  end
+
+  # Moves command tags placed after hashtags and mentions to their own line.
+  def prepare_input(text)
+    text.gsub(/\r\n|\n\r|\r/, "\n").gsub(/^\s*(#{MENTIONS_OR_HASHTAGS_RE})#!/, "\\1\n#!")
+  end
+
+  # Translates %%...%% templates.
+  def replace_templates(text)
+    text.gsub(TEMPLATE_RE) do
+      template = unescape_literals(Regexp.last_match(1))
+      next if template.blank?
+      next template[1..-2] if template.match?(/\A'.*'\z/)
+
+      template = template.match?(/\A".*"\z/) ? template[1..-2] : "\#{#{template}}"
+      template.gsub(/#\{\s*(.*?)\s*\}/) do
+        next if Regexp.last_match(1).blank?
+
+        parts     = Regexp.last_match(1).scan(/'([^']*)'|"([^"]*)"|(\S+)/).flatten.compact
+        name      = normalize(parts[0])
+        separator = "\n"
+
+        if parts.count > 2
+          if %w(: by: with: using: sep: separator: delim: delimiter:).include?(parts[-2].downcase)
+            separator = parts[-1]
+            parts = parts[0..-3]
+          elsif !parts[-1].match?(/\A[-+]?[0-9]+\z/)
+            separator = parts[-1]
+            parts.pop
+          end
+        end
+
+        index       = to_integer(parts[1])
+        str_start   = to_integer(parts[2])
+        str_end     = to_integer(parts[3])
+
+        str_start, str_end = [str_end, str_start] if str_start > str_end
+
+        old_value = (['all', '[]'].include?(parts[1]) ? var(name).join(separator) : var(name)[index].to_s)
+        name      = name.gsub(/[^\w_]+/, '_')
+        new_value = transform_using("transform_#{name}_template_return", old_value, [index, str_start, str_end])
+        next new_value if new_value != old_value
+
+        new_value = transform_using("transform_#{name}_template_value", new_value, [index, str_start, str_end])
+        (str_end - str_start).zero? ? new_value : new_value[str_start..str_end]
+      end
+    end.rstrip
+  end
+
+  # Parses statements from text and merges them into statement queues.
+  # Mutates statement queues hash!
+  def parse_statements_from!(text, statement_queues)
+    @run_once.clear
+
+    text = prepare_input(text)
+    text.gsub!(STATEMENT_RE) do
+      statement = unescape_literals(Regexp.last_match(0).strip[2..-1])
+      next if statement.blank?
+
+      statement_array = statement.scan(STATEMENT_PARSE_RE).flatten.compact.map { |arg| arg.gsub('\#!', '#!') }
+      statement_array[0] = statement_array[0].strip.tr(':.\- ', '_').gsub(/__+/, '_').downcase
+      next unless statement_array[0].match?(/\A[\w_]+\z/)
+
+      statement_array[-1].rstrip! if statement_array.count > 1
+      add_statement_handlers_for!(statement_array, statement_queues)
+    end
+
+    @run_once.clear
+    text
+  end
+
+  # Yields all possible handler names for a command.
+  def potential_handlers_for(name)
+    ['_once', ''].each_with_index do |count_affix, index|
+      %w(at_start with_return when_blank at_end).each do |when_affix|
+        yield ["#{count_affix}_#{when_affix}", "handle_#{name}#{count_affix}_#{when_affix}", index.zero?]
+      end
+
+      %w(destroy save postprocess save_fail).each do |event_affix|
+        %w(before after).each do |when_affix|
+          yield ["#{count_affix}_#{when_affix}_#{event_affix}", "handle_#{name}#{count_affix}_#{when_affix}_#{event_affix}", index.zero?]
+        end
+      end
+    end
+  end
+
+  # Expands a statement to a handler method call, arguments, and template UUID for each handler affix.
+  # Mutates statement queues hash!
+  def add_statement_handlers_for!(statement_array, statement_queues = {})
+    statement_uuid = SecureRandom.uuid
+
+    potential_handlers_for(statement_array[0]) do |when_affix, handler, once|
+      if !(once && @run_once.include?(handler)) && respond_to?(handler)
+        statement_queues[when_affix] ||= []
+        statement_queues[when_affix] << [handler, statement_array[1..-1], statement_uuid]
+        @run_once << handler if once
+      end
+    end
+
+    # Template for statement return value.
+    "%% statement:#{statement_uuid} all %%"
+  end
+
+  # Calls all handlers for a queue of statements in order.
+  def execute_statements(event, with_return = false, statements: nil)
+    statements = @statements if statements.blank?
+
+    ["_#{event}", "_once_#{event}"].each do |when_affix|
+      next if statements[when_affix].blank?
+
+      statements[when_affix].each do |handler, arguments, uuid|
+        @vars['statement_uuid'][0] = uuid
+        if with_return
+          @vars["statement:#{uuid}"] = [public_send(handler, arguments)]
+        else
+          public_send(handler, arguments)
+        end
+      end
+    end
+  end
+
+  # Expire cached statuses after potentially updating them.
+  def reset_status_caches(statuses = nil)
+    statuses = [@status, @parent] if statuses.blank?
+    statuses.each do |status|
+      next unless @account.id == status&.account_id
+
+      Rails.cache.delete_matched("statuses/#{status.id}-*")
+      Rails.cache.delete("statuses/#{status.id}")
+      Rails.cache.delete(status)
+      Rails.cache.delete_matched("format:#{status.id}:*")
+      redis.zremrangebyscore("spam_check:#{status.account.id}", status.id, status.id)
+    end
+  end
+
+  def author_of_status?
+    @account.id == @status.account_id
+  end
+
+  def author_of_parent?
+    @account.id == @parent&.account_id
+  end
+
+  def status_text_blank?
+    @text.blank? || @text.gsub(MENTIONS_OR_HASHTAGS_RE, '').strip.blank?
+  end
+
+  def destroy_status!
+    return if @status.destroyed?
+
+    @status.update(published: false)
+    @status.destroy
+  end
+
+  def replace_status!(new_status)
+    return if new_status.blank?
+
+    destroy_status!
+    @status = new_status
+  end
+
+  def normalize(text)
+    text.to_s.strip.downcase
+  end
+
+  def to_integer(text)
+    text&.strip.to_i
+  end
+
+  def unescape_literals(text)
+    ESCAPE_MAP.each { |escaped, unescaped| text.gsub!(escaped, unescaped) }
+    text
+  end
+
+  def html_encode(text)
+    (@html_entities ||= HTMLEntities.new).encode(text)
+  end
+
+  def var(name)
+    @vars[name].presence || []
+  end
+
+  def read_visibility_from(arg)
+    return if arg.strip.blank?
+
+    arg = case arg.strip
+          when 'p', 'pu', 'all', 'world'
+            'public'
+          when 'u', 'ul'
+            'unlisted'
+          when 'f', 'follower', 'followers', 'packmates', 'follower-only', 'followers-only', 'packmates-only'
+            'private'
+          when 'd', 'dm', 'pm', 'directmessage'
+            'direct'
+          when 'default', 'reset'
+            @account.user.setting_default_privacy
+          when 'to', 'allow', 'allow-from', 'from'
+            'cc'
+          when 'm', 'l', 'mp', 'monsterpit', 'local'
+            'community'
+          else
+            arg.strip
+          end
+
+    %w(public unlisted private limited direct cc community).include?(arg) ? arg : nil
+  end
+
+  def read_falsy_from(arg)
+    %w(f n false no off disable).include?(arg)
+  end
+
+  def read_truthy_from(arg)
+    %w(t y true yes on enable).include?(arg)
+  end
+
+  def read_boolean_from(arg)
+    arg.present? && (read_truthy_from(arg) || !read_falsy_from(arg))
+  end
+
+  def normalize_domain(domain)
+    return if domain&.strip.blank? || !domain.include?('.')
+
+    domain.split('.').map(&:strip).reject(&:blank?).join('.').downcase
+  end
+
+  def federating_with_domain?(domain)
+    return false if domain.blank?
+
+    DomainAllow.where(domain: domain).exists? || Account.where(domain: domain, suspended_at: nil).exists?
+  end
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 3c1f8d6e2..665869b26 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -1,18 +1,17 @@
 # frozen_string_literal: true
 
 require 'singleton'
-
 class FeedManager
   include Singleton
   include Redisable
 
   # Maximum number of items stored in a single feed
-  MAX_ITEMS = 400
+  MAX_ITEMS = 1000
 
   # Number of items in the feed since last reblog of status
   # before the new reblog will be inserted. Must be <= MAX_ITEMS
   # or the tracking sets will grow forever
-  REBLOG_FALLOFF = 40
+  REBLOG_FALLOFF = 50
 
   # Execute block for every active account
   # @yield [Account]
@@ -40,9 +39,9 @@ class FeedManager
   def filter?(timeline_type, status, receiver)
     case timeline_type
     when :home
-      filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]))
+      filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), receiver.user&.filters_unknown?)
     when :list
-      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
+      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), receiver.account.user&.filters_unknown?)
     when :mentions
       filter_from_mentions?(status, receiver.id)
     when :direct
@@ -57,7 +56,7 @@ class FeedManager
   # @param [Status] status
   # @return [Boolean]
   def push_to_home(account, status)
-    return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
+    return false unless add_to_feed(:home, account.id, status, account.user&.home_reblogs?)
 
     trim(:home, account.id)
     PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}")
@@ -68,8 +67,8 @@ class FeedManager
   # @param [Account] account
   # @param [Status] status
   # @return [Boolean]
-  def unpush_from_home(account, status)
-    return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
+  def unpush_from_home(account, status, include_reblogs_list = true)
+    return false unless remove_from_feed(:home, account.id, status, include_reblogs_list)
 
     redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
     true
@@ -80,7 +79,8 @@ class FeedManager
   # @param [Status] status
   # @return [Boolean]
   def push_to_list(list, status)
-    return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
+    return false if filter_from_list?(status, list)
+    return false unless add_to_feed(:list, list.id, status, list.reblogs?)
 
     trim(:list, list.id)
     PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
@@ -92,7 +92,7 @@ class FeedManager
   # @param [Status] status
   # @return [Boolean]
   def unpush_from_list(list, status)
-    return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
+    return false unless remove_from_feed(:list, list.id, status)
 
     redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
     true
@@ -121,13 +121,33 @@ class FeedManager
     true
   end
 
+  def unpush_status(account, status)
+    return if account.blank? || status.blank?
+
+    unpush_from_home(account, status)
+    unpush_from_direct(account, status) if status.direct_visibility?
+
+    account.lists_for_local_distribution.select(:id, :account_id).each do |list|
+      unpush_from_list(list, status)
+    end
+  end
+
+  def unpush_conversation(account, conversation)
+    return if account.blank? || conversation.blank?
+
+    conversation.statuses.reorder(nil).find_each do |status|
+      unpush_status(account, status)
+    end
+  end
+
   # Fill a home feed with an account's statuses
   # @param [Account] from_account
   # @param [Account] into_account
   # @return [void]
   def merge_into_home(from_account, into_account)
     timeline_key = key(:home, into_account.id)
-    aggregate    = into_account.user&.aggregates_reblogs?
+    reblogs      = into_account.user&.home_reblogs?
+    no_unknown   = into_account.user&.filters_unknown?
     query        = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
 
     if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
@@ -139,9 +159,9 @@ class FeedManager
     crutches = build_crutches(into_account.id, statuses)
 
     statuses.each do |status|
-      next if filter_from_home?(status, into_account.id, crutches)
+      next if filter_from_home?(status, into_account.id, crutches, no_unknown)
 
-      add_to_feed(:home, into_account.id, status, aggregate)
+      add_to_feed(:home, into_account.id, status, reblogs)
     end
 
     trim(:home, into_account.id)
@@ -153,7 +173,8 @@ class FeedManager
   # @return [void]
   def merge_into_list(from_account, list)
     timeline_key = key(:list, list.id)
-    aggregate    = list.account.user&.aggregates_reblogs?
+    reblogs      = list.account.user&.home_reblogs?
+    no_unknown   = list.account.user&.filters_unknown?
     query        = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
 
     if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
@@ -165,9 +186,9 @@ class FeedManager
     crutches = build_crutches(list.account_id, statuses)
 
     statuses.each do |status|
-      next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list)
+      next if filter_from_home?(status, list.account_id, crutches, no_unknown) || filter_from_list?(status, list)
 
-      add_to_feed(:list, list.id, status, aggregate)
+      add_to_feed(:list, list.id, status, reblogs)
     end
 
     trim(:list, list.id)
@@ -182,7 +203,7 @@ class FeedManager
     oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
 
     from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
-      remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
+      remove_from_feed(:home, into_account.id, status)
     end
   end
 
@@ -195,7 +216,7 @@ class FeedManager
     oldest_list_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
 
     from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_list_score).reorder(nil).find_each do |status|
-      remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
+      remove_from_feed(:list, list.id, status, !list.reblogs?)
     end
   end
 
@@ -219,16 +240,63 @@ class FeedManager
     end
   end
 
+  # Clear all reblogs from a home feed
+  # @param [Account] account
+  # @return [void]
+  def clear_reblogs_from_home(account)
+    timeline_key        = key(:home, account.id)
+    timeline_status_ids = redis.zrange(timeline_key, 0, -1)
+
+    Status.reblogs.joins(:reblog).where(reblogs_statuses: { local: false }).where(id: timeline_status_ids).find_each do |status|
+      unpush_from_home(account, status, false)
+    end
+  end
+
+  # Populate list feeds of account from scratch
+  # @param [Account] account
+  # @return [void]
+  def populate_lists(account)
+    limit = FeedManager::MAX_ITEMS / 2
+
+    account.owned_lists.includes(:accounts) do |list|
+      timeline_key = key(:list, list.id)
+
+      list.accounts.includes(:account_stat).find_each do |target_account|
+        if redis.zcard(timeline_key) >= limit
+          oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i
+          last_status_score = Mastodon::Snowflake.id_at(account.last_status_at)
+
+          # If the feed is full and this account has not posted more recently
+          # than the last item on the feed, then we can skip the whole account
+          # because none of its statuses would stay on the feed anyway
+          next if last_status_score < oldest_home_score
+        end
+
+        statuses = target_account.statuses.published.without_reblogs.where(visibility: [:public, :unlisted, :private]).includes(:mentions, :preloadable_poll).limit(limit)
+        crutches = build_crutches(account.id, statuses)
+
+        statuses.each do |status|
+          next if filter_from_list?(status, account.id) || filter_from_home?(status, account.id, crutches, account.user&.filters_unknown?)
+
+          add_to_feed(:list, list.id, status, list.reblogs?)
+        end
+
+        trim(:list, list.id)
+      end
+    end
+  end
+
   # Populate home feed of account from scratch
   # @param [Account] account
   # @return [void]
   def populate_home(account)
     limit        = FeedManager::MAX_ITEMS / 2
-    aggregate    = account.user&.aggregates_reblogs?
+    reblogs      = account.user&.home_reblogs?
+    no_unknown   = account.user&.filters_unknown?
     timeline_key = key(:home, account.id)
 
     account.statuses.limit(limit).each do |status|
-      add_to_feed(:home, account.id, status, aggregate)
+      add_to_feed(:home, account.id, status, reblogs)
     end
 
     account.following.includes(:account_stat).find_each do |target_account|
@@ -242,13 +310,13 @@ class FeedManager
         next if last_status_score < oldest_home_score
       end
 
-      statuses = target_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(limit)
+      statuses = target_account.statuses.published.where(visibility: [:public, :unlisted, :private]).includes(:mentions, :preloadable_poll, reblog: [:account, :mentions]).limit(limit)
       crutches = build_crutches(account.id, statuses)
 
       statuses.each do |status|
-        next if filter_from_home?(status, account.id, crutches)
+        next if filter_from_home?(status, account.id, crutches, no_unknown)
 
-        add_to_feed(:home, account.id, status, aggregate)
+        add_to_feed(:home, account.id, status, reblogs, false)
       end
 
       trim(:home, account.id)
@@ -270,6 +338,7 @@ class FeedManager
 
       statuses.each do |status|
         next if filter_from_direct?(status, account)
+
         added += 1 if add_to_feed(:direct, account.id, status)
       end
 
@@ -334,36 +403,55 @@ class FeedManager
   # @param [Integer] receiver_id
   # @param [Hash] crutches
   # @return [Boolean]
-  def filter_from_home?(status, receiver_id, crutches)
+  def filter_from_home?(status, receiver_id, crutches, followed_only = false)
     return false if receiver_id == status.account_id
+    return true  if !status.published? || crutches[:hiding_thread][status.conversation_id]
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
     return true  if phrase_filtered?(status, receiver_id, :home)
 
     check_for_blocks = crutches[:active_mentions][status.id] || []
     check_for_blocks.concat([status.account_id])
+    check_for_blocks.concat([status.in_reply_to_account_id]) if status.reply?
 
     if status.reblog?
       check_for_blocks.concat([status.reblog.account_id])
       check_for_blocks.concat(crutches[:active_mentions][status.reblog_of_id] || [])
+      check_for_blocks.concat([status.reblog.in_reply_to_account_id]) if status.reblog.reply?
     end
 
     return true if check_for_blocks.any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] }
 
-    if status.reply? && !status.in_reply_to_account_id.nil?                                                                      # Filter out if it's a reply
-      should_filter   = !crutches[:following][status.in_reply_to_account_id]                                                     # and I'm not following the person it's a reply to
-      should_filter &&= receiver_id != status.in_reply_to_account_id                                                             # and it's not a reply to me
-      should_filter &&= status.account_id != status.in_reply_to_account_id                                                       # and it's not a self-reply
+    # Filter if...
+    if status.reply? # ...it's a reply and...
+      # ...you're not following the participants...
+      should_filter   = (status.mentions.pluck(:account_id) - crutches[:following].keys).present?
+      # ...and the author isn't replying to you...
+      should_filter &&= receiver_id != status.in_reply_to_account_id
 
       return !!should_filter
-    elsif status.reblog?                                                                                                         # Filter out a reblog
-      should_filter   = crutches[:hiding_reblogs][status.account_id]                                                             # if the reblogger's reblogs are suppressed
-      should_filter ||= crutches[:blocked_by][status.reblog.account_id]                                                          # or if the author of the reblogged status is blocking me
-      should_filter ||= crutches[:domain_blocking][status.reblog.account.domain]                                                 # or the author's domain is blocked
+    elsif status.reblog? # ...it's a boost and...
+      # ...you don't follow the OP and they're non-local or they're silenced...
+      should_filter = (followed_only || status.reblog.account.silenced?) && !crutches[:following][status.reblog.account_id]
+
+      # ..or you're hiding boosts from them...
+      should_filter ||= crutches[:hiding_reblogs][status.account_id]
+      # ...or they're blocking you...
+      should_filter ||= crutches[:blocked_by][status.reblog.account_id]
+      # ...or you're blocking their domain...
+      should_filter ||= crutches[:domain_blocking][status.reblog.account.domain]
+
+      # ...or it's a reply...
+      if !(should_filter || status.reblog.in_reply_to_account_id.nil?) && status.reblog.reply?
+        # ...and you don't follow the participants...
+        should_filter ||= (status.reblog.mentions.pluck(:account_id) - crutches[:following].keys).present?
+        # ...and the author isn't replying to you...
+        should_filter &&= receiver_id != status.in_reply_to_account_id
+      end
 
       return !!should_filter
     end
 
-    false
+    !crutches[:following][status.account_id]
   end
 
   # Check if status should not be added to the mentions feed
@@ -381,18 +469,29 @@ class FeedManager
     check_for_blocks = status.active_mentions.pluck(:account_id)
     check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
 
-    should_filter   = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions)                                                         # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
-    should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
+    should_filter   = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions)
+    should_filter ||= (status.account.silenced? && !relationship_exists?(receiver_id, status.account_id))
 
     should_filter
   end
 
+  def following?(account_id, target_account_id)
+    Follow.where(account_id: account_id, target_account_id: target_account_id).exists?
+  end
+
+  def relationship_exists?(account_id, target_account_id)
+    Follow.where(account_id: account_id, target_account_id: target_account_id)
+          .or(Follow.where(account_id: target_account_id, target_account_id: account_id))
+          .exists?
+  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
 
@@ -401,6 +500,9 @@ class FeedManager
   # @param [List] list
   # @return [Boolean]
   def filter_from_list?(status, list)
+    return true if (list.reblogs? && !status.reblog?) || (!list.reblogs? && status.reblog?)
+    return true if status.reblog? ? status.reblog.account_id == list.account_id : status.account_id == list.account_id
+
     if status.reply? && status.in_reply_to_account_id != status.account_id
       should_filter = status.in_reply_to_account_id != list.account_id
       should_filter &&= !list.show_all_replies?
@@ -455,13 +557,19 @@ class FeedManager
   # @param [Symbol] timeline_type
   # @param [Integer] account_id
   # @param [Status] status
-  # @param [Boolean] aggregate_reblogs
+  # @param [Boolean] home_reblogs
+  # @param [Boolean] stream
   # @return [Boolean]
-  def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true)
+  def add_to_feed(timeline_type, account_id, status, home_reblogs = true, stream = true)
     timeline_key = key(timeline_type, account_id)
     reblog_key   = key(timeline_type, account_id, 'reblogs')
 
-    if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
+    if status.reblog?
+      add_to_reblogs(account_id, status, stream) if timeline_type == :home
+      return false unless home_reblogs || (timeline_type == :home && (status.reblog.local? || following?(account_id, status.reblog.account_id)))
+    end
+
+    if status.reblog?
       # If the original status or a reblog of it is within
       # REBLOG_FALLOFF statuses from the top, do not re-insert it into
       # the feed
@@ -493,6 +601,8 @@ class FeedManager
       redis.zadd(timeline_key, status.id, status.id)
     end
 
+    add_to_reblogs(account_id, status, stream) if timeline_type == :home && status.reblog?
+
     true
   end
 
@@ -503,13 +613,15 @@ class FeedManager
   # @param [Symbol] timeline_type
   # @param [Integer] account_id
   # @param [Status] status
-  # @param [Boolean] aggregate_reblogs
+  # @param [Boolean] include_reblogs_list
   # @return [Boolean]
-  def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
+  def remove_from_feed(timeline_type, account_id, status, include_reblogs_list = true)
     timeline_key = key(timeline_type, account_id)
     reblog_key   = key(timeline_type, account_id, 'reblogs')
 
-    if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
+    remove_from_reblogs(account_id, status) if include_reblogs_list && timeline_type == :home && status.reblog?
+
+    if status.reblog?
       # 1. If the reblogging status is not in the feed, stop.
       status_rank = redis.zrevrank(timeline_key, status.id)
       return false if status_rank.nil?
@@ -547,27 +659,43 @@ class FeedManager
   def build_crutches(receiver_id, statuses)
     crutches = {}
 
-    crutches[:active_mentions] = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact).pluck(:status_id, :account_id).each_with_object({}) { |(id, account_id), mapping| (mapping[id] ||= []).push(account_id) }
-
-    check_for_blocks = statuses.flat_map do |s|
-      arr = crutches[:active_mentions][s.id] || []
-      arr.concat([s.account_id])
-
-      if s.reblog?
-        arr.concat([s.reblog.account_id])
-        arr.concat(crutches[:active_mentions][s.reblog_of_id] || [])
-      end
+    mentions = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact).pluck(:status_id, :account_id)
+    participants = statuses.flat_map { |s| [s.account_id, s.in_reply_to_account_id, s.reblog&.account_id, s.reblog&.in_reply_to_account_id].compact } | mentions.map { |m| m[1] }
 
-      arr
-    end
+    crutches[:active_mentions] = mentions.each_with_object({}) { |(id, account_id), mapping| (mapping[id] ||= []).push(account_id) }
 
-    crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:in_reply_to_account_id).compact).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
+    crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: participants).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
     crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.map { |s| s.account_id if s.reblog? }.compact, show_reblogs: false).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
-    crutches[:blocking]        = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
-    crutches[:muting]          = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
+    crutches[:blocking]        = Block.where(account_id: receiver_id, target_account_id: participants).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
+    crutches[:muting]          = Mute.where(account_id: receiver_id, target_account_id: participants).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
     crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.map { |s| s.reblog&.account&.domain }.compact).pluck(:domain).each_with_object({}) { |domain, mapping| mapping[domain] = true }
     crutches[:blocked_by]      = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| s.reblog&.account_id }.compact).pluck(:account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
+    crutches[:hiding_thread]   = ConversationMute.where(account_id: receiver_id, conversation_id: statuses.map(&:conversation_id).compact).pluck(:conversation_id).each_with_object({}) { |id, mapping| mapping[id] = true }
 
     crutches
   end
+
+  def find_or_create_reblogs_list(account_id)
+    List.find_or_create_by!(account_id: account_id, reblogs: true) do |list|
+      list.title = I18n.t('accounts.reblogs')
+      list.replies_policy = :no_replies
+    end
+  end
+
+  def add_to_reblogs(account_id, status, stream = true)
+    reblogs_list_id = find_or_create_reblogs_list(account_id).id
+    return unless add_to_feed(:list, reblogs_list_id, status)
+
+    trim(:list, reblogs_list_id)
+    return unless stream && push_update_required?("timeline:list:#{reblogs_list_id}")
+
+    PushUpdateWorker.perform_async(account_id, status.id, "timeline:list:#{reblogs_list_id}")
+  end
+
+  def remove_from_reblogs(account_id, status)
+    reblogs_list_id = find_or_create_reblogs_list(account_id).id
+    return unless remove_from_feed(:list, reblogs_list_id, status)
+
+    redis.publish("timeline:list:#{reblogs_list_id}", Oj.dump(event: :delete, payload: status.id.to_s))
+  end
 end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index e7bb0743d..e0c1e94db 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -26,6 +26,7 @@ class HTMLRenderer < Redcarpet::Render::HTML
   end
 end
 
+# rubocop:disable Metrics/ClassLength
 class Formatter
   include Singleton
   include RoutingHelper
@@ -33,50 +34,95 @@ class Formatter
   include ActionView::Helpers::TextHelper
 
   def format(status, **options)
-    if status.reblog?
-      prepend_reblog = status.reblog.account.acct
-      status         = status.proper
-    else
-      prepend_reblog = false
+    Rails.cache.fetch(formatter_cache_key(status, options), expires_in: 1.hour) do
+      uncached_format(status, options)
     end
+  end
 
-    raw_content = status.text
+  def uncached_format(status, options)
+    summary = nil
+    raw_content = status.proper.text
+    summary_mode = false
+
+    if status.title.present?
+      summary = status.spoiler_text.presence || status.text
+      summary_mode = !options[:article_content]
+      raw_content = summary_mode ? summary : status.text
+    end
 
     if options[:inline_poll_options] && status.preloadable_poll
       raw_content = raw_content + "\n\n" + status.preloadable_poll.options.map { |title| "[ ] #{title}" }.join("\n")
     end
 
     return '' if raw_content.blank?
+    return format_remote_content(raw_content, status.emojis, summary: summary, **options) unless status.local?
 
-    unless status.local?
-      html = reformat(raw_content)
-      html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
-      return html.html_safe # rubocop:disable Rails/OutputSafety
+    if status.reblog?
+      html = "🔁 @#{status.reblog.account.acct}\n🔗 #{ActivityPub::TagManager.instance.url_for(status.reblog)}"
+      html += "\nℹ️ #{status.reblog.spoiler_text}" if status.reblog.spoiler_text.present?
+    else
+      html = raw_content
     end
 
-    linkable_accounts = status.active_mentions.map(&:account)
+    html = "📄 #{html}" if summary_mode
+    return html if options[:plaintext]
+
+    linkable_accounts = status.mentions.map(&:account)
     linkable_accounts << status.account
 
-    html = raw_content
-    html = "RT @#{prepend_reblog} #{html}" if prepend_reblog
-    html = format_markdown(html) if status.content_type == 'text/markdown'
-    html = encode_and_link_urls(html, linkable_accounts, keep_html: %w(text/markdown text/html).include?(status.content_type))
-    html = reformat(html, true) if %w(text/markdown text/html).include?(status.content_type)
+    keep_html = !summary_mode && %w(text/markdown text/html).include?(status.content_type)
+
+    html = format_markdown(html) if !summary_mode && status.content_type == 'text/markdown'
+    html = encode_and_link_urls(html, linkable_accounts, keep_html: keep_html)
+    html = reformat(html, true) if keep_html
     html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
 
-    unless %w(text/markdown text/html).include?(status.content_type)
+    unless keep_html
       html = simple_format(html, {}, sanitize: false)
-      html = html.delete("\n")
+      html.delete!("\n")
     end
 
+    html = summary_mode ? format_article_summary(html, status) : format_article_content(summary, html) if summary.present?
+    html = format_footer(html, status.footer, linkable_accounts, status.emojis, **options) if status.footer.present?
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  def format_remote_content(html, emojis, **options)
+    html = reformat(html, options[:outgoing])
+    html = encode_custom_emojis(html, emojis, options[:autoplay]) if options[:custom_emojify]
+    html = format_article_content(options[:summary], html) if options[:article_content] && options[:summary].present?
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
+  def format_footer(html, footer, linkable_accounts, emojis, **options)
+    footer = encode_and_link_urls(footer, linkable_accounts)
+    footer = encode_custom_emojis(footer, emojis, options[:autoplay]) if options[:custom_emojify]
+    footer = "<span class=\"invisible\">– </span>#{footer}"
+    footer = simple_format(footer, { 'data-name': 'footer' }, sanitize: false)
+    footer.delete!("\n")
+
+    "#{html}#{footer}"
+  end
+
   def format_markdown(html)
     html = markdown_formatter.render(html)
     html.delete("\r").delete("\n")
   end
 
+  def format_article(text)
+    text = text.gsub(/>[\r\n]+</, '><')
+    text.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  def format_article_summary(html, status)
+    status_url = ActivityPub::TagManager.instance.url_for(status)
+    "#{html}\n<p data-name=\"permalink\">#{link_url(status_url)}</p>"
+  end
+
+  def format_article_content(summary, html)
+    "<blockquote data-name=\"summary\">#{format_summary(summary, html)}</blockquote>#{html}"
+  end
+
   def reformat(html, outgoing = false)
     sanitize(html, Sanitize::Config::MASTODON_STRICT.merge(outgoing: outgoing))
   rescue ArgumentError
@@ -91,7 +137,11 @@ class Formatter
   end
 
   def simplified_format(account, **options)
-    html = account.local? ? linkify(account.note) : reformat(account.note)
+    return reformat(account.note) unless account.local?
+
+    html = format_markdown(account.note)
+    html = encode_and_link_urls(html, keep_html: true)
+    html = reformat(html, true)
     html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
@@ -100,8 +150,12 @@ class Formatter
     Sanitize.fragment(html, config)
   end
 
+  def format_summary(summary, fallback)
+    summary&.strip.presence || fallback[/(?:<p>.*?<\/p>)/im].presence || '🗎❓'
+  end
+
   def format_spoiler(status, **options)
-    html = encode(status.spoiler_text)
+    html = encode(status.title.presence || status.spoiler_text)
     html = encode_custom_emojis(html, status.emojis, options[:autoplay])
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
@@ -124,8 +178,8 @@ class Formatter
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
-  def linkify(text)
-    html = encode_and_link_urls(text)
+  def linkify(text, accounts = nil, options = {})
+    html = encode_and_link_urls(text, accounts, options)
     html = simple_format(html, {}, sanitize: false)
     html = html.delete("\n")
 
@@ -156,7 +210,7 @@ class Formatter
     renderer = HTMLRenderer.new({
       filter_html: false,
       escape_html: false,
-      no_images: true,
+      no_images: false,
       no_styles: true,
       safe_links_only: true,
       hard_wrap: true,
@@ -392,4 +446,17 @@ class Formatter
   def mention_html(account)
     "<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(account.username)}</span></a></span>"
   end
+
+  def formatter_cache_key(status, options)
+    [
+      'format',
+      status.id.to_s,
+      options[:article_content]     ? '1' : '0',
+      options[:inline_poll_options] ? '1' : '0',
+      options[:plaintext]           ? '1' : '0',
+      options[:autoplay]            ? '1' : '0',
+      options[:custom_emojify]      ? '1' : '0',
+    ].join(':')
+  end
 end
+# rubocop:enable Metrics/ClassLength
diff --git a/app/lib/img_tag_handler.rb b/app/lib/img_tag_handler.rb
new file mode 100644
index 000000000..0263e1cbd
--- /dev/null
+++ b/app/lib/img_tag_handler.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class ImgTagHandler < ::Ox::Sax
+  attr_reader :srcs
+  attr_reader :alts
+
+  def initialize
+    @stack = []
+    @srcs = []
+    @alts = {}
+  end
+
+  def start_element(element_name)
+    @stack << [element_name, {}]
+  end
+
+  def end_element(_)
+    self_name, self_attributes = @stack[-1]
+    if self_name == :img && !self_attributes[:src].nil?
+      @srcs << self_attributes[:src]
+      @alts[self_attributes[:src]] = self_attributes[:alt]&.strip
+    end
+    @stack.pop
+  end
+
+  def attr(attribute_name, attribute_value)
+    _name, attributes = @stack.last
+    attributes[attribute_name] = attribute_value&.strip
+  end
+end
diff --git a/app/lib/rss/serializer.rb b/app/lib/rss/serializer.rb
index fd56c568c..334726885 100644
--- a/app/lib/rss/serializer.rb
+++ b/app/lib/rss/serializer.rb
@@ -10,6 +10,7 @@ class RSS::Serializer
             .link(ActivityPub::TagManager.instance.url_for(status))
             .pub_date(status.created_at)
             .description(status.spoiler_text.presence || Formatter.instance.format(status, inline_poll_options: true).to_str)
+            .content(Formatter.instance.format(status, inline_poll_options: true, article_content: true).to_str)
 
         status.media_attachments.each do |media|
           item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size)
diff --git a/app/lib/rss_builder.rb b/app/lib/rss_builder.rb
index 63ddba2e8..a74b4e035 100644
--- a/app/lib/rss_builder.rb
+++ b/app/lib/rss_builder.rb
@@ -35,6 +35,12 @@ class RSSBuilder
       self
     end
 
+    def content(str)
+      @item << (Ox::Element.new('content:encoded') << str)
+
+      self
+    end
+
     def enclosure(url, type, size)
       @item << Ox::Element.new('enclosure').tap do |enclosure|
         enclosure['url']    = url
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index 0fb415bd1..3bc25fe9f 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -31,28 +31,23 @@ class Sanitize
         next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
         next true if e =~ /^(mention|hashtag)$/ # semantic classes
         next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
+        next true if %w(center centered abstract).include?(e)
       end
 
       node['class'] = class_list.join(' ')
     end
 
-    IMG_TAG_TRANSFORMER = lambda do |env|
+    DATA_NAME_ALLOWLIST_TRANSFORMER = lambda do |env|
       node = env[:node]
+      name_list = node['data-name']&.split(/[\t\n\f\r ]/)
 
-      return unless env[:node_name] == 'img'
+      return unless name_list
 
-      node.name = 'a'
-
-      node['href'] = node['src']
-      if node['alt'].present?
-        node.content = "[🖼  #{node['alt']}]"
-      else
-        url = node['href']
-        prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s
-        text   = url[prefix.length, 30]
-        text   = text + "…" if url[prefix.length..-1].length > 30
-        node.content = "[🖼  #{text}]"
+      name_list.keep_if do |name|
+        next true if %w(summary abstract permalink footer).include?(name)
       end
+
+      node['data-name'] = name_list.join(' ')
     end
 
     LINK_REL_TRANSFORMER = lambda do |env|
@@ -84,15 +79,17 @@ class Sanitize
     end
 
     MASTODON_STRICT ||= freeze_config(
-      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),
+      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 img h6 s center details summary),
 
       attributes: {
         'a'          => %w(href rel class title),
         'span'       => %w(class),
         'abbr'       => %w(title),
-        'blockquote' => %w(cite),
+        'blockquote' => %w(cite data-name),
         'ol'         => %w(start reversed),
         'li'         => %w(value),
+        'img'        => %w(src alt title),
+        'p'          => %w(data-name),
       },
 
       add_attributes: {
@@ -108,7 +105,7 @@ class Sanitize
 
       transformers: [
         CLASS_WHITELIST_TRANSFORMER,
-        IMG_TAG_TRANSFORMER,
+        DATA_NAME_ALLOWLIST_TRANSFORMER,
         UNSUPPORTED_HREF_TRANSFORMER,
         LINK_REL_TRANSFORMER,
       ]
diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb
index b6c80b801..bd3e5245e 100644
--- a/app/lib/status_filter.rb
+++ b/app/lib/status_filter.rb
@@ -3,15 +3,17 @@
 class StatusFilter
   attr_reader :status, :account
 
-  def initialize(status, account, preloaded_relations = {})
+  def initialize(status, account, filter_silenced, preloaded_relations = {})
     @status              = status
     @account             = account
     @preloaded_relations = preloaded_relations
+    @filter_silenced     = filter_silenced
   end
 
   def filtered?
     return false if !account.nil? && account.id == status.account_id
-    blocked_by_policy? || (account_present? && filtered_status?) || silenced_account?
+
+    blocked_by_policy? || (account_present? && filtered_status?) || (@filter_silenced && silenced_account?)
   end
 
   private
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 581101782..ca6c09a3a 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
+require 'w3c_validators'
+
 class UserSettingsDecorator
+  include W3CValidators
+
   attr_reader :user, :settings
 
   def initialize(user)
@@ -32,18 +36,37 @@ class UserSettingsDecorator
     user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
     user.settings['system_emoji_font']   = system_emoji_font_preference if change?('setting_system_emoji_font')
     user.settings['noindex']             = noindex_preference if change?('setting_noindex')
-    user.settings['hide_followers_count']= hide_followers_count_preference if change?('setting_hide_followers_count')
+    user.settings['hide_followers_count'] = hide_followers_count_preference if change?('setting_hide_followers_count')
     user.settings['flavour']             = flavour_preference if change?('setting_flavour')
     user.settings['skin']                = skin_preference if change?('setting_skin')
     user.settings['hide_network']        = hide_network_preference if change?('setting_hide_network')
-    user.settings['aggregate_reblogs']   = aggregate_reblogs_preference if change?('setting_aggregate_reblogs')
     user.settings['show_application']    = show_application_preference if change?('setting_show_application')
     user.settings['advanced_layout']     = advanced_layout_preference if change?('setting_advanced_layout')
-    user.settings['default_content_type']= default_content_type_preference if change?('setting_default_content_type')
+    user.settings['default_content_type'] = default_content_type_preference if change?('setting_default_content_type')
     user.settings['use_blurhash']        = use_blurhash_preference if change?('setting_use_blurhash')
     user.settings['use_pending_items']   = use_pending_items_preference if change?('setting_use_pending_items')
     user.settings['trends']              = trends_preference if change?('setting_trends')
     user.settings['crop_images']         = crop_images_preference if change?('setting_crop_images')
+
+    user.settings['manual_publish']      = manual_publish_preference if change?('setting_manual_publish')
+    user.settings['style_dashed_nest']   = style_dashed_nest_preference if change?('setting_style_dashed_nest')
+    user.settings['style_underline_a']   = style_underline_a_preference if change?('setting_style_underline_a')
+    user.settings['style_css_profile']   = style_css_profile_preference if change?('setting_style_css_profile')
+    user.settings['style_css_webapp']    = style_css_webapp_preference if change?('setting_style_css_webapp')
+    user.settings['style_wide_media']    = style_wide_media_preference if change?('setting_style_wide_media')
+    user.settings['style_lowercase']     = style_lowercase_preference if change?('setting_style_lowercase')
+    user.settings['publish_in']          = publish_in_preference if change?('setting_publish_in')
+    user.settings['unpublish_in']        = unpublish_in_preference if change?('setting_unpublish_in')
+    user.settings['unpublish_delete']    = unpublish_delete_preference if change?('setting_unpublish_delete')
+    user.settings['boost_every']         = boost_every_preference if change?('setting_boost_every')
+    user.settings['boost_jitter']        = boost_jitter_preference if change?('setting_boost_jitter')
+    user.settings['boost_random']        = boost_random_preference if change?('setting_boost_random')
+    user.settings['filter_unknown']      = filter_unknown_preference if change?('setting_filter_unknown')
+    user.settings['unpublish_on_delete'] = unpublish_on_delete_preference if change?('setting_unpublish_on_delete')
+    user.settings['home_reblogs']        = home_reblogs_preference if change?('setting_home_reblogs')
+    user.settings['max_history_public']  = max_history_public_preference if change?('setting_max_history_public')
+    user.settings['max_history_private'] = max_history_private_preference if change?('setting_max_history_private')
+    user.settings['web_push']            = web_push_preferences if change?('setting_web_push')
   end
 
   def merged_notification_emails
@@ -134,10 +157,6 @@ class UserSettingsDecorator
     settings['setting_default_language']
   end
 
-  def aggregate_reblogs_preference
-    boolean_cast_setting 'setting_aggregate_reblogs'
-  end
-
   def advanced_layout_preference
     boolean_cast_setting 'setting_advanced_layout'
   end
@@ -162,6 +181,86 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_crop_images'
   end
 
+  def manual_publish_preference
+    boolean_cast_setting 'setting_manual_publish'
+  end
+
+  def style_dashed_nest_preference
+    boolean_cast_setting 'setting_style_dashed_nest'
+  end
+
+  def style_underline_a_preference
+    boolean_cast_setting 'setting_style_underline_a'
+  end
+
+  def style_css_profile_preference
+    css = settings['setting_style_css_profile'].to_s.strip.delete("\r").gsub(/\n\n\n+/, "\n\n")
+    user.settings['style_css_profile_errors'] = validate_css(css)
+    css
+  end
+
+  def style_css_webapp_preference
+    css = settings['setting_style_css_webapp'].to_s.strip.delete("\r").gsub(/\n\n\n+/, "\n\n")
+    user.settings['style_css_webapp_errors'] = validate_css(css)
+    css
+  end
+
+  def style_wide_media_preference
+    boolean_cast_setting 'setting_style_wide_media'
+  end
+
+  def style_lowercase_preference
+    boolean_cast_setting 'setting_style_lowercase'
+  end
+
+  def publish_in_preference
+    settings['setting_publish_in'].to_i
+  end
+
+  def unpublish_in_preference
+    settings['setting_unpublish_in'].to_i
+  end
+
+  def unpublish_delete_preference
+    boolean_cast_setting 'setting_unpublish_delete'
+  end
+
+  def boost_every_preference
+    settings['setting_boost_every'].to_i
+  end
+
+  def boost_jitter_preference
+    settings['setting_boost_jitter'].to_i
+  end
+
+  def boost_random_preference
+    boolean_cast_setting 'setting_boost_random'
+  end
+
+  def filter_unknown_preference
+    boolean_cast_setting 'setting_filter_unknown'
+  end
+
+  def unpublish_on_delete_preference
+    boolean_cast_setting 'setting_unpublish_on_delete'
+  end
+
+  def home_reblogs_preference
+    boolean_cast_setting 'setting_home_reblogs'
+  end
+
+  def max_history_public_preference
+    settings['setting_max_history_public'].to_i
+  end
+
+  def max_history_private_preference
+    settings['setting_max_history_private'].to_i
+  end
+
+  def web_push_preferences
+    boolean_cast_setting 'setting_web_push'
+  end
+
   def boolean_cast_setting(key)
     ActiveModel::Type::Boolean.new.cast(settings[key])
   end
@@ -177,4 +276,10 @@ class UserSettingsDecorator
   def change?(key)
     !settings[key].nil?
   end
+
+  def validate_css(css)
+    @validator ||= CSSValidator.new
+    results = @validator.validate_text(css)
+    results.errors.map { |e| e.to_s.strip }
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 641e984cd..bf286d111 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -50,8 +50,14 @@
 #  avatar_storage_schema_version :integer
 #  header_storage_schema_version :integer
 #  devices_url                   :string
+#  show_replies                  :boolean          default(TRUE), not null
+#  show_unlisted                 :boolean          default(TRUE), not null
+#  private                       :boolean          default(FALSE), not null
+#  last_synced_at                :datetime
 #  sensitized_at                 :datetime
 #  suspension_origin             :integer
+#  no_verify_auth                :boolean          default(FALSE), not null
+#  allow_anonymous               :boolean          default(FALSE), not null
 #
 
 class Account < ApplicationRecord
@@ -119,6 +125,7 @@ class Account < ApplicationRecord
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
+  scope :random, -> { reorder(Arel.sql('RANDOM()')).limit(1) }
 
   delegate :email,
            :unconfirmed_email,
@@ -384,6 +391,38 @@ class Account < ApplicationRecord
     @synchronization_uri_prefix ||= uri[/http(s?):\/\/[^\/]+\//]
   end
 
+  def max_visibility_for_domain(domain)
+    return 'public' if domain.blank?
+
+    domain_permissions.find_by(domain: [domain, '*'])&.visibility || 'public'
+  end
+
+  def visibility_for_domain(domain)
+    v = visibility.to_s
+    return v if domain.blank?
+
+    case max_visibility_for_domain(domain)
+    when 'public'
+      v
+    when 'unlisted'
+      v == 'public' ? 'unlisted' : v
+    when 'private'
+      %w(public unlisted).include?(v) ? 'private' : v
+    when 'direct'
+      'direct'
+    else
+      v != 'direct' ? 'limited' : 'direct'
+    end
+  end
+
+  def public_domain_permissions?
+    domain_permissions.where(visibility: [:public, :unlisted]).exists?
+  end
+
+  def private_domain_permissions?
+    domain_permissions.where(visibility: [:private, :direct, :limited]).exists?
+  end
+
   class Field < ActiveModelSerializers::Model
     attributes :name, :value, :verified_at, :account, :errors
 
@@ -552,6 +591,8 @@ class Account < ApplicationRecord
   before_validation :prepare_username, on: :create
   before_destroy :clean_feed_manager
 
+  after_create_commit :set_metadata, if: :local?
+
   private
 
   def prepare_contents
@@ -595,4 +636,8 @@ class Account < ApplicationRecord
       end
     end
   end
+
+  def set_metadata
+    self.metadata = AccountMetadata.new(account_id: id, fields: {}) if metadata.nil?
+  end
 end
diff --git a/app/models/account_domain_permission.rb b/app/models/account_domain_permission.rb
new file mode 100644
index 000000000..9e77950f2
--- /dev/null
+++ b/app/models/account_domain_permission.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_domain_permissions
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  domain     :string           default(""), not null
+#  visibility :integer          default("public"), not null
+#  sticky     :boolean          default(FALSE), not null
+#
+
+class AccountDomainPermission < ApplicationRecord
+  include Paginable
+  include Cacheable
+
+  validates :domain, presence: true, uniqueness: { scope: :account_id }
+  validates :visibility, presence: true
+
+  belongs_to :account, inverse_of: :domain_permissions
+  enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
+
+  default_scope { order(domain: :desc) }
+
+  cache_associated :account
+
+  class << self
+    def create_by_domains(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create
+      end
+    end
+
+    def create_by_domains!(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create!
+      end
+    end
+
+    def create_or_update(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s)
+      else
+        create(**domain_permissions)
+      end
+      permissions
+    end
+
+    def create_or_update!(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update!(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s)
+      else
+        create!(**domain_permissions)
+      end
+      permissions
+    end
+
+    private
+
+    def normalize(hash)
+      hash.symbolize_keys!
+      hash[:domain] = hash[:domain].strip.downcase
+      hash.compact
+    end
+  end
+end
diff --git a/app/models/account_metadata.rb b/app/models/account_metadata.rb
new file mode 100644
index 000000000..bb0f7676e
--- /dev/null
+++ b/app/models/account_metadata.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_metadata
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  fields     :jsonb            not null
+#
+
+class AccountMetadata < ApplicationRecord
+  include Cacheable
+
+  belongs_to :account, inverse_of: :metadata
+  cache_associated :account
+
+  def fields
+    self[:fields].presence || {}
+  end
+
+  def fields_json
+    fields.select { |name, _| name.start_with?('custom:') }
+          .map do |name, value|
+            {
+              '@context': {
+                schema: 'http://schema.org/',
+                name: 'schema:name',
+                value: 'schema:value',
+              },
+              type: 'PropertyValue',
+              name: name,
+              value: value.is_a?(Array) ? value.join("\r\n") : value,
+            }
+          end
+  end
+
+  def cached_fields_json
+    Rails.cache.fetch("custom_metadata:#{account_id}", expires_in: 1.hour) do
+      fields_json
+    end
+  end
+
+  class << self
+    def create_or_update(fields)
+      create(fields).presence || update(fields)
+    end
+
+    def create_or_update!(fields)
+      create(fields).presence || update!(fields)
+    end
+  end
+end
diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb
new file mode 100644
index 000000000..24aaf66d4
--- /dev/null
+++ b/app/models/collection_item.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: collection_items
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  uri        :string           not null
+#  processed  :boolean          default(FALSE), not null
+#  retries    :integer          default(0), not null
+#
+
+class CollectionItem < ApplicationRecord
+  belongs_to :account, inverse_of: :collection_items, optional: true
+
+  default_scope { order(id: :desc) }
+  scope :unprocessed, -> { where(processed: false) }
+  scope :joins_on_collection_pages, -> { joins('LEFT OUTER JOIN collection_pages ON collection_pages.account_id = collection_items.account_id') }
+  scope :inactive, -> { joins_on_collection_pages.where('collection_pages.account_id IS NULL') }
+  scope :active, -> { joins_on_collection_pages.where('collection_pages.account_id IS NOT NULL') }
+end
diff --git a/app/models/collection_page.rb b/app/models/collection_page.rb
new file mode 100644
index 000000000..e974e58a2
--- /dev/null
+++ b/app/models/collection_page.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: collection_pages
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  uri        :string           not null
+#  next       :string
+#
+
+class CollectionPage < ApplicationRecord
+  belongs_to :account, inverse_of: :collection_pages, optional: true
+
+  default_scope { order(id: :desc) }
+  scope :current, -> { where(next: nil) }
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 98849f8fc..ec4e18699 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -63,5 +63,23 @@ module AccountAssociations
 
     # Account deletion requests
     has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
+
+    # Domain permissions
+    has_many :domain_permissions, class_name: 'AccountDomainPermission', inverse_of: :account, dependent: :destroy
+
+    # Custom metadata
+    has_one :metadata, class_name: 'AccountMetadata', inverse_of: :account, dependent: :destroy
+
+    # Queued boosts
+    has_many :queued_boosts, inverse_of: :account, dependent: :destroy
+
+    # Collection pages
+    has_many :collection_pages, inverse_of: :account, dependent: :destroy
+
+    # Collection items
+    has_many :collection_items, inverse_of: :account, dependent: :destroy
+
+    # Custom emojis
+    has_many :custom_emojis, inverse_of: :account, dependent: :nullify
   end
 end
diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb
index 04b2c981b..c414a6d87 100644
--- a/app/models/concerns/account_finder_concern.rb
+++ b/app/models/concerns/account_finder_concern.rb
@@ -16,6 +16,10 @@ module AccountFinderConcern
       Account.find(-99)
     end
 
+    def site_contact
+      Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')).presence || representative
+    end
+
     def find_local(username)
       find_remote(username, nil)
     end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index e2c4b8acf..98d06d586 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -26,7 +26,7 @@ module AccountInteractions
     end
 
     def muting_map(target_account_ids, account_id)
-      Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
+      Mute.where(target_account_id: target_account_ids, account_id: account_id, timelines_only: false).each_with_object({}) do |mute, mapping|
         mapping[mute.target_account_id] = {
           notifications: mute.hide_notifications?,
         }
@@ -92,9 +92,10 @@ module AccountInteractions
     has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
     has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy
     has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
-    has_many :conversation_mutes, dependent: :destroy
+    has_many :conversation_mutes, inverse_of: :account, dependent: :destroy
     has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
     has_many :announcement_mutes, dependent: :destroy
+    has_many :status_mutes, inverse_of: :account, dependent: :destroy
   end
 
   def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false)
@@ -131,17 +132,18 @@ module AccountInteractions
                        .find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account, notifications: nil, duration: 0)
+  def mute!(other_account, notifications: nil, timelines_only: nil, duration: 0)
     notifications = true if notifications.nil?
-    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_initialize_by(target_account: other_account)
+    timelines_only = false if timelines_only.nil?
+    mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_initialize_by(target_account: other_account)
     mute.expires_in = duration.zero? ? nil : duration
     mute.save!
 
     remove_potential_friendship(other_account)
 
     # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
-    if mute.hide_notifications? != notifications
-      mute.update!(hide_notifications: notifications)
+    if mute.hide_notifications? != notifications || mute.timelines_only? != timelines_only
+      mute.update!(hide_notifications: notifications, timelines_only: timelines_only)
     end
 
     mute
@@ -180,6 +182,15 @@ module AccountInteractions
     block&.destroy
   end
 
+  def mute_status!(status)
+    status_mutes.find_or_create_by!(status: status)
+  end
+
+  def unmute_status!(status)
+    mute = status_mutes.find_by(status: status)
+    mute&.destroy
+  end
+
   def following?(other_account)
     active_relationships.where(target_account: other_account).exists?
   end
@@ -193,7 +204,7 @@ module AccountInteractions
   end
 
   def muting?(other_account)
-    mute_relationships.where(target_account: other_account).exists?
+    mute_relationships.where(target_account: other_account, timelines_only: false).exists?
   end
 
   def muting_conversation?(conversation)
@@ -208,6 +219,10 @@ module AccountInteractions
     active_relationships.where(target_account: other_account, show_reblogs: false).exists?
   end
 
+  def muting_status?(status)
+    status_mutes.where(status: status).exists?
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index a0ead1995..50d081811 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -86,7 +86,7 @@ module StatusThreadingConcern
     domains     = statuses.map(&:account_domain).compact.uniq
     relations   = relations_map_for_account(account, account_ids, domains)
 
-    statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? }
+    statuses.reject! { |status| StatusFilter.new(status, account, false, relations).filtered? }
 
     # Order ancestors/descendants by tree path
     statuses.sort_by! { |status| ids.index(status.id) }
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 4dfaea889..6f776e052 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -7,12 +7,14 @@
 #  uri        :string
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  root       :string
 #
 
 class Conversation < ApplicationRecord
   validates :uri, uniqueness: true, if: :uri?
 
   has_many :statuses
+  has_many :mutes, class_name: 'ConversationMute', inverse_of: :conversation, dependent: :destroy
 
   def local?
     uri.nil?
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
index 52c1a33e0..5d56a3172 100644
--- a/app/models/conversation_mute.rb
+++ b/app/models/conversation_mute.rb
@@ -6,9 +6,10 @@
 #  id              :bigint(8)        not null, primary key
 #  conversation_id :bigint(8)        not null
 #  account_id      :bigint(8)        not null
+#  hidden          :boolean          default(FALSE), not null
 #
 
 class ConversationMute < ApplicationRecord
-  belongs_to :account
-  belongs_to :conversation
+  belongs_to :account, inverse_of: :conversation_mutes
+  belongs_to :conversation, inverse_of: :mutes
 end
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 7cb03b819..c819288ba 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -18,6 +18,7 @@
 #  visible_in_picker            :boolean          default(TRUE), not null
 #  category_id                  :bigint(8)
 #  image_storage_schema_version :integer
+#  account_id                   :bigint(8)
 #
 
 class CustomEmoji < ApplicationRecord
@@ -32,6 +33,7 @@ class CustomEmoji < ApplicationRecord
   IMAGE_MIME_TYPES = %w(image/png image/gif).freeze
 
   belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
+  belongs_to :account, inverse_of: :custom_emojis, optional: true
   has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
 
   has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
@@ -46,6 +48,7 @@ class CustomEmoji < ApplicationRecord
   scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) }
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
   scope :listed, -> { local.where(disabled: false).where(visible_in_picker: true) }
+  scope :owned_by, ->(account) { where(account: account) }
 
   remotable_attachment :image, LIMIT
 
@@ -61,8 +64,11 @@ class CustomEmoji < ApplicationRecord
     :emoji
   end
 
-  def copy!
+  def copy!(current_account = nil)
     copy = self.class.find_or_initialize_by(domain: nil, shortcode: shortcode)
+    return copy if copy.account_id.present? && copy.account_id != current_account&.id
+
+    copy.account = current_account
     copy.image = image
     copy.tap(&:save!)
   end
diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb
index 414e1fcdd..58c888518 100644
--- a/app/models/custom_emoji_filter.rb
+++ b/app/models/custom_emoji_filter.rb
@@ -5,13 +5,16 @@ class CustomEmojiFilter
     local
     remote
     by_domain
+    claimed
+    unclaimed
     shortcode
   ).freeze
 
   attr_reader :params
 
-  def initialize(params)
+  def initialize(params, account)
     @params = params
+    @account = account
   end
 
   def results
@@ -36,6 +39,10 @@ class CustomEmojiFilter
       CustomEmoji.remote
     when 'by_domain'
       CustomEmoji.where(domain: value.strip.downcase)
+    when 'claimed'
+      CustomEmoji.where(account: @account)
+    when 'unclaimed'
+      CustomEmoji.where(account: nil)
     when 'shortcode'
       CustomEmoji.search(value.strip)
     else
diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb
index 5fe0e3a29..70f559f49 100644
--- a/app/models/domain_allow.rb
+++ b/app/models/domain_allow.rb
@@ -8,10 +8,12 @@
 #  domain     :string           default(""), not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  hidden     :boolean          default(FALSE), not null
 #
 
 class DomainAllow < ApplicationRecord
   include DomainNormalizable
+  include Paginable
 
   validates :domain, presence: true, uniqueness: true, domain: true
 
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 2b18e01fa..743e21a29 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -16,6 +16,7 @@
 
 class DomainBlock < ApplicationRecord
   include DomainNormalizable
+  include Paginable
 
   enum severity: [:silence, :suspend, :noop]
 
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index c1f19149b..5899a7f0e 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -30,7 +30,10 @@ class FollowRequest < ApplicationRecord
 
   def authorize!
     account.follow!(target_account, reblogs: show_reblogs, notify: notify, uri: uri)
-    MergeWorker.perform_async(target_account.id, account.id) if account.local?
+    if account.local?
+      MergeWorker.perform_async(target_account.id, account.id)
+      ActivityPub::SyncAccountWorker.perform_async(target_account.id, every_page: true) unless target_account.local?
+    end
     destroy!
   end
 
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index fcec3e686..e36974519 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -4,6 +4,8 @@ class Form::AdminSettings
   include ActiveModel::Model
 
   KEYS = %i(
+    show_domain_allows
+
     site_contact_username
     site_contact_email
     site_title
@@ -76,6 +78,8 @@ class Form::AdminSettings
 
   attr_accessor(*KEYS)
 
+  validates :show_domain_allows, inclusion: { in: %w(disabled users all) }
+
   validates :site_short_description, :site_description, html: { wrap_with: :p }
   validates :site_extended_description, :site_terms, :closed_registrations_message, html: true
   validates :registrations_mode, inclusion: { in: %w(open approved none) }
diff --git a/app/models/form/custom_emoji_batch.rb b/app/models/form/custom_emoji_batch.rb
index f4fa84c10..54a15dc18 100644
--- a/app/models/form/custom_emoji_batch.rb
+++ b/app/models/form/custom_emoji_batch.rb
@@ -24,13 +24,17 @@ class Form::CustomEmojiBatch
       copy!
     when 'delete'
       delete!
+    when 'claim'
+      claim!
+    when 'unclaim'
+      unclaim!
     end
   end
 
   private
 
-  def custom_emojis
-    @custom_emojis ||= CustomEmoji.where(id: custom_emoji_ids)
+  def custom_emojis(include_all = false)
+    @custom_emojis ||= (include_all || current_account&.user&.staff? ? CustomEmoji.where(id: custom_emoji_ids) : CustomEmoji.local.where(id: custom_emoji_ids, account: current_account))
   end
 
   def update!
@@ -40,10 +44,12 @@ class Form::CustomEmojiBatch
       if category_id.present?
         CustomEmojiCategory.find(category_id)
       elsif category_name.present?
-        CustomEmojiCategory.find_or_create_by!(name: category_name)
+        CustomEmojiCategory.find_or_create_by!(name: current_account&.user&.staff? ? category_name.strip : "(@#{current_account.username}) #{category_name}".rstrip)
       end
     end
 
+    return if category.name.start_with?('(@') && !category.name.start_with?("(@#{current_account.username}) ")
+
     custom_emojis.each do |custom_emoji|
       custom_emoji.update(category_id: category&.id)
       log_action :update, custom_emoji
@@ -87,10 +93,10 @@ class Form::CustomEmojiBatch
   end
 
   def copy!
-    custom_emojis.each { |custom_emoji| authorize(custom_emoji, :copy?) }
+    custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :copy?) }
 
     custom_emojis.each do |custom_emoji|
-      copied_custom_emoji = custom_emoji.copy!
+      copied_custom_emoji = custom_emoji.copy!(current_account)
       log_action :create, copied_custom_emoji
     end
   end
@@ -103,4 +109,27 @@ class Form::CustomEmojiBatch
       log_action :destroy, custom_emoji
     end
   end
+
+  def claim!
+    custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :claim?) }
+
+    custom_emojis.each do |custom_emoji|
+      if custom_emoji.local?
+        custom_emoji.update(account: current_account)
+        log_action :update, custom_emoji
+      else
+        copied_custom_emoji = custom_emoji.copy!(current_account)
+        log_action :create, copied_custom_emoji
+      end
+    end
+  end
+
+  def unclaim!
+    custom_emojis.each { |custom_emoji| authorize(custom_emoji, :unclaim?) }
+
+    custom_emojis.each do |custom_emoji|
+      custom_emoji.update(account: nil)
+      log_action :update, custom_emoji
+    end
+  end
 end
diff --git a/app/models/inline_media_attachment.rb b/app/models/inline_media_attachment.rb
new file mode 100644
index 000000000..faa8ca1ac
--- /dev/null
+++ b/app/models/inline_media_attachment.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: inline_media_attachments
+#
+#  id                  :bigint(8)        not null, primary key
+#  status_id           :bigint(8)
+#  media_attachment_id :bigint(8)
+#
+
+class InlineMediaAttachment < ApplicationRecord
+  include Cacheable
+
+  validates :status_id, uniqueness: { scope: :media_attachment_id }
+
+  belongs_to :status, inverse_of: :inlined_attachments
+  belongs_to :media_attachment, inverse_of: :inlines
+
+  cache_associated :status, :media_attachment
+end
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 7ea4e2f98..d60866ad6 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -35,7 +35,7 @@ class Invite < ApplicationRecord
 
   def set_code
     loop do
-      self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join
+      self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(16).join
       break if Invite.find_by(code: code).nil?
     end
   end
diff --git a/app/models/list.rb b/app/models/list.rb
index 8493046e5..006b7e745 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -9,12 +9,13 @@
 #  created_at     :datetime         not null
 #  updated_at     :datetime         not null
 #  replies_policy :integer          default("list_replies"), not null
+#  reblogs        :boolean          default(FALSE), not null
 #
 
 class List < ApplicationRecord
   include Paginable
 
-  PER_ACCOUNT_LIMIT = 50
+  PER_ACCOUNT_LIMIT = 100
 
   enum replies_policy: [:list_replies, :all_replies, :no_replies], _prefix: :show
 
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index cc81b648c..a1fe76589 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -26,6 +26,7 @@
 #  thumbnail_file_size         :integer
 #  thumbnail_updated_at        :datetime
 #  thumbnail_remote_url        :string
+#  inline                      :boolean          default(FALSE), not null
 #
 
 class MediaAttachment < ApplicationRecord
@@ -34,7 +35,7 @@ class MediaAttachment < ApplicationRecord
   enum type: [:image, :gifv, :video, :unknown, :audio]
   enum processing: [:queued, :in_progress, :complete, :failed], _prefix: true
 
-  MAX_DESCRIPTION_LENGTH = 1_500
+  MAX_DESCRIPTION_LENGTH = 2_000
 
   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
@@ -59,12 +60,12 @@ class MediaAttachment < ApplicationRecord
 
   IMAGE_STYLES = {
     original: {
-      pixels: 1_638_400, # 1280x1280px
+      pixels: 16_777_216, # 4096x4096px
       file_geometry_parser: FastGeometryParser,
     }.freeze,
 
     small: {
-      pixels: 160_000, # 400x400px
+      pixels: 250_000, # 500x500px
       file_geometry_parser: FastGeometryParser,
       blurhash: BLURHASH_OPTIONS,
     }.freeze,
@@ -81,8 +82,8 @@ class MediaAttachment < ApplicationRecord
         'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
         'vsync' => 'cfr',
         'c:v' => 'h264',
-        'maxrate' => '1300K',
-        'bufsize' => '1300K',
+        'maxrate' => '2M',
+        'bufsize' => '2M',
         'frames:v' => 60 * 60 * 3,
         'crf' => 18,
         'map_metadata' => '-1',
@@ -112,7 +113,7 @@ class MediaAttachment < ApplicationRecord
       convert_options: {
         output: {
           'loglevel' => 'fatal',
-          vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+          vf: 'scale=\'min(500\, iw):min(500\, ih)\':force_original_aspect_ratio=decrease',
         }.freeze,
       }.freeze,
       format: 'png',
@@ -131,7 +132,7 @@ class MediaAttachment < ApplicationRecord
       convert_options: {
         output: {
           'loglevel' => 'fatal',
-          'q:a' => 2,
+          'q:a' => 0,
         }.freeze,
       }.freeze,
     }.freeze,
@@ -147,7 +148,7 @@ class MediaAttachment < ApplicationRecord
   }.freeze
 
   GLOBAL_CONVERT_OPTIONS = {
-    all: '-quality 90 -strip +set modify-date +set create-date',
+    all: '-quality 95 -strip +set modify-date +set create-date',
   }.freeze
 
   IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i
@@ -160,6 +161,8 @@ class MediaAttachment < ApplicationRecord
   belongs_to :status,           inverse_of: :media_attachments, optional: true
   belongs_to :scheduled_status, inverse_of: :media_attachments, optional: true
 
+  has_many :inlines, class_name: 'InlineMediaAttachment', inverse_of: :media_attachment, dependent: :destroy
+
   has_attached_file :file,
                     styles: ->(f) { file_styles f },
                     processors: ->(f) { file_processors f },
@@ -189,13 +192,16 @@ class MediaAttachment < ApplicationRecord
   validates :file, presence: true, if: :local?
   validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
 
-  scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
-  scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
-  scope :local,      -> { where(remote_url: '') }
-  scope :remote,     -> { where.not(remote_url: '') }
+  scope :attached,   -> { all_media.where.not(status_id: nil).or(all_media.where.not(scheduled_status_id: nil)) }
+  scope :unattached, -> { all_media.where(status_id: nil, scheduled_status_id: nil) }
+  scope :uninlined,  -> { where(inline: false) }
+  scope :inlined,    -> { rewhere(inline: true) }
+  scope :all_media,  -> { unscope(where: :inline) }
+  scope :local,      -> { all_media.where(remote_url: '') }
+  scope :remote,     -> { all_media.where.not(remote_url: '') }
   scope :cached,     -> { remote.where.not(file_file_name: nil) }
 
-  default_scope { order(id: :asc) }
+  default_scope { uninlined.order(id: :asc) }
 
   def local?
     remote_url.blank?
diff --git a/app/models/mute.rb b/app/models/mute.rb
index fe8b6f42c..07135b215 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -9,7 +9,7 @@
 #  hide_notifications :boolean          default(TRUE), not null
 #  account_id         :bigint(8)        not null
 #  target_account_id  :bigint(8)        not null
-#  hide_notifications :boolean          default(TRUE), not null
+#  timelines_only     :boolean          default(FALSE), not null
 #  expires_at         :datetime
 #
 
diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb
index 2839da5cb..7418a1c9d 100644
--- a/app/models/public_feed.rb
+++ b/app/models/public_feed.rb
@@ -26,7 +26,8 @@ class PublicFeed < Feed
     scope.merge!(without_replies_scope) unless with_replies?
     scope.merge!(without_reblogs_scope) unless with_reblogs?
     scope.merge!(local_only_scope) if local_only?
-    scope.merge!(remote_only_scope) if remote_only?
+    #scope.merge!(remote_only_scope) if remote_only?
+    scope.merge!(curated_scope) unless local_only? || remote_only?
     scope.merge!(account_filters_scope) if account?
     scope.merge!(media_only_scope) if media_only?
 
@@ -79,6 +80,10 @@ class PublicFeed < Feed
     Status.remote
   end
 
+  def curated_scope
+    Status.curated
+  end
+
   def without_replies_scope
     Status.without_replies
   end
diff --git a/app/models/queued_boost.rb b/app/models/queued_boost.rb
new file mode 100644
index 000000000..6eca3725f
--- /dev/null
+++ b/app/models/queued_boost.rb
@@ -0,0 +1,15 @@
+# == Schema Information
+#
+# Table name: queued_boosts
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
+#
+
+class QueuedBoost < ApplicationRecord
+  belongs_to :account, inverse_of: :queued_boosts
+  belongs_to :status, inverse_of: :queued_boosts
+
+  validates :account_id, uniqueness: { scope: :status_id }
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index d1ac2e4f2..f20e7710d 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -21,13 +21,23 @@
 #  account_id             :bigint(8)        not null
 #  application_id         :bigint(8)
 #  in_reply_to_account_id :bigint(8)
-#  local_only             :boolean
-#  full_status_text       :text             default(""), not null
+#  local_only             :boolean          default(FALSE), not null
 #  poll_id                :bigint(8)
 #  content_type           :string
 #  deleted_at             :datetime
+#  edited                 :integer          default(0), not null
+#  nest_level             :integer          default(0), not null
+#  published              :boolean          default(TRUE), not null
+#  title                  :text
+#  original_text          :text
+#  footer                 :text
+#  expires_at             :datetime
+#  publish_at             :datetime
+#  originally_local_only  :boolean          default(FALSE), not null
+#  curated                :boolean          default(FALSE), not null
 #
 
+# rubocop:disable Metrics/ClassLength
 class Status < ApplicationRecord
   before_destroy :unlink_from_conversations
 
@@ -65,8 +75,15 @@ class Status < ApplicationRecord
   has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy, inverse_of: :status
   has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
+  has_many :silent_mentions, -> { silent }, class_name: 'Mention', inverse_of: :status
   has_many :media_attachments, dependent: :nullify
 
+  has_many :inlined_attachments, class_name: 'InlineMediaAttachment', inverse_of: :status, dependent: :destroy
+  has_many :mutes, class_name: 'StatusMute', inverse_of: :status, dependent: :destroy
+  belongs_to :conversation_mute, primary_key: 'conversation_id', foreign_key: 'conversation_id', inverse_of: :conversation, dependent: :destroy, optional: true
+  has_many :domain_permissions, class_name: 'StatusDomainPermission', inverse_of: :status, dependent: :destroy
+  has_many :queued_boosts, inverse_of: :status, dependent: :destroy
+
   has_and_belongs_to_many :tags
   has_and_belongs_to_many :preview_cards
 
@@ -90,9 +107,10 @@ class Status < ApplicationRecord
   scope :remote, -> { where(local: false).where.not(uri: nil) }
   scope :local,  -> { where(local: true).or(where(uri: nil)) }
   scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
-  scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
+  scope :without_replies, -> { where(reply: false) }
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
-  scope :with_public_visibility, -> { where(visibility: :public) }
+  scope :with_public_visibility, -> { where(visibility: :public, published: true) }
+  scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) }
   scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
   scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
@@ -113,6 +131,21 @@ class Status < ApplicationRecord
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
 
+  scope :including_unpublished, -> { unscope(where: :published) }
+  scope :unpublished, -> { rewhere(published: false) }
+  scope :published, -> { where(published: true) }
+  scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') }
+  scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) }
+  scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) }
+  scope :replies, -> { where(reply: true) }
+  scope :expired, -> { published.where('statuses.expires_at IS NOT NULL AND statuses.expires_at < ?', Time.now.utc) }
+  scope :ready_to_publish, -> { unpublished.where('statuses.publish_at IS NOT NULL AND statuses.publish_at < ?', Time.now.utc) }
+  scope :curated, -> { where(curated: true) }
+
+  scope :not_hidden_by_account, ->(account) do
+    left_outer_joins(:mutes, :conversation_mute).where('(status_mutes.account_id IS NULL OR status_mutes.account_id != ?) AND (conversation_mutes.account_id IS NULL OR conversation_mutes.account_id != ?)', account.id, account.id)
+  end
+
   cache_associated :application,
                    :media_attachments,
                    :conversation,
@@ -136,8 +169,21 @@ class Status < ApplicationRecord
                    thread: { account: :account_stat }
 
   delegate :domain, to: :account, prefix: true
+  delegate :max_visibility_for_domain, to: :account
 
   REAL_TIME_WINDOW = 6.hours
+  SORTED_VISIBILITY = {
+    direct: 0,
+    limited: 1,
+    private: 2,
+    unlisted: 3,
+    public: 4,
+  }.with_indifferent_access.freeze
+  TIMER_VALUES = [
+    0, 1, 2, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 4320, 7200,
+    10_080, 20_160, 30_240, 60_480, 120_960, 181_440, 241_920, 362_880, 524_160
+  ].freeze
+  HISTORY_VALUES = [0, 1, 2, 3, 6, 12, 18, 24, 36, 52, 104, 156].freeze
 
   def searchable_by(preloaded = nil)
     ids = []
@@ -204,11 +250,11 @@ class Status < ApplicationRecord
   end
 
   def hidden?
-    !distributable?
+    !(published? || distributable?)
   end
 
   def distributable?
-    public_visibility? || unlisted_visibility?
+    !account.private? && (public_visibility? || unlisted_visibility?)
   end
 
   alias sign? distributable?
@@ -228,7 +274,7 @@ class Status < ApplicationRecord
   def emojis
     return @emojis if defined?(@emojis)
 
-    fields  = [spoiler_text, text]
+    fields  = [spoiler_text, text, footer || '']
     fields += preloadable_poll.options unless preloadable_poll.nil?
 
     @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
@@ -262,24 +308,100 @@ class Status < ApplicationRecord
     update_status_stat!(key => [public_send(key) - 1, 0].max)
   end
 
+  def curate!
+    update_column(:curated, true) if public_visibility? && !curated
+  end
+
+  def uncurate!
+    update_column(:curated, false) if curated
+  end
+
+  def notify=(value)
+    Redis.current.set("status:#{id}:notify", value ? 1 : 0, ex: 1.hour)
+    @notify = value
+  end
+
+  def notify
+    return @notify if defined?(@notify)
+
+    value = Redis.current.get("status:#{id}:notify")
+    @notify = value.nil? ? true : value.to_i == 1
+  end
+
+  alias notify? notify
+
+  def less_private_than?(other_visibility)
+    return false if other_visibility.blank?
+
+    SORTED_VISIBILITY[visibility] > SORTED_VISIBILITY[other_visibility]
+  end
+
+  def more_private_than?(other_visibility)
+    return false if other_visibility.blank?
+
+    SORTED_VISIBILITY[visibility] < SORTED_VISIBILITY[other_visibility]
+  end
+
+  def visibility_for_domain(domain)
+    return visibility.to_s if domain.blank?
+    return 'private' if account.private?
+
+    v = domain_permissions.find_by(domain: [domain, '*'])&.visibility || visibility.to_s
+
+    case max_visibility_for_domain(domain)
+    when 'public'
+      v
+    when 'unlisted'
+      v == 'public' ? 'unlisted' : v
+    when 'private'
+      %w(public unlisted).include?(v) ? 'private' : v
+    when 'direct'
+      'direct'
+    else
+      v != 'direct' ? 'limited' : 'direct'
+    end
+  end
+
+  def public_domain_permissions?
+    return @public_permissions if defined?(@public_permissions)
+    return @public_permissions = false unless account.local?
+
+    @public_permissions = domain_permissions.where(visibility: [:public, :unlisted]).exists?
+  end
+
+  def private_domain_permissions?
+    return @private_permissions if defined?(@private_permissions)
+    return @private_permissions = false unless account.local?
+
+    @private_permissions = domain_permissions.where(visibility: [:private, :direct, :limited]).exists?
+  end
+
+  def should_limit_visibility?
+    less_private_than?(thread&.visibility)
+  end
+
   after_create_commit  :increment_counter_caches
   after_destroy_commit :decrement_counter_caches
 
   after_create_commit :store_uri, if: :local?
+  after_create_commit :store_url, if: :local?
   after_create_commit :update_statistics, if: :local?
 
   around_create Mastodon::Snowflake::Callbacks
 
   before_create :set_locality
+  before_create :set_nest_level
 
   before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
-  before_validation :set_visibility
-  before_validation :set_conversation
+  before_validation :set_conversation_perms
   before_validation :set_local
 
   after_create :set_poll_id
 
+  after_save :set_domain_permissions, if: :local?
+  after_save :set_conversation_root
+
   class << self
     def selectable_visibilities
       visibilities.keys - %w(direct limited)
@@ -346,6 +468,10 @@ class Status < ApplicationRecord
       ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
     end
 
+    def hidden_statuses_map(status_ids, account_id)
+      StatusMute.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.status_id] = true }
+    end
+
     def pins_map(status_ids, account_id)
       StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
     end
@@ -370,26 +496,26 @@ class Status < ApplicationRecord
       end
     end
 
-    def permitted_for(target_account, account)
+    def permitted_for(target_account, account, **options)
       visibility = [:public, :unlisted]
 
-      if account.nil?
-        where(visibility: visibility).not_local_only
-      elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps
-        none
-      elsif account.id == target_account.id # author can see own stuff
-        all
-      else
-        # followers can see followers-only stuff, but also things they are mentioned in.
-        # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in.
+      if account.present?
+        return none if target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain))
+        return apply_category_filters(all, target_account, account, **options) if account.id == target_account.id
+
         visibility.push(:private) if account.following?(target_account)
+      end
 
-        scope = left_outer_joins(:reblog)
+      visibility = :public if options[:public] || (account.blank? && !target_account.show_unlisted?)
 
-        scope.where(visibility: visibility)
-             .or(scope.where(id: account.mentions.select(:status_id)))
-             .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids })))
-      end
+      scope = where(visibility: visibility)
+      apply_category_filters(scope, target_account, account, **options)
+    end
+
+    def mentions_between(account, target_account)
+      return none if account.blank? || target_account.blank?
+
+      account.statuses.mentioning_account(target_account).or(target_account.statuses.mentioning_account(account))
     end
 
     def from_text(text)
@@ -406,6 +532,67 @@ class Status < ApplicationRecord
         status&.distributable? ? status : nil
       end.compact
     end
+
+    private
+
+    # TODO: Cast cleanup spell.
+    # rubocop:disable Metrics/PerceivedComplexity
+    def apply_category_filters(query, target_account, account, **options)
+      options[:without_account_filters] ||= target_account.id == account&.id
+      query = apply_account_filters(query, account, **options)
+      return query if options[:without_category_filters]
+
+      query = query.published unless options[:include_unpublished]
+
+      if options[:only_reblogs]
+        query = query.joins(:reblog)
+        if account.present? && account.excluded_from_timeline_account_ids.present?
+          query = query.where.not(
+            reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }
+          )
+        end
+      elsif target_account.id == account&.id
+        query = query.without_replies unless options[:include_replies] || options[:only_replies]
+        query = query.without_reblogs unless options[:include_reblogs] || options[:only_reblogs]
+        query = query.reblogs if options[:only_reblogs]
+        query = query.replies if options[:only_replies]
+      else
+        if options[:include_reblogs] && account.present? && account.excluded_from_timeline_account_ids.present?
+          query = query.left_outer_joins(:reblog).where(
+            '(statuses.reblog_of_id IS NULL OR reblogs_statuses.account_id NOT IN (?))',
+            account.excluded_from_timeline_account_ids
+          )
+        elsif !options[:include_reblogs]
+          query = query.without_reblogs
+        end
+
+        if options[:include_replies]
+          query = query.replies if options[:only_replies]
+        else
+          query = query.without_replies
+        end
+      end
+
+      if target_account.id != account&.id && target_account&.user&.max_history_public.present?
+        history_limit = account&.following?(target_account) ? target_account.user.max_history_private : target_account.user.max_history_public
+        query = query.where('statuses.updated_at >= ?', history_limit.weeks.ago) if history_limit.positive?
+      end
+
+      return query if options[:tag].blank?
+
+      (tag = Tag.find_normalized(options[:tag])) ? query.merge(Status.tagged_with(tag.id)) : none
+    end
+    # rubocop:enable Metrics/PerceivedComplexity
+
+    def apply_account_filters(query, account, **options)
+      return query.not_local_only if account.blank?
+      return (!options[:exclude_local_only] && account.local? ? query : query.not_local_only) if options[:without_account_filters]
+
+      query = query.not_local_only unless !options[:exclude_local_only] && account.local?
+      query = query.not_hidden_by_account(account)
+      query = query.in_chosen_languages(account) if account.chosen_languages.present?
+      query
+    end
   end
 
   def marked_local_only?
@@ -433,9 +620,15 @@ class Status < ApplicationRecord
     update_column(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil?
   end
 
+  def store_url
+    update_column(:url, ActivityPub::TagManager.instance.url_for(self)) if url.nil?
+  end
+
   def prepare_contents
     text&.strip!
     spoiler_text&.strip!
+    title&.strip!
+    language&.gsub!('en-MP', 'en')
   end
 
   def set_reblog
@@ -446,31 +639,35 @@ class Status < ApplicationRecord
     update_column(:poll_id, poll.id) unless poll.nil?
   end
 
-  def set_visibility
-    self.visibility = reblog.visibility if reblog? && visibility.nil?
-    self.visibility = (account.locked? ? :private : :public) if visibility.nil?
-    self.sensitive  = false if sensitive.nil?
-  end
-
   def set_locality
     if account.domain.nil? && !attribute_changed?(:local_only)
-      self.local_only = marked_local_only?
+      self.local_only = true if marked_local_only?
     end
+    self.local_only = true if thread&.local_only? && local_only.nil?
+    self.local_only = reblog.local_only if reblog?
+
+    self.originally_local_only = local_only if attribute_changed?(:local_only) && !attribute_changed?(:originally_local_only)
   end
 
-  def set_conversation
+  def set_conversation_perms
     self.thread = thread.reblog if thread&.reblog?
-
     self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
+    self.visibility = reblog.visibility if reblog? && visibility.nil?
+    self.visibility = (account.locked? ? :private : :public) if visibility.nil?
+    self.visibility = thread.visibility if should_limit_visibility?
+    self.sensitive  = false if sensitive.nil?
 
     if reply? && !thread.nil?
       self.in_reply_to_account_id = carried_over_reply_to_account_id
       self.conversation_id        = thread.conversation_id if conversation_id.nil?
-    elsif conversation_id.nil?
-      self.conversation = Conversation.new
+      self.visibility             = :limited if in_reply_to_account_id != account_id && (visibility.to_s == 'private' || account.private?)
     end
   end
 
+  def set_conversation_root
+    conversation.update!(root: uri) if !reply && conversation.present? && conversation.root.blank?
+  end
+
   def carried_over_reply_to_account_id
     if thread.account_id == account_id && thread.reply?
       thread.in_reply_to_account_id
@@ -483,6 +680,28 @@ class Status < ApplicationRecord
     self.local = account.local?
   end
 
+  def set_nest_level
+    return if attribute_changed?(:nest_level)
+
+    self.nest_level = if reply?
+                        [thread&.account_id == account_id ? thread&.nest_level.to_i : thread&.nest_level.to_i + 1, 127].min
+                      else
+                        0
+                      end
+  end
+
+  def set_domain_permissions
+    return unless saved_change_to_visibility?
+
+    domain_permissions.transaction do
+      existing_domains = domain_permissions.select(:domain)
+      permissions = account.domain_permissions.where.not(domain: existing_domains)
+      permissions.find_each do |permission|
+        domain_permissions.create!(domain: permission.domain, visibility: permission.visibility) if less_private_than?(permission.visibility)
+      end
+    end
+  end
+
   def update_statistics
     return unless distributable?
 
@@ -516,3 +735,4 @@ class Status < ApplicationRecord
     end
   end
 end
+# rubocop:enable Metrics/ClassLength
diff --git a/app/models/status_domain_permission.rb b/app/models/status_domain_permission.rb
new file mode 100644
index 000000000..be767a2b6
--- /dev/null
+++ b/app/models/status_domain_permission.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_domain_permissions
+#
+#  id         :bigint(8)        not null, primary key
+#  status_id  :bigint(8)        not null
+#  domain     :string           default(""), not null
+#  visibility :integer          default("public"), not null
+#
+
+class StatusDomainPermission < ApplicationRecord
+  include Paginable
+  include Cacheable
+
+  validates :domain, presence: true, uniqueness: { scope: :status_id }
+  validates :visibility, presence: true
+
+  belongs_to :status, inverse_of: :domain_permissions
+  enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
+
+  default_scope { order(domain: :desc) }
+
+  cache_associated :status
+
+  class << self
+    def create_by_domains(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create
+      end
+    end
+
+    def create_by_domains!(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create!
+      end
+    end
+
+    def create_or_update(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update(**domain_permissions)
+      else
+        create(**domain_permissions)
+      end
+      permissions
+    end
+
+    def create_or_update!(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update!(**domain_permissions)
+      else
+        create!(**domain_permissions)
+      end
+      permissions
+    end
+
+    private
+
+    def normalize(hash)
+      hash.symbolize_keys!
+      hash[:domain] = hash[:domain].strip.downcase
+      hash.compact
+    end
+  end
+end
diff --git a/app/models/status_mute.rb b/app/models/status_mute.rb
new file mode 100644
index 000000000..1e01f0278
--- /dev/null
+++ b/app/models/status_mute.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_mutes
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
+#
+
+class StatusMute < ApplicationRecord
+  include Cacheable
+
+  validates :account_id, uniqueness: { scope: :status_id }
+
+  belongs_to :account, inverse_of: :status_mutes
+  belongs_to :status, inverse_of: :mutes
+
+  cache_associated :account, :status
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 9bdbac76d..d3ac464d7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -42,6 +42,8 @@
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
 #  sign_up_ip                :inet
+#  username                  :string
+#  kobold                    :string
 #
 
 class User < ApplicationRecord
@@ -90,7 +92,7 @@ class User < ApplicationRecord
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 
   scope :recent, -> { order(id: :desc) }
-  scope :pending, -> { where(approved: false) }
+  scope :pending, -> { where(approved: false).where.not(kobold: '') }
   scope :approved, -> { where(approved: true) }
   scope :confirmed, -> { where.not(confirmed_at: nil) }
   scope :enabled, -> { where(disabled: false) }
@@ -100,6 +102,7 @@ class User < ApplicationRecord
   scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
   scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
+  scope :lower_username, ->(username) { where('lower(users.username) = lower(?)', username) }
 
   before_validation :sanitize_languages
   before_create :set_approved
@@ -114,9 +117,15 @@ class User < ApplicationRecord
 
   delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
            :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network, :hide_followers_count,
-           :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
+           :expand_spoilers, :default_language, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
            :disable_swiping, :default_content_type, :system_emoji_font,
+           :manual_publish, :style_dashed_nest, :style_underline_a, :style_css_profile,
+           :style_css_profile_errors, :style_css_webapp, :style_css_webapp_errors,
+           :style_wide_media, :style_lowercase,
+           :publish_in, :unpublish_in, :unpublish_delete, :boost_every, :boost_jitter,
+           :boost_random, :unpublish_on_delete, :home_reblogs,
+           :filter_unknown, :max_history_public, :max_history_private, :web_push,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_reader :invite_code, :sign_in_token_attempt
@@ -150,7 +159,7 @@ class User < ApplicationRecord
 
     if new_user && approved?
       prepare_new_user!
-    elsif new_user
+    elsif new_user && user_might_not_be_a_spam_bot
       notify_staff_about_pending_account!
     end
   end
@@ -192,7 +201,7 @@ class User < ApplicationRecord
   end
 
   def suspicious_sign_in?(ip)
-    !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip)
+    !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 12.weeks.ago && !recent_ip?(ip)
   end
 
   def functional?
@@ -260,14 +269,26 @@ class User < ApplicationRecord
     @hides_network ||= settings.hide_network
   end
 
-  def aggregates_reblogs?
-    @aggregates_reblogs ||= settings.aggregate_reblogs
-  end
-
   def shows_application?
     @shows_application ||= settings.show_application
   end
 
+  def home_reblogs?
+    @home_reblogs ||= settings.home_reblogs
+  end
+
+  def filters_unknown?
+    @filters_unknown ||= settings.filter_unknown
+  end
+
+  def max_history_private
+    @max_history_private ||= settings.max_history_private.to_i
+  end
+
+  def max_history_public
+    @max_history_public ||= [settings.max_history_public.to_i, max_history_private].min
+  end
+
   # rubocop:disable Naming/MethodParameterName
   def token_for_app(a)
     return nil if a.nil? || a.owner != self
@@ -321,6 +342,17 @@ class User < ApplicationRecord
     super
   end
 
+  def send_confirmation_instructions
+    unless approved? || user_might_not_be_a_spam_bot
+      invite_request&.destroy
+      account&.destroy
+      destroy
+      return false
+    end
+
+    super
+  end
+
   def reset_password!(new_password, new_password_confirmation)
     return false if encrypted_password.blank?
 
@@ -440,7 +472,7 @@ class User < ApplicationRecord
 
   def notify_staff_about_pending_account!
     User.staff.includes(:account).find_each do |u|
-      next unless u.allows_pending_account_emails?
+      next unless u.account.actor_type == 'Person' && u.allows_pending_account_emails?
       AdminMailer.new_pending_account(u.account, self).deliver_later
     end
   end
@@ -458,4 +490,30 @@ class User < ApplicationRecord
   def validate_email_dns?
     email_changed? && !(Rails.env.test? || Rails.env.development?)
   end
+
+  def user_might_not_be_a_spam_bot
+    return false unless username.downcase == account.username.downcase
+
+    update(username: account.username) unless username == account.username
+    invited? || (invite_request&.text.present? && kobold_hash_matches?)
+  end
+
+  def kobold_hash_matches?
+    kobold.present? && kobold == kobold_hash
+  end
+
+  def kobold_hash
+    value = [account.username, username.downcase, email, invite_request.text.gsub(/\r\n?/, "\n")].compact.map(&:downcase).join("\u{F0666}")
+    Digest::SHA512.hexdigest(value).upcase
+  end
+
+  class << self
+    def find_by_lower_username(username)
+      lower_username(username).first
+    end
+
+    def find_by_lower_username!(username)
+      lower_username(username).first!
+    end
+  end
 end
diff --git a/app/policies/account_domain_permission_policy.rb b/app/policies/account_domain_permission_policy.rb
new file mode 100644
index 000000000..b50857f9f
--- /dev/null
+++ b/app/policies/account_domain_permission_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AccountDomainPermissionPolicy < ApplicationPolicy
+  def update?
+    owned?
+  end
+
+  def destroy?
+    owned?
+  end
+
+  private
+
+  def owned?
+    record.account_id == current_account&.id
+  end
+end
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
index a8c3cbc73..7e585a3d6 100644
--- a/app/policies/custom_emoji_policy.rb
+++ b/app/policies/custom_emoji_policy.rb
@@ -2,30 +2,52 @@
 
 class CustomEmojiPolicy < ApplicationPolicy
   def index?
-    staff?
+    user_signed_in?
   end
 
   def create?
-    admin?
+    user_signed_in?
   end
 
   def update?
-    admin?
+    user_signed_in? && owned?
   end
 
   def copy?
-    admin?
+    staff? || (user_signed_in? && new_or_owned?)
   end
 
   def enable?
-    staff?
+    user_signed_in? && owned?
   end
 
   def disable?
-    staff?
+    user_signed_in? && owned?
   end
 
   def destroy?
-    admin?
+    user_signed_in? && owned?
+  end
+
+  def claim?
+    staff? || claimable?
+  end
+
+  def unclaim?
+    user_signed_in? && owned?
+  end
+
+  private
+
+  def owned?
+    staff? || (current_account.present? && record.account_id == current_account.id)
+  end
+
+  def new_or_owned?
+    !CustomEmoji.where(domain: nil, shortcode: record.shortcode).where('account_id IS NULL OR account_id != ?', current_account.id).exists?
+  end
+
+  def claimable?
+    record.local? ? record.account_id.blank? || record.account_id == current_account.id : new_or_owned?
   end
 end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index d0359580d..c16ec29ed 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -13,19 +13,22 @@ class StatusPolicy < ApplicationPolicy
 
   def show?
     return false if author.suspended?
-    return false if local_only? && (current_account.nil? || !current_account.local?)
+    return false if local_only? && !current_account&.local?
+    return false unless published? || owned?
 
     if requires_mention?
       owned? || mention_exists?
+    elsif author.private? && public?
+      !(author_blocking? || author_blocking_domain?)
     elsif private?
       owned? || following_author? || mention_exists?
     else
-      current_account.nil? || (!author_blocking? && !author_blocking_domain?)
+      current_account.nil? || !(author_blocking? || author_blocking_domain?)
     end
   end
 
   def reblog?
-    !requires_mention? && (!private? || owned?) && show? && !blocking_author?
+    published? && !requires_mention? && (!private? || owned?) && show? && !blocking_author?
   end
 
   def favourite?
@@ -45,7 +48,7 @@ class StatusPolicy < ApplicationPolicy
   private
 
   def requires_mention?
-    record.direct_visibility? || record.limited_visibility?
+    %w(direct limited).include?(visibility_for_remote_domain)
   end
 
   def owned?
@@ -53,7 +56,7 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def private?
-    record.private_visibility?
+    visibility_for_remote_domain == 'private'
   end
 
   def mention_exists?
@@ -93,8 +96,20 @@ class StatusPolicy < ApplicationPolicy
   def author
     record.account
   end
-  
+
   def local_only?
     record.local_only?
   end
+
+  def published?
+    record.published?
+  end
+
+  def public?
+    record.public_visibility? || record.unlisted_visibility?
+  end
+
+  def visibility_for_remote_domain
+    @visibility_for_domain ||= record.visibility_for_domain(current_account&.domain)
+  end
 end
diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb
index 5d174767f..dbeeb5316 100644
--- a/app/presenters/activitypub/activity_presenter.rb
+++ b/app/presenters/activitypub/activity_presenter.rb
@@ -4,24 +4,30 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
   attributes :id, :type, :actor, :published, :to, :cc, :virtual_object
 
   class << self
-    def from_status(status)
+    def from_status(status, domain, update: false, embed: false)
       new.tap do |presenter|
+        default_activity    = update && status.edited.positive? ? 'Update' : 'Create'
         presenter.id        = ActivityPub::TagManager.instance.activity_uri_for(status)
-        presenter.type      = status.reblog? ? 'Announce' : 'Create'
+        presenter.type      = (status.reblog? && status.spoiler_text.blank? ? 'Announce' : default_activity)
         presenter.actor     = ActivityPub::TagManager.instance.uri_for(status.account)
         presenter.published = status.created_at
-        presenter.to        = ActivityPub::TagManager.instance.to(status)
-        presenter.cc        = ActivityPub::TagManager.instance.cc(status)
+        presenter.to        = ActivityPub::TagManager.instance.to(status, domain)
+        presenter.cc        = ActivityPub::TagManager.instance.cc(status, domain)
+
+        unless embed || status.account.no_verify_auth?
+          presenter.virtual_object = ActivityPub::TagManager.instance.uri_for(status.proper)
+          next
+        end
 
         presenter.virtual_object = begin
-          if status.reblog?
+          if status.reblog? && status.spoiler_text.blank?
             if status.account == status.proper.account && status.proper.private_visibility? && status.local?
               status.proper
             else
               ActivityPub::TagManager.instance.uri_for(status.proper)
             end
           else
-            status.proper
+            status
           end
         end
       end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 3cc905a75..d898a9188 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -4,6 +4,8 @@ class StatusRelationshipsPresenter
   attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
               :bookmarks_map
 
+  attr_reader :hidden_statuses_map
+
   def initialize(statuses, current_account_id = nil, **options)
     if current_account_id.nil?
       @reblogs_map    = {}
@@ -11,6 +13,8 @@ class StatusRelationshipsPresenter
       @bookmarks_map  = {}
       @mutes_map      = {}
       @pins_map       = {}
+
+      @hidden_statuses_map = {}
     else
       statuses            = statuses.compact
       status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
@@ -22,6 +26,8 @@ class StatusRelationshipsPresenter
       @bookmarks_map   = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
       @mutes_map       = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
       @pins_map        = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
+
+      @hidden_statuses_map = Status.hidden_statuses_map(status_ids, current_account_id).merge(options[:hidden_statuses_map] || {})
     end
   end
 end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index 759ef30f9..ebaf2d093 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -25,6 +25,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   attribute :also_known_as, if: :also_known_as?
   attribute :suspended, if: :suspended?
 
+  context_extensions :show_replies, :private, :metadata, :server_metadata
+  attributes :show_replies, :show_unlisted, :private
+  attributes :metadata, :server_metadata
+
   class EndpointsSerializer < ActivityPub::Serializer
     include RoutingHelper
 
@@ -146,6 +150,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
     object.suspended? ? [] : (object.fields + object.identity_proofs.active)
   end
 
+  def metadata
+    object.metadata.cached_fields_json
+  end
+
+  def server_metadata
+    Mastodon::Version.server_metadata_json
+  end
+
   def moved_to
     ActivityPub::TagManager.instance.uri_for(object.moved_to_account)
   end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index c4f8f5c3f..29fec7e03 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,16 +3,25 @@
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
   context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message
 
+  context_extensions :edited, :server_metadata, :root, :reblog, :expires
+
   attributes :id, :type, :summary,
              :in_reply_to, :published, :url,
              :attributed_to, :to, :cc, :sensitive,
              :atom_uri, :in_reply_to_atom_uri,
              :conversation
 
+  attributes :updated, :root
+  attribute :title, key: :name, if: :title_present?
+  attribute :reblog, if: :reblog_present?
+  attribute :renote, key: '_misskey_quote', if: :reblog_present?
+  attribute :expires_at, key: :expires, if: :expires_at_present?
+
   attribute :content
   attribute :content_map, if: :language?
 
   attribute :direct_message, if: :non_public?
+  attribute :server_metadata
 
   has_many :media_attachments, key: :attachment
   has_many :virtual_tags, key: :tag
@@ -29,14 +38,28 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 
   def id
     raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only]
+    raise Mastodon::NotPermittedError, 'Unpublished statuses should not be serialized' unless object.published? || instance_options[:allow_local_only]
+
     ActivityPub::TagManager.instance.uri_for(object)
   end
 
   def type
-    object.preloadable_poll ? 'Question' : 'Note'
+    if object.preloadable_poll
+      'Question'
+    elsif title_present?
+      'Article'
+    else
+      'Note'
+    end
+  end
+
+  def root
+    object.conversation&.root
   end
 
   def summary
+    return Formatter.instance.format(object, plaintext: true) || Setting.outgoing_spoilers.presence if title_present?
+
     object.spoiler_text.presence || (instance_options[:allow_local_only] ? nil : Setting.outgoing_spoilers.presence)
   end
 
@@ -49,11 +72,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def content
-    Formatter.instance.format(object)
+    Formatter.instance.format(object, article_content: true)
   end
 
   def content_map
-    { object.language => Formatter.instance.format(object) }
+    { object.language => Formatter.instance.format(object, article_content: true) }
   end
 
   def replies
@@ -90,6 +113,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     object.created_at.iso8601
   end
 
+  def updated
+    object.updated_at.iso8601
+  end
+
   def url
     ActivityPub::TagManager.instance.url_for(object)
   end
@@ -99,11 +126,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def to
-    ActivityPub::TagManager.instance.to(object)
+    ActivityPub::TagManager.instance.to(object, instance_options[:domain])
   end
 
   def cc
-    ActivityPub::TagManager.instance.cc(object)
+    ActivityPub::TagManager.instance.cc(object, instance_options[:domain])
   end
 
   def sensitive
@@ -174,6 +201,32 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     object.preloadable_poll&.voters_count
   end
 
+  def title_present?
+    return @has_title if defined?(@has_title)
+
+    @has_title = object.title.present?
+  end
+
+  def server_metadata
+    Mastodon::Version.server_metadata_json
+  end
+
+  def reblog
+    ActivityPub::TagManager.instance.uri_for(object.reblog)
+  end
+
+  def renote
+    ActivityPub::TagManager.instance.uri_for(object.reblog)
+  end
+
+  def reblog_present?
+    object.reblog_of_id.present?
+  end
+
+  def expires_at_present?
+    object.expires_at.present?
+  end
+
   class MediaAttachmentSerializer < ActivityPub::Serializer
     context_extensions :blurhash, :focal_point
 
diff --git a/app/serializers/activitypub/outbox_serializer.rb b/app/serializers/activitypub/outbox_serializer.rb
index 4f4f950a5..b59dd643b 100644
--- a/app/serializers/activitypub/outbox_serializer.rb
+++ b/app/serializers/activitypub/outbox_serializer.rb
@@ -10,6 +10,6 @@ class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer
   end
 
   def items
-    object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status) }
+    object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status, instance_options[:domain], embed: true) }
   end
 end
diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb
index a925efc18..535cda20a 100644
--- a/app/serializers/activitypub/undo_announce_serializer.rb
+++ b/app/serializers/activitypub/undo_announce_serializer.rb
@@ -22,6 +22,6 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
   end
 
   def virtual_object
-    ActivityPub::ActivityPresenter.from_status(object)
+    ActivityPub::ActivityPresenter.from_status(object, instance_options[:domain])
   end
 end
diff --git a/app/serializers/activitypub/update_poll_serializer.rb b/app/serializers/activitypub/update_poll_serializer.rb
index 1d47b9764..a3ac028ec 100644
--- a/app/serializers/activitypub/update_poll_serializer.rb
+++ b/app/serializers/activitypub/update_poll_serializer.rb
@@ -18,10 +18,10 @@ class ActivityPub::UpdatePollSerializer < ActivityPub::Serializer
   end
 
   def to
-    ActivityPub::TagManager.instance.to(object)
+    ActivityPub::TagManager.instance.to(object, instance_options[:domain])
   end
 
   def cc
-    ActivityPub::TagManager.instance.cc(object)
+    ActivityPub::TagManager.instance.cc(object, instance_options[:domain])
   end
 end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 470cec8a1..654f2f3dd 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -57,6 +57,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       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
+      store[:web_push]          = object.current_account.user.setting_web_push
     else
       store[:auto_play_gif] = Setting.auto_play_gif
       store[:display_media] = Setting.display_media
diff --git a/app/serializers/nodeinfo/serializer.rb b/app/serializers/nodeinfo/serializer.rb
index 7ff8aabec..2bd2c772f 100644
--- a/app/serializers/nodeinfo/serializer.rb
+++ b/app/serializers/nodeinfo/serializer.rb
@@ -3,7 +3,7 @@
 class NodeInfo::Serializer < ActiveModel::Serializer
   include RoutingHelper
 
-  attributes :version, :software, :protocols, :usage, :open_registrations
+  attributes :version, :software, :protocols, :usage, :open_registrations, :metadata
 
   def version
     '2.0'
@@ -37,9 +37,26 @@ class NodeInfo::Serializer < ActiveModel::Serializer
     Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
   end
 
+  def metadata
+    {
+      domain_allows: display_allows? ? DomainAllow.where(hidden: false).map { |a| a.slice(:domain) } : [],
+      domain_blocks: display_blocks? ? DomainBlock.all.map { |b| b.slice(:domain, :severity, :reject_media, :reject_reports, :public_comment) } : [],
+    }
+  end
+
   private
 
   def instance_presenter
     @instance_presenter ||= InstancePresenter.new
   end
+
+  # Monsterfork additions
+
+  def display_allows?
+    Setting.show_domain_allows == 'all' || (Setting.show_domain_allows == 'users' && user_signed_in?)
+  end
+
+  def display_blocks?
+    Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?)
+  end
 end
diff --git a/app/serializers/rest/account_domain_permission_serializer.rb b/app/serializers/rest/account_domain_permission_serializer.rb
new file mode 100644
index 000000000..8bfbe1473
--- /dev/null
+++ b/app/serializers/rest/account_domain_permission_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class REST::AccountDomainPermissionSerializer < ActiveModel::Serializer
+  attributes :id, :domain, :visibility
+
+  def id
+    object.id.to_s
+  end
+end
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 5cc42c7cf..1b780da90 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -7,6 +7,8 @@ class REST::AccountSerializer < ActiveModel::Serializer
              :note, :url, :avatar, :avatar_static, :header, :header_static,
              :followers_count, :following_count, :statuses_count, :last_status_at
 
+  attributes :show_replies, :show_unlisted
+
   has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
 
   has_many :emojis, serializer: REST::CustomEmojiSerializer
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index 54e7c450c..f20d9ef2b 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -5,7 +5,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer
 
   attributes :uri, :title, :short_description, :description, :email,
              :version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits,
-             :languages, :registrations, :approval_required, :invites_enabled
+             :languages, :registrations, :approval_required, :invites_enabled,
+             :federation
 
   has_one :contact_account, serializer: REST::AccountSerializer
 
@@ -80,9 +81,26 @@ class REST::InstanceSerializer < ActiveModel::Serializer
     Setting.min_invite_role == 'user'
   end
 
+  def federation
+    {
+      domain_allows: display_allows? ? DomainAllow.where(hidden: false).map { |a| a.slice(:domain) } : [],
+      domain_blocks: display_blocks? ? DomainBlock.all.map { |b| b.slice(:domain, :severity, :reject_media, :reject_reports, :public_comment) } : [],
+    }
+  end
+
   private
 
   def instance_presenter
     @instance_presenter ||= InstancePresenter.new
   end
+
+  # Monsterfork additions
+
+  def display_allows?
+    Setting.show_domain_allows == 'all' || (Setting.show_domain_allows == 'users' && user_signed_in?)
+  end
+
+  def display_blocks?
+    Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?)
+  end
 end
diff --git a/app/serializers/rest/list_serializer.rb b/app/serializers/rest/list_serializer.rb
index 3e87f7119..45c8dca67 100644
--- a/app/serializers/rest/list_serializer.rb
+++ b/app/serializers/rest/list_serializer.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class REST::ListSerializer < ActiveModel::Serializer
-  attributes :id, :title, :replies_policy
+  attributes :id, :title, :replies_policy, :reblogs
 
   def id
     object.id.to_s
diff --git a/app/serializers/rest/mute_serializer.rb b/app/serializers/rest/mute_serializer.rb
index 043a2f059..db33f8574 100644
--- a/app/serializers/rest/mute_serializer.rb
+++ b/app/serializers/rest/mute_serializer.rb
@@ -2,8 +2,8 @@
 
 class REST::MuteSerializer < ActiveModel::Serializer
   include RoutingHelper
-  
-  attributes :id, :account, :target_account, :created_at, :hide_notifications
+
+  attributes :id, :account, :target_account, :created_at, :hide_notifications, :timelines_only
 
   def account
     REST::AccountSerializer.new(object.account)
@@ -12,4 +12,4 @@ class REST::MuteSerializer < ActiveModel::Serializer
   def target_account
     REST::AccountSerializer.new(object.target_account)
   end
-end
\ No newline at end of file
+end
diff --git a/app/serializers/rest/preferences_serializer.rb b/app/serializers/rest/preferences_serializer.rb
index 119f0e06d..5220aa034 100644
--- a/app/serializers/rest/preferences_serializer.rb
+++ b/app/serializers/rest/preferences_serializer.rb
@@ -8,6 +8,8 @@ class REST::PreferencesSerializer < ActiveModel::Serializer
   attribute :reading_default_sensitive_media, key: 'reading:expand:media'
   attribute :reading_default_sensitive_text, key: 'reading:expand:spoilers'
 
+  attribute :posting_default_manual_publish, key: 'posting:default:manual_publish'
+
   def posting_default_privacy
     object.user.setting_default_privacy
   end
@@ -27,4 +29,8 @@ class REST::PreferencesSerializer < ActiveModel::Serializer
   def reading_default_sensitive_text
     object.user.setting_expand_spoilers
   end
+
+  def posting_default_manual_publish
+    object.user.setting_manual_publish
+  end
 end
diff --git a/app/serializers/rest/status_domain_permission_serializer.rb b/app/serializers/rest/status_domain_permission_serializer.rb
new file mode 100644
index 000000000..ecdecdd3b
--- /dev/null
+++ b/app/serializers/rest/status_domain_permission_serializer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class REST::StatusDomainPermissionSerializer < ActiveModel::Serializer
+  attributes :id, :domain, :visibility
+  has_one :status
+
+  def id
+    object.id.to_s
+  end
+end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index b5dcf6208..4433713a7 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -6,6 +6,9 @@ class REST::StatusSerializer < ActiveModel::Serializer
              :uri, :url, :replies_count, :reblogs_count,
              :favourites_count
 
+  # Monsterfork additions
+  attributes :updated_at, :edited, :nest_level, :root
+
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
   attribute :muted, if: :current_user?
@@ -13,22 +16,32 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :pinned, if: :pinnable?
   attribute :local_only if :local?
 
-  attribute :content, unless: :source_requested?
+  attribute :content
   attribute :text, if: :source_requested?
   attribute :content_type, if: :source_requested?
 
+  attribute :published if :local?
+  attribute :hidden, if: :current_user?
+  attribute :notify, if: :locally_owned?
+  attribute :title?, key: :article
+  attribute :article_content, if: :title?
+  attribute :publish_at, if: :locally_owned?
+  attribute :expires_at, if: :locally_owned?
+
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application, if: :show_application?
   belongs_to :account, serializer: REST::AccountSerializer
 
   has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
   has_many :ordered_mentions, key: :mentions
-  has_many :tags
+  has_many :ordered_tags, key: :tags
   has_many :emojis, serializer: REST::CustomEmojiSerializer
 
   has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
   has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
 
+  has_many :domain_permissions, serializer: REST::StatusDomainPermissionSerializer, if: :locally_owned?
+
   def id
     object.id.to_s
   end
@@ -45,8 +58,22 @@ class REST::StatusSerializer < ActiveModel::Serializer
     !current_user.nil?
   end
 
+  def owned?
+    current_user? && current_user.account_id == object.account_id
+  end
+
+  def locally_owned?
+    object.local? && owned?
+  end
+
+  def title?
+    return @has_title if defined?(@has_title)
+
+    @has_title = object.title.present?
+  end
+
   def show_application?
-    object.account.user_shows_application? || (current_user? && current_user.account_id == object.account_id)
+    object.account.user_shows_application? || owned?
   end
 
   def visibility
@@ -72,14 +99,30 @@ class REST::StatusSerializer < ActiveModel::Serializer
     ActivityPub::TagManager.instance.uri_for(object)
   end
 
+  def spoiler_text
+    title? ? object.title : object.spoiler_text
+  end
+
   def content
     Formatter.instance.format(object)
   end
 
+  def article_content
+    Formatter.instance.format(object, article_content: true)
+  end
+
+  def text
+    object.original_text.presence || object.text
+  end
+
   def url
     ActivityPub::TagManager.instance.url_for(object)
   end
 
+  def root
+    object.conversation&.root
+  end
+
   def favourited
     if instance_options && instance_options[:relationships]
       instance_options[:relationships].favourites_map[object.id] || false
@@ -104,6 +147,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
     end
   end
 
+  def hidden
+    if instance_options && instance_options[:relationships]
+      instance_options[:relationships].hidden_statuses_map[object.id] || false
+    else
+      current_user.account.muting_status?(object)
+    end
+  end
+
   def bookmarked
     if instance_options && instance_options[:relationships]
       instance_options[:relationships].bookmarks_map[object.id] || false
@@ -135,6 +186,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
     object.active_mentions.to_a.sort_by(&:id)
   end
 
+  def ordered_tags
+    object.tags.order('name')
+  end
+
   class ApplicationSerializer < ActiveModel::Serializer
     attributes :name, :website
   end
diff --git a/app/services/activitypub/fetch_collection_items_service.rb b/app/services/activitypub/fetch_collection_items_service.rb
new file mode 100644
index 000000000..ef54321de
--- /dev/null
+++ b/app/services/activitypub/fetch_collection_items_service.rb
@@ -0,0 +1,167 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchCollectionItemsService < BaseService
+  include JsonLdHelper
+
+  COOLDOWN = 30.minutes
+
+  # Fetches objects in a collection from a URI or hash and queues them for processing.
+  # @param collection [Hash, String] Collection hash or URI
+  # @param account [Account] Owner of the collection
+  # @param page_limit [Integer] (10) Maximum number of pages to fetch from the collection.
+  # @param item_limit [Integer] (100) Maximum number of items to fetch from the collection.
+  # @option options [Boolean] :every_page (false) Whether to fetch every page in the collection,
+  #   even if its items have been previously fetched.  By default, fetching will stop if all the
+  #   items on any page have already been fetched.
+  # @option options [Boolean] :look_ahead (false) Whether to check the next page for unfetched
+  #   items if the current page's items have been previously fetched.  If there are unfetched
+  #   items on the next page, fetching will continue.
+  # @option options [Boolean] :skip_cooldown (false) Skip the fetch cooldown period on the a
+  #   collection URI (e.g., for account migration).
+  # @option options [Boolean] :include_boosts (false) Whether to skip boosts.  Including these
+  #   will cause a LOT of server traffic.
+  # @return [void]
+  # @raise [Mastodon::RaceConditionError] Collection is already being fetched.
+  # @raise [Mastodon::UnexpectedResponseError] Server returned an error while fetching a page.
+  def call(collection, account, page_limit: 10, item_limit: 100, **options)
+    uri = value_or_id(collection)
+    return if uri.blank? || ActivityPub::TagManager.instance.local_uri?(uri)
+
+    uri = collection['partOf'] if collection.is_a?(Hash) && collection['partOf'].present?
+
+    @account = account
+    @account = account_from_uri(uri) if @account.blank?
+    set_fetch_account
+
+    return if !options[:skip_cooldown] && Redis.current.get("fetch_collection_cooldown:#{uri}")
+
+    collection = fetch_collection(collection)
+    return if collection.blank?
+
+    if @account.blank?
+      @account = account_from_uri(collection['partOf'].presence || collection['id'])
+      set_fetch_account
+    end
+
+    fetch_collection_pages(collection, page_limit, item_limit, **options)
+  end
+
+  private
+
+  def lock_options(uri)
+    { redis: Redis.current, key: "fetch_collection:#{uri}" }
+  end
+
+  def set_fetch_account
+    @on_behalf_of = @account.present? ? @account.followers.local.random.first : nil
+  end
+
+  def account_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+  end
+
+  def account_id_from_uri(uri)
+    return if uri.blank?
+
+    Rails.cache.fetch("account_id_from_uri:#{uri}", expires_in: 10.minutes) do
+      account_from_uri(uri)&.id
+    end
+  end
+
+  def valid_item?(item)
+    item.is_a?(Hash) &&
+      !invalid_uri?(item['id']) &&
+      (item['attributedTo'].present? || item['actor'].present?) && (
+        item['object'].blank? || item['type'] == 'Create' && !invalid_uri?(value_or_id(item['object']))
+      )
+  end
+
+  def uri_with_account_id(item)
+    object = item['object'].presence || item
+    [value_or_id(object), object.is_a?(Hash) ? account_id_from_uri(object['attributedTo']) : account_id_from_uri(item['actor'])]
+  end
+
+  def invalid_uri?(uri)
+    unsupported_uri_scheme?(uri) || !uri_allowed?(uri) || ActivityPub::TagManager.instance.local_uri?(uri)
+  end
+
+  def fetch_collection(collection_or_uri)
+    return (collection_or_uri['id'].present? ? collection_or_uri : nil) if collection_or_uri.is_a?(Hash)
+    return if !collection_or_uri.is_a?(String) || invalid_origin?(collection_or_uri)
+
+    fetch_resource_without_id_validation(collection_or_uri, @on_behalf_of, true)
+  end
+
+  def fetch_collection_pages(collection, page_limit, item_limit, **options)
+    uri = collection['partOf'].presence || collection['id']
+    cooldown_key = "fetch_collection_cooldown:#{uri}"
+
+    return if !options[:skip_cooldown] && Redis.current.get(cooldown_key)
+
+    Redis.current.set(cooldown_key, 1, ex: COOLDOWN)
+
+    RedisLock.acquire(lock_options(uri)) do |lock|
+      raise Mastodon::RaceConditionError unless lock.acquired?
+
+      page = CollectionPage.find_or_create_by(uri: uri, account: @account)
+      every_page = options[:every_page]
+
+      if page.next.present?
+        collection = fetch_collection(page.next)
+        fetch_collection_items(collection, page, page_limit, item_limit, **options)
+        every_page = false
+      end
+
+      uri = collection['first'].presence || collection['id']
+      page.update!(next: uri)
+      collection = fetch_collection(uri) if collection['id'] != uri
+      fetch_collection_items(collection, page, page_limit, item_limit, **options.merge({ every_page: every_page }))
+    end
+  end
+
+  def fetch_collection_items(collection, page, page_limit, item_limit, **options)
+    page_count = 0
+    item_count = 0
+    seen_pages = Set[page.next]
+    have_items = false
+
+    while collection.present? && collection['type'].present?
+      batch = case collection['type']
+              when 'Collection', 'CollectionPage'
+                collection['items']
+              when 'OrderedCollection', 'OrderedCollectionPage'
+                collection['orderedItems']
+              end
+
+      break unless batch.is_a?(Array)
+
+      batch_size = [batch.count, item_limit - item_count].min
+      batch = batch.take(batch_size).select { |item| valid_item?(item) }.map { |item| uri_with_account_id(item) }
+      result = CollectionItem.import([:uri, :account_id], batch, validate: false, on_duplicate_key_ignore: true)
+
+      if !options[:every_page] && result.ids.blank?
+        break if have_items || !options[:look_ahead]
+
+        have_items = true
+      elsif have_items
+        have_items = false
+      end
+
+      item_count += result.ids.count
+      page_count += 1
+
+      next_page = collection['next']
+      break unless item_count < item_limit && page_count < page_limit && next_page.present?
+      break if seen_pages.include?(next_page)
+
+      sleep [page_count.to_f / 5, 1].min
+
+      seen_pages << next_page
+      page.update!(next: next_page)
+      collection = fetch_collection(next_page)
+    end
+
+    page.delete
+    ActivityPub::ProcessCollectionItemsWorker.perform_async
+  end
+end
diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb
index 2c2770466..0a20f5edc 100644
--- a/app/services/activitypub/fetch_featured_collection_service.rb
+++ b/app/services/activitypub/fetch_featured_collection_service.rb
@@ -22,9 +22,10 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
   private
 
   def process_items(items)
+    first_local_follower = @account.followers.local.random.first
     status_ids = items.map { |item| value_or_id(item) }
                       .reject { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }
-                      .map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri) }
+                      .map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: first_local_follower) }
                       .compact
                       .select { |status| status.account_id == @account.id }
                       .map(&:id)
@@ -43,7 +44,7 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
     StatusPin.where(account: @account, status_id: to_remove).delete_all unless to_remove.empty?
 
     to_add.each do |status_id|
-      StatusPin.create!(account: @account, status_id: status_id)
+      StatusPin.create(account: @account, status_id: status_id)
     end
   end
 
diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb
index 8cb309e52..e36ca9f39 100644
--- a/app/services/activitypub/fetch_replies_service.rb
+++ b/app/services/activitypub/fetch_replies_service.rb
@@ -1,49 +1,27 @@
 # frozen_string_literal: true
 
 class ActivityPub::FetchRepliesService < BaseService
-  include JsonLdHelper
-
-  def call(parent_status, collection_or_uri, allow_synchronous_requests = true)
+  def call(parent_status, collection, **options)
     @account = parent_status.account
-    @allow_synchronous_requests = allow_synchronous_requests
-
-    @items = collection_items(collection_or_uri)
-    return if @items.nil?
-
-    FetchReplyWorker.push_bulk(filtered_replies)
-
-    @items
+    fetch_collection_items(collection, **options)
+  rescue ActiveRecord::RecordNotFound
+    nil
   end
 
   private
 
-  def collection_items(collection_or_uri)
-    collection = fetch_collection(collection_or_uri)
-    return unless collection.is_a?(Hash)
-
-    collection = fetch_collection(collection['first']) if collection['first'].present?
-    return unless collection.is_a?(Hash)
-
-    case collection['type']
-    when 'Collection', 'CollectionPage'
-      collection['items']
-    when 'OrderedCollection', 'OrderedCollectionPage'
-      collection['orderedItems']
-    end
-  end
-
-  def fetch_collection(collection_or_uri)
-    return collection_or_uri if collection_or_uri.is_a?(Hash)
-    return unless @allow_synchronous_requests
-    return if invalid_origin?(collection_or_uri)
-    fetch_resource_without_id_validation(collection_or_uri, nil, true)
-  end
-
-  def filtered_replies
-    # Only fetch replies to the same server as the original status to avoid
-    # amplification attacks.
-
-    # Also limit to 5 fetched replies to limit potential for DoS.
-    @items.map { |item| value_or_id(item) }.reject { |uri| invalid_origin?(uri) }.take(5)
+  def fetch_collection_items(collection, **options)
+    ActivityPub::FetchCollectionItemsService.new.call(
+      collection,
+      @account,
+      page_limit: 1,
+      item_limit: 20,
+      **options
+    )
+  rescue Mastodon::RaceConditionError, Mastodon::UnexpectedResponseError
+    collection_uri = collection.is_a?(Hash) ? collection['id'] : collection
+    return unless collection_uri.present? && collection_uri.is_a?(String)
+
+    ActivityPub::FetchRepliesWorker.perform_async(@account.id, collection_uri)
   end
 end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 4cb8e09db..049ceae83 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -36,13 +36,14 @@ class ActivityPub::ProcessAccountService < BaseService
     return if @account.nil?
 
     after_protocol_change! if protocol_changed?
-    after_key_change! if key_changed? && !@options[:signed_with_known_key]
     clear_tombstones! if key_changed?
     after_suspension_change! if suspension_changed?
+    return after_key_change! if key_changed? && !@options[:signed_with_known_key]
 
     unless @options[:only_key] || @account.suspended?
       check_featured_collection! if @account.featured_collection_url.present?
       check_links! unless @account.fields.empty?
+      process_sync
     end
 
     @account
@@ -91,6 +92,9 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.also_known_as           = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
     @account.actor_type              = actor_type
     @account.discoverable            = @json['discoverable'] || false
+    @account.show_replies            = @json['showReplies'] || true
+    @account.show_unlisted           = @json['showUnlisted'] || true
+    @account.private                 = @json['private'] || false
   end
 
   def set_fetchable_attributes!
@@ -121,7 +125,8 @@ class ActivityPub::ProcessAccountService < BaseService
   end
 
   def after_key_change!
-    RefollowWorker.perform_async(@account.id)
+    ResetAccountWorker.perform_async(@account.id)
+    nil
   end
 
   def after_suspension_change!
@@ -317,4 +322,8 @@ class ActivityPub::ProcessAccountService < BaseService
 
     @account.identity_proofs.where(provider: provider, provider_username: provider_username).find_or_create_by(provider: provider, provider_username: provider_username, token: token)
   end
+
+  def process_sync
+    ActivityPub::SyncAccountWorker.perform_async(@account.id)
+  end
 end
diff --git a/app/services/activitypub/process_collection_items_service.rb b/app/services/activitypub/process_collection_items_service.rb
new file mode 100644
index 000000000..9c30d81e9
--- /dev/null
+++ b/app/services/activitypub/process_collection_items_service.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessCollectionItemsService < BaseService
+  def call(account_id, on_behalf_of)
+    RedisLock.acquire(lock_options(account_id)) do |lock|
+      if lock.acquired?
+        CollectionItem.unprocessed.where(account_id: account_id).find_each do |item|
+          # Avoid failing servers holding up the rest of the queue.
+          next if item.retries.positive? && rand(3).positive?
+
+          begin
+            FetchRemoteStatusService.new.call(item.uri, nil, on_behalf_of)
+          rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound
+            nil
+          rescue HTTP::TimeoutError
+            item.increment!(:retries)
+          end
+
+          item.update!(processed: true) if item.retries.zero? || item.retries > 4
+        end
+      end
+    end
+  end
+
+  private
+
+  def lock_options(account_id)
+    { redis: Redis.current, key: "process_collection_items:#{account_id}" }
+  end
+end
diff --git a/app/services/after_block_domain_from_account_service.rb b/app/services/after_block_domain_from_account_service.rb
index 89d007c1c..58bd69bdd 100644
--- a/app/services/after_block_domain_from_account_service.rb
+++ b/app/services/after_block_domain_from_account_service.rb
@@ -11,6 +11,7 @@ class AfterBlockDomainFromAccountService < BaseService
     @domain  = domain
 
     clear_notifications!
+    defederate_from_domain!
     remove_follows!
     reject_existing_followers!
     reject_pending_follow_requests!
@@ -18,6 +19,10 @@ class AfterBlockDomainFromAccountService < BaseService
 
   private
 
+  def defederate_from_domain!
+    DefederateAccountService.new.call(@account, @domain)
+  end
+
   def remove_follows!
     @account.active_relationships.where(target_account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).find_each do |follow|
       UnfollowService.new.call(@account, follow.target_account)
diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb
index 314919df8..36f891988 100644
--- a/app/services/after_block_service.rb
+++ b/app/services/after_block_service.rb
@@ -1,13 +1,18 @@
 # frozen_string_literal: true
 
 class AfterBlockService < BaseService
-  def call(account, target_account)
+  def call(account, target_account, defederate: true)
     @account        = account
     @target_account = target_account
 
     clear_home_feed!
     clear_notifications!
     clear_conversations!
+
+    return unless defederate
+
+    defederate_interactions!
+    unlink_interactions!
   end
 
   private
@@ -23,4 +28,27 @@ class AfterBlockService < BaseService
   def clear_notifications!
     Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all
   end
+
+  def unlink_interactions!
+    @target_account.statuses.where(in_reply_to_account_id: @account.id).in_batches.update_all(in_reply_to_account_id: nil)
+    @target_account.mentions.where(account_id: @account.id).in_batches.destroy_all
+  end
+
+  def defederate_interactions!
+    defederate_statuses!(@account.statuses.where(in_reply_to_account_id: @target_account.id))
+    defederate_statuses!(@account.statuses.joins(:mentions).where(mentions: { account_id: @target_account.id }))
+    defederate_statuses!(@account.statuses.joins(:reblog).where(reblogs_statuses: { account_id: @target_account.id }))
+    defederate_favourites!
+  end
+
+  def defederate_statuses!(statuses)
+    statuses.find_each { |status| RemovalWorker.perform_async(status.id, unpublish: true, blocking: @target_account.id) }
+  end
+
+  def defederate_favourites!
+    favourites = @account.favourites.joins(:status).where(statuses: { account_id: @target_account.id })
+    favourites.pluck(:status_id).each do |status_id|
+      UnfavouriteWorker.perform_async(@account.id, status_id)
+    end
+  end
 end
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
index 749c84736..e4d4c5a89 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, allow_local_only: true)
+        item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status, nil, embed: true), ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true)
         item.delete(:'@context')
 
         unless item[:type] == 'Announce' || item[:object][:attachment].blank?
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 707672ee0..ef68dc5bc 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -73,18 +73,12 @@ class BatchedRemoveStatusService < BaseService
 
     redis.pipelined do
       redis.publish('timeline:public', payload)
-      if status.local?
-        redis.publish('timeline:public:local', payload)
-      else
-        redis.publish('timeline:public:remote', payload)
-      end
+      redis.publish('timeline:public:local', payload) if status.local?
+      redis.publish('timeline:public:remote', payload)
       if status.media_attachments.any?
         redis.publish('timeline:public:media', payload)
-        if status.local?
-          redis.publish('timeline:public:local:media', payload)
-        else
-          redis.publish('timeline:public:remote:media', payload)
-        end
+        redis.publish('timeline:public:local:media', payload) if status.local?
+        redis.publish('timeline:public:remote:media', payload)
       end
 
       @tags[status.id].each do |hashtag|
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index 76cc36ff6..6bfc4d10e 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -23,6 +23,7 @@ class BlockDomainService < BaseService
     if domain_block.silence?
       silence_accounts!
     elsif domain_block.suspend?
+      DefederateDomainService.new.call(domain_block.domain)
       suspend_accounts!
     end
 
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 266a0f4b9..0b8ecd3e0 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -3,16 +3,16 @@
 class BlockService < BaseService
   include Payloadable
 
-  def call(account, target_account)
+  def call(account, target_account, softblock: false)
     return if account.id == target_account.id
 
-    UnfollowService.new.call(account, target_account) if account.following?(target_account)
-    UnfollowService.new.call(target_account, account) if target_account.following?(account)
+    UnfollowService.new.call(account, target_account, force: softblock) if softblock || account.following?(target_account)
+    UnfollowService.new.call(target_account, account, force: softblock) if softblock || target_account.following?(account)
     RejectFollowService.new.call(target_account, account) if target_account.requested?(account)
 
     block = account.block!(target_account)
 
-    BlockWorker.perform_async(account.id, target_account.id)
+    BlockWorker.perform_async(account.id, target_account.id) unless softblock
     create_notification(block) if !target_account.local? && target_account.activitypub?
     block
   end
diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb
index 3e45570c3..1492f8076 100644
--- a/app/services/concerns/payloadable.rb
+++ b/app/services/concerns/payloadable.rb
@@ -7,14 +7,10 @@ module Payloadable
     payload   = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json
     object    = record.respond_to?(:virtual_object) ? record.virtual_object : record
 
-    if (object.respond_to?(:sign?) && object.sign?) && signer && signing_enabled?
+    if (object.respond_to?(:sign?) && object.sign?) && signer && !signer.allow_anonymous?
       ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with)
     else
       payload
     end
   end
-
-  def signing_enabled?
-    ENV['AUTHORIZED_FETCH'] != 'true' && !Rails.configuration.x.whitelist_mode
-  end
 end
diff --git a/app/services/defederate_account_serivice.rb b/app/services/defederate_account_serivice.rb
new file mode 100644
index 000000000..5d9cc1597
--- /dev/null
+++ b/app/services/defederate_account_serivice.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class DefederateAccountService < BaseService
+  include Payloadable
+
+  def call(account, domains)
+    @account = account
+    @domains = domains
+
+    return if account.blank? || !account.local? || domains.blank?
+
+    distribute_delete_actor!
+  end
+
+  private
+
+  def distribute_delete_actor!
+    ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
+      [delete_actor_json, @account.id, inbox_url]
+    end
+
+    ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
+      [delete_actor_json, @account.id, inbox_url]
+    end
+  end
+
+  def delete_actor_json
+    @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
+  end
+
+  def delivery_inboxes
+    @delivery_inboxes ||= @account.followers.where(domain: @domains).inboxes
+  end
+
+  def low_priority_delivery_inboxes
+    Account.where(domain: @domains).inboxes - delivery_inboxes
+  end
+end
diff --git a/app/services/defederate_domain_service.rb b/app/services/defederate_domain_service.rb
new file mode 100644
index 000000000..d40f88e3f
--- /dev/null
+++ b/app/services/defederate_domain_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class DefederateDomainService < BaseService
+  def call(domains)
+    return if domains.blank?
+
+    Account.local.find_each do |account|
+      DefederateAccountService.new.call(account, domains)
+    end
+  end
+end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 6fa98ce12..679ba8501 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -3,10 +3,11 @@
 class FanOutOnWriteService < BaseService
   # Push a status into home and mentions feeds
   # @param [Status] status
-  def call(status)
+  def call(status, only_to_self: false)
     raise Mastodon::RaceConditionError if status.visibility.nil?
 
     deliver_to_self(status) if status.account.local?
+    return if only_to_self || !status.published?
 
     if status.direct_visibility?
       deliver_to_mentioned_followers(status)
@@ -14,16 +15,16 @@ class FanOutOnWriteService < BaseService
       deliver_to_own_conversation(status)
     elsif status.limited_visibility?
       deliver_to_mentioned_followers(status)
+      deliver_to_lists(status)
     else
       deliver_to_followers(status)
       deliver_to_lists(status)
     end
 
-    return if status.account.silenced? || !status.public_visibility?
+    return if !status.public_visibility? || status.account.silenced?
     return if status.reblog? && !Setting.show_reblogs_in_public_timelines
 
     render_anonymous_payload(status)
-
     deliver_to_hashtags(status)
 
     return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines
@@ -87,29 +88,23 @@ class FanOutOnWriteService < BaseService
   def deliver_to_public(status)
     Rails.logger.debug "Delivering status #{status.id} to public timeline"
 
-    Redis.current.publish('timeline:public', @payload)
-    if status.local?
-      Redis.current.publish('timeline:public:local', @payload)
-    else
-      Redis.current.publish('timeline:public:remote', @payload)
-    end
+    Redis.current.publish('timeline:public', @payload) if status.curated?
+    Redis.current.publish('timeline:public:local', @payload) if status.local?
+    Redis.current.publish('timeline:public:remote', @payload)
   end
 
   def deliver_to_media(status)
     Rails.logger.debug "Delivering status #{status.id} to media timeline"
 
-    Redis.current.publish('timeline:public:media', @payload)
-    if status.local?
-      Redis.current.publish('timeline:public:local:media', @payload)
-    else
-      Redis.current.publish('timeline:public:remote:media', @payload)
-    end
+    Redis.current.publish('timeline:public:media', @payload) if status.curated?
+    Redis.current.publish('timeline:public:local:media', @payload) if status.local?
+    Redis.current.publish('timeline:public:remote:media', @payload)
   end
 
   def deliver_to_direct_timelines(status)
     Rails.logger.debug "Delivering status #{status.id} to direct timelines"
 
-    FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? }) do |account|
+    FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select(&:local?)) do |account|
       [status.id, account.id, :direct]
     end
   end
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index eafde4d4a..4f98b51f6 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -1,14 +1,20 @@
 # frozen_string_literal: true
 
 class FetchRemoteStatusService < BaseService
-  def call(url, prefetched_body = nil)
+  def call(url, prefetched_body = nil, on_behalf_of = nil)
+    status = ActivityPub::TagManager.instance.uri_to_resource(url, Status)
+    return status if status.present?
+
     if prefetched_body.nil?
-      resource_url, resource_options = FetchResourceService.new.call(url)
+      resource_url, resource_options = FetchResourceService.new.call(url, on_behalf_of: on_behalf_of)
     else
       resource_url     = url
       resource_options = { prefetched_body: prefetched_body }
     end
 
-    ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options) unless resource_url.nil?
+    return if resource_url.blank?
+
+    resource_options ||= {}
+    ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options.merge({ on_behalf_of: on_behalf_of }))
   end
 end
diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb
index 6c0093cd4..17e8024de 100644
--- a/app/services/fetch_resource_service.rb
+++ b/app/services/fetch_resource_service.rb
@@ -7,9 +7,11 @@ class FetchResourceService < BaseService
 
   attr_reader :response_code
 
-  def call(url)
+  def call(url, on_behalf_of: nil)
     return if url.blank?
 
+    @on_behalf_of = on_behalf_of || Account.representative
+
     process(url)
   rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
     Rails.logger.debug "Error fetching resource #{@url}: #{e}"
@@ -18,8 +20,9 @@ class FetchResourceService < BaseService
 
   private
 
-  def process(url, terminal = false)
+  def process(url, terminal = false, retry_as_server = false)
     @url = url
+    @retry_as_server ||= retry_as_server
 
     perform_request { |response| process_response(response, terminal) }
   end
@@ -35,13 +38,14 @@ class FetchResourceService < BaseService
       # and prevents even public resources from being fetched, so
       # don't do it
 
-      request.on_behalf_of(Account.representative) unless Rails.env.development?
+      request.on_behalf_of(@retry_as_server ? Account.representative : @on_behalf_of) unless Rails.env.development?
     end.perform(&block)
   end
 
   def process_response(response, terminal = false)
     @response_code = response.code
-    return nil if response.code != 200
+    skip_retry = @retry_as_server || Rails.env.development? || @on_behalf_of.id == -99
+    return (skip_retry ? nil : process(response.uri, terminal, true)) if response.code != 200
 
     if ['application/activity+json', 'application/ld+json'].include?(response.mime_type)
       body = response.body_with_limit
@@ -67,13 +71,13 @@ class FetchResourceService < BaseService
     page      = Nokogiri::HTML(response.body_with_limit)
     json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
 
-    process(json_link['href'], terminal: true) unless json_link.nil?
+    process(json_link['href'], true) unless json_link.nil?
   end
 
   def process_link_headers(link_header)
     json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
 
-    process(json_link.href, terminal: true) unless json_link.nil?
+    process(json_link.href, true) unless json_link.nil?
   end
 
   def parse_link_header(response)
diff --git a/app/services/keys/query_service.rb b/app/services/keys/query_service.rb
index 286fbd834..f48dafb61 100644
--- a/app/services/keys/query_service.rb
+++ b/app/services/keys/query_service.rb
@@ -63,7 +63,7 @@ class Keys::QueryService < BaseService
 
     json = fetch_resource(@account.devices_url)
 
-    return if json['items'].blank?
+    return if json.blank? || json['items'].blank?
 
     @devices = json['items'].map do |device|
       Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim'])
diff --git a/app/services/mute_conversation_service.rb b/app/services/mute_conversation_service.rb
new file mode 100644
index 000000000..a12bf9533
--- /dev/null
+++ b/app/services/mute_conversation_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class MuteConversationService < BaseService
+  def call(account, conversation)
+    return if account.blank? || conversation.blank?
+
+    account.mute_conversation!(conversation)
+    MuteConversationWorker.perform_async(account.id, conversation.id)
+  end
+end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 9ae9afd62..5e7e1d292 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -1,13 +1,13 @@
 # frozen_string_literal: true
 
 class MuteService < BaseService
-  def call(account, target_account, notifications: nil, duration: 0)
+  def call(account, target_account, notifications: nil, timelines_only: nil, duration: 0)
     return if account.id == target_account.id
 
-    mute = account.mute!(target_account, notifications: notifications, duration: duration)
+    mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only, duration: duration)
 
     if mute.hide_notifications?
-      BlockWorker.perform_async(account.id, target_account.id)
+      BlockWorker.perform_async(account.id, target_account.id, defederate: false)
     else
       MuteWorker.perform_async(account.id, target_account.id)
     end
diff --git a/app/services/mute_status_service.rb b/app/services/mute_status_service.rb
new file mode 100644
index 000000000..bdf99232c
--- /dev/null
+++ b/app/services/mute_status_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class MuteStatusService < BaseService
+  def call(account, status)
+    return if account.blank? || status.blank?
+
+    account.mute_status!(status)
+    FeedManager.instance.unpush_status(account, status)
+  end
+end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index fc187db40..c241c3ca0 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -51,6 +51,11 @@ class NotifyService < BaseService
     @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
   end
 
+  def following_recipient?
+    return @following_recipient if defined?(@following_recipient)
+    @following_recipient = @notification.from_account.following?(@recipient)
+  end
+
   def optional_non_follower?
     @recipient.user.settings.interactions['must_be_follower']  && !@notification.from_account.following?(@recipient)
   end
@@ -83,7 +88,7 @@ class NotifyService < BaseService
   end
 
   def hellbanned?
-    @notification.from_account.silenced? && !following_sender?
+    @notification.from_account.silenced? && !(following_sender? || following_recipient?)
   end
 
   def from_self?
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 250d0e8ed..216dedeac 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -2,6 +2,7 @@
 
 class PostStatusService < BaseService
   include Redisable
+  include ImgProxyHelper
 
   MIN_SCHEDULE_OFFSET = 5.minutes.freeze
 
@@ -12,7 +13,10 @@ class PostStatusService < BaseService
   # @option [Status] :thread Optional status to reply to
   # @option [Boolean] :sensitive
   # @option [String] :visibility
+  # @option [Boolean] :local_only
   # @option [String] :spoiler_text
+  # @option [String] :title
+  # @option [String] :footer
   # @option [String] :language
   # @option [String] :scheduled_at
   # @option [Hash] :poll Optional poll to attach
@@ -20,12 +24,31 @@ class PostStatusService < BaseService
   # @option [Doorkeeper::Application] :application
   # @option [String] :idempotency Optional idempotency key
   # @option [Boolean] :with_rate_limit
+  # @option [Status] :status Edit an existing status
+  # @option [Enumerable] :mentions Optional array of Mentions to include
+  # @option [Enumerable] :tags Option array of tag names to include
+  # @option [Boolean] :publish If true, status will be published
+  # @option [Boolean] :notify If false, status will not be delivered to local timelines or mentions
+  # @option [String] :expires_at If set, automatically delete at this time (UTC)
+  # @option [String] :publish_at If set, automatically publish at this time (UTC)
   # @return [Status]
   def call(account, options = {})
     @account     = account
     @options     = options
     @text        = @options[:text] || ''
     @in_reply_to = @options[:thread]
+    @expires_at  = @options[:expires_at]&.to_datetime
+    @publish_at  = @options[:publish_at]&.to_datetime
+
+    @expires_at ||= Time.now.utc + @account.user&.setting_unpublish_in.to_i.minutes if @account.user&.setting_unpublish_in.to_i.positive?
+    @publish_at ||= Time.now.utc + @account.user&.setting_publish_in.to_i.minutes if @account.user&.setting_publish_in.to_i.positive?
+
+    @options[:publish] ||= !(account.user&.setting_manual_publish || @publish_at.present?)
+
+    raise Mastodon::NotPermittedError if different_author?
+
+    @tag_names   = (@options[:tags] || []).select { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
+    @mentions    = @options[:mentions] || []
 
     return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
 
@@ -34,10 +57,12 @@ class PostStatusService < BaseService
 
     if scheduled?
       schedule_status!
+    elsif @options[:status].present? && status_exists?
+      update_status!
     else
       process_status!
       postprocess_status!
-      bump_potential_friendship!
+      bump_potential_friendship! if @options[:publish]
     end
 
     redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
@@ -49,14 +74,14 @@ class PostStatusService < BaseService
 
   def preprocess_attributes!
     if @text.blank? && @options[:spoiler_text].present?
-     @text = '.'
-     if @media&.find(&:video?) || @media&.find(&:gifv?)
-       @text = '📹'
-     elsif @media&.find(&:audio?)
-       @text = '🎵'
-     elsif @media&.find(&:image?)
-       @text = '🖼'
-     end
+      @text = '.'
+      if @media&.find(&:video?) || @media&.find(&:gifv?)
+        @text = '📹'
+      elsif @media&.find(&:audio?)
+        @text = '🎵'
+      elsif @media&.find(&:image?)
+        @text = '🖼'
+      end
     end
     @sensitive    = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
@@ -75,8 +100,11 @@ class PostStatusService < BaseService
       @status = @account.statuses.create!(status_attributes)
     end
 
-    process_hashtags_service.call(@status)
-    process_mentions_service.call(@status)
+    @status.notify = @options[:notify] if @options[:notify].present?
+
+    process_command_tags_service.call(@account, @status)
+    process_hashtags_service.call(@status, nil, @tag_names)
+    process_mentions_service.call(@status, mentions: @mentions, deliver: @options[:publish])
   end
 
   def schedule_status!
@@ -99,16 +127,25 @@ class PostStatusService < BaseService
   def postprocess_status!
     LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
     DistributionWorker.perform_async(@status.id)
+
+    return unless @options[:publish]
+
     ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only?
     PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
   end
 
+  def update_status!
+    tags = Tag.find_or_create_by_names(@tag_names)
+    @status = UpdateStatusService.new.call(@options[:status], status_attributes, @mentions, tags)
+  end
+
   def validate_media!
     return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
 
     raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present?
 
-    @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i))
+    @media = @options[:status].present? ? @account.media_attachments.where(status_id: [nil, @options[:status].id]) : @account.media_attachments.where(status_id: nil)
+    @media = @media.where(id: @options[:media_ids].take(4).map(&:to_i))
 
     raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:audio_or_video?)
     raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if @media.any?(&:not_processed?)
@@ -126,6 +163,10 @@ class PostStatusService < BaseService
     ProcessHashtagsService.new
   end
 
+  def process_command_tags_service
+    ProcessCommandTagsService.new
+  end
+
   def scheduled?
     @scheduled_at.present?
   end
@@ -156,24 +197,33 @@ class PostStatusService < BaseService
 
   def bump_potential_friendship!
     return if !@status.reply? || @account.id == @status.in_reply_to_account_id
+
     ActivityTracker.increment('activity:interactions')
     return if @account.following?(@status.in_reply_to_account_id)
+
     PotentialFriendshipTracker.record(@account.id, @status.in_reply_to_account_id, :reply)
   end
 
   def status_attributes
     {
       text: @text,
+      original_text: @text,
       media_attachments: @media || [],
       thread: @in_reply_to,
       poll_attributes: poll_attributes,
       sensitive: @sensitive,
       spoiler_text: @options[:spoiler_text] || '',
+      title: @options[:title],
+      footer: @options[:footer],
       visibility: @visibility,
+      local_only: @options[:local_only],
       language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
       application: @options[:application],
+      published: @options[:publish],
       content_type: @options[:content_type] || @account.user&.setting_default_content_type,
       rate_limit: @options[:with_rate_limit],
+      expires_at: @expires_at,
+      publish_at: @publish_at,
     }.compact
   end
 
@@ -198,6 +248,16 @@ class PostStatusService < BaseService
       options_hash[:scheduled_at]    = nil
       options_hash[:idempotency]     = nil
       options_hash[:with_rate_limit] = false
+      options_hash[:mention_ids]     = options_hash.delete(:mentions)&.pluck(:id)
+      options_hash[:status_id]       = options_hash.delete(:status)&.id
     end
   end
+
+  def different_author?
+    @options[:status].present? && @options[:status].account_id != @account.id
+  end
+
+  def status_exists?
+    !(@options[:status].discarded? || @options[:status].destroyed?)
+  end
 end
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index b4fa70710..56d9d2f68 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -4,6 +4,7 @@ class PrecomputeFeedService < BaseService
   def call(account)
     FeedManager.instance.populate_home(account)
     FeedManager.instance.populate_direct_feed(account)
+    FeedManager.instance.populate_lists(account)
   ensure
     Redis.current.del("account:#{account.id}:regeneration")
   end
diff --git a/app/services/process_command_tags_service.rb b/app/services/process_command_tags_service.rb
new file mode 100644
index 000000000..6b6d46662
--- /dev/null
+++ b/app/services/process_command_tags_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class ProcessCommandTagsService < BaseService
+  def call(account, status, raise_if_no_output: true)
+    CommandTag::Processor.new(account, status).process!
+    raise Mastodon::LengthValidationError, 'Text commands were processed successfully.' if raise_if_no_output && status.destroyed?
+
+    status
+  end
+end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index e8e139b05..51c152164 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -1,18 +1,22 @@
 # frozen_string_literal: true
 
 class ProcessHashtagsService < BaseService
-  def call(status, tags = [])
-    tags    = Extractor.extract_hashtags(status.text) if status.local?
+  def call(status, tags = nil, extra_tags = [])
+    tags ||= extra_tags | (status.local? ? Extractor.extract_hashtags(status.text) : [])
     records = []
 
+    tag_ids = status.tag_ids.to_set
+
     Tag.find_or_create_by_names(tags) do |tag|
+      next if tag_ids.include?(tag.id)
+
       status.tags << tag
       records << tag
 
       TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility?
     end
 
-    return unless status.distributable?
+    return unless status.public_visibility?
 
     status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|
       featured_tag.increment(status.created_at)
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 570cd8272..6ee8550c4 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -7,70 +7,37 @@ class ProcessMentionsService < BaseService
   # local mention pointers, send Salmon notifications to mentioned
   # remote users
   # @param [Status] status
-  def call(status)
-    return unless status.local?
+  # @option [Enumerable] :mentions Mentions to include
+  # @option [Boolean] :deliver Deliver mention notifications
+  def call(status, mentions: [], deliver: true)
+    return unless status.local? && !(status.frozen? || status.destroyed?)
 
-    @status  = status
-    mentions = []
+    @status = status
+    @status.text, mentions = ResolveMentionsService.new.call(@status, mentions: mentions)
+    @status.save!
 
-    status.text = status.text.gsub(Account::MENTION_RE) do |match|
-      username, domain = Regexp.last_match(1).split('@')
+    return unless deliver
 
-      domain = begin
-        if TagManager.instance.local_domain?(domain)
-          nil
-        else
-          TagManager.instance.normalize_domain(domain)
-        end
-      end
-
-      mentioned_account = Account.find_remote(username, domain)
-
-      if mention_undeliverable?(mentioned_account)
-        begin
-          mentioned_account = resolve_account_service.call(Regexp.last_match(1))
-        rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
-          mentioned_account = nil
-        end
-      end
-
-      next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
-
-      mention = mentioned_account.mentions.new(status: status)
-      mentions << mention if mention.save
-
-      "@#{mentioned_account.acct}"
-    end
-
-    status.save!
     check_for_spam(status)
 
+    @activitypub_json = {}
     mentions.each { |mention| create_notification(mention) }
   end
 
   private
 
-  def mention_undeliverable?(mentioned_account)
-    mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?)
-  end
-
   def create_notification(mention)
     mentioned_account = mention.account
 
     if mentioned_account.local?
-      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention)
+      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention) unless !@status.notify? || mention.silent?
     elsif mentioned_account.activitypub? && !@status.local_only?
-      ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? })
+      ActivityPub::DeliveryWorker.perform_async(activitypub_json(mentioned_account.domain), mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? })
     end
   end
 
-  def activitypub_json
-    return @activitypub_json if defined?(@activitypub_json)
-    @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
-  end
-
-  def resolve_account_service
-    ResolveAccountService.new
+  def activitypub_json(domain)
+    @activitypub_json[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, domain), ActivityPub::ActivitySerializer, signer: @status.account, domain: domain))
   end
 
   def check_for_spam(status)
diff --git a/app/services/publish_status_service.rb b/app/services/publish_status_service.rb
new file mode 100644
index 000000000..e95c3dacd
--- /dev/null
+++ b/app/services/publish_status_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+class PublishStatusService < BaseService
+  include Redisable
+
+  def call(status)
+    return if status.published?
+
+    @status = status
+
+    update_status!
+    reset_status_caches
+    distribute
+    bump_potential_friendship!
+  end
+
+  private
+
+  def update_status!
+    @status.update!(published: true, publish_at: nil, expires_at: @status.expires_at.blank? ? nil : Time.now.utc + (@status.expires_at - @status.created_at))
+    ProcessMentionsService.new.call(@status)
+  end
+
+  def reset_status_caches
+    Rails.cache.delete_matched("statuses/#{@status.id}-*")
+    Rails.cache.delete("statuses/#{@status.id}")
+    Rails.cache.delete(@status)
+    Rails.cache.delete_matched("format:#{@status.id}:*")
+    redis.zremrangebyscore("spam_check:#{@status.account.id}", @status.id, @status.id)
+  end
+
+  def distribute
+    LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text?
+    DistributionWorker.perform_async(@status.id)
+    ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only?
+  end
+
+  def bump_potential_friendship!
+    return if !@status.reply? || @status.account.id == @status.in_reply_to_account_id
+
+    ActivityTracker.increment('activity:interactions')
+    return if @status.account.following?(@status.in_reply_to_account_id)
+
+    PotentialFriendshipTracker.record(@status.account.id, @status.in_reply_to_account_id, :reply)
+  end
+end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 6f018e24b..b694bc500 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -28,10 +28,11 @@ class ReblogService < BaseService
       end
     end
 
-    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
+    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit], sensitive: true, spoiler_text: options[:spoiler_text] || '', published: true)
+    curate!(reblogged_status) unless reblogged_status.curated? || !reblogged_status.published?
 
     DistributionWorker.perform_async(reblog.id)
-    ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
+    ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only? || reblogged_status.account.private?
 
     create_notification(reblog)
     bump_potential_friendship(account, reblog)
@@ -60,6 +61,11 @@ class ReblogService < BaseService
   end
 
   def build_json(reblog)
-    Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account))
+    Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog, reblog.account.domain), ActivityPub::ActivitySerializer, signer: reblog.account, domain: reblog.account.domain))
+  end
+
+  def curate!(status)
+    status.curate!
+    DistributionWorker.perform_async(status.id)
   end
 end
diff --git a/app/services/remove_hashtags_service.rb b/app/services/remove_hashtags_service.rb
new file mode 100644
index 000000000..af2ba6f8c
--- /dev/null
+++ b/app/services/remove_hashtags_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class RemoveHashtagsService < BaseService
+  def call(status, tags)
+    tags = status.tags.matching_name(tags) if tags.is_a?(Array)
+
+    status.account.featured_tags.where(tag: tags).each do |featured_tag|
+      featured_tag.decrement(status.id)
+    end
+
+    if status.public_visibility?
+      delete_payload = Oj.dump(event: :delete, payload: status.id.to_s)
+      tags.pluck(:name).each do |hashtag|
+        redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", delete_payload)
+        redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", delete_payload) if status.local?
+      end
+    end
+
+    status.tags -= tags
+  end
+end
diff --git a/app/services/remove_media_attachments_service.rb b/app/services/remove_media_attachments_service.rb
new file mode 100644
index 000000000..de3cd9afb
--- /dev/null
+++ b/app/services/remove_media_attachments_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RemoveMediaAttachmentsService < BaseService
+  # Remove a list of media attachments by their IDs
+  # @param [Enumerable] attachment_ids
+  def call(attachment_ids)
+    media_attachments = MediaAttachment.where(id: attachment_ids)
+    media_attachments.map(&:id).each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
+    media_attachments.destroy_all
+  end
+end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index a5aafee21..e6ecfbc56 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -15,13 +15,15 @@ class RemoveStatusService < BaseService
     @status   = status
     @account  = status.account
     @tags     = status.tags.pluck(:name).to_a
-    @mentions = status.active_mentions.includes(:account).to_a
+    @mentions = status.mentions.includes(:account).to_a
     @reblogs  = status.reblogs.includes(:account).to_a
     @options  = options
 
+    @signed_activity_json = {}
+
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
-        remove_from_self if status.account.local?
+        remove_from_self if status.account.local? && !@options[:unpublish]
         remove_from_followers
         remove_from_lists
         remove_from_affected
@@ -30,10 +32,10 @@ class RemoveStatusService < BaseService
         remove_from_public
         remove_from_media if status.media_attachments.any?
         remove_from_direct if status.direct_visibility?
-        remove_from_spam_check
-        remove_media
+        remove_from_spam_check unless @options[:unpublish]
+        remove_media unless @options[:unpublish]
 
-        @status.destroy! if @options[:immediate] || !@status.reported?
+        @status.destroy! if @options[:immediate] || !((@options[:unpublish] && @status.local?) || @status.reported?)
       else
         raise Mastodon::RaceConditionError
       end
@@ -44,10 +46,17 @@ class RemoveStatusService < BaseService
     # original object being removed implicitly removes reblogs
     # of it. The Delete activity of the original is forwarded
     # separately.
-    return if !@account.local? || @options[:original_removed]
+    return if !@account.local? || @options[:original_removed] || !(status.published? || @options[:unpublished])
 
     remove_from_remote_followers
     remove_from_remote_affected
+
+    @status.mentions.where(account_id: @options[:blocking]).destroy_all if @options[:blocking]
+
+    return unless @options[:unpublish]
+
+    @status.update(published: false, expires_at: nil, local_only: @status.local?)
+    DistributionWorker.perform_async(@status.id) if @status.local?
   end
 
   private
@@ -88,14 +97,18 @@ class RemoveStatusService < BaseService
 
     # ActivityPub
     ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:preferred_inbox_url)) do |target_account|
-      [signed_activity_json, @account.id, target_account.preferred_inbox_url]
+      [signed_activity_json(inbox_url), @account.id, target_account.preferred_inbox_url]
     end
   end
 
   def remove_from_remote_followers
     # ActivityPub
     ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
-      [signed_activity_json, @account.id, inbox_url]
+      [signed_activity_json(inbox_url), @account.id, inbox_url]
+    end
+
+    ActivityPub::DeliveryWorker.push_bulk(@account.following.inboxes) do |inbox_url|
+      [signed_activity_json(inbox_url), @account.id, inbox_url]
     end
 
     relay! if relayable?
@@ -107,12 +120,13 @@ class RemoveStatusService < BaseService
 
   def relay!
     ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
-      [signed_activity_json, @account.id, inbox_url]
+      [signed_activity_json(inbox_url), @account.id, inbox_url]
     end
   end
 
-  def signed_activity_json
-    @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
+  def signed_activity_json(inbox_url)
+    domain = Addressable::URI.parse(inbox_url).normalized_host
+    @signed_activity_json[domain] ||= Oj.dump(serialize_payload(@status, @status.reblog? && @status.spoiler_text.blank? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account, domain: domain))
   end
 
   def remove_reblogs
@@ -142,22 +156,16 @@ class RemoveStatusService < BaseService
     return unless @status.public_visibility?
 
     redis.publish('timeline:public', @payload)
-    if @status.local?
-      redis.publish('timeline:public:local', @payload)
-    else
-      redis.publish('timeline:public:remote', @payload)
-    end
+    redis.publish('timeline:public:local', @payload) if @status.local?
+    redis.publish('timeline:public:remote', @payload)
   end
 
   def remove_from_media
     return unless @status.public_visibility?
 
     redis.publish('timeline:public:media', @payload)
-    if @status.local?
-      redis.publish('timeline:public:local:media', @payload)
-    else
-      redis.publish('timeline:public:remote:media', @payload)
-    end
+    redis.publish('timeline:public:local:media', @payload) if @status.local?
+    redis.publish('timeline:public:remote:media', @payload)
   end
 
   def remove_from_direct
diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb
new file mode 100644
index 000000000..6478dc902
--- /dev/null
+++ b/app/services/resolve_mentions_service.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class ResolveMentionsService < BaseService
+  # Scan text for mentions and create local mention pointers
+  # @param [Status] status Status to attach to mention pointers
+  # @option [String] :text Text containing mentions to resolve (default: use status text)
+  # @option [Enumerable] :mentions Additional mentions to include
+  # @return [Array] Array containing text with mentions resolved (String) and mention pointers (Set)
+  def call(status, text: nil, mentions: [])
+    mentions                  = Mention.includes(:account).where(id: mentions.pluck(:id), accounts: { suspended_at: nil }).or(status.mentions.includes(:account))
+    implicit_mention_acct_ids = mentions.active.pluck(:account_id).to_set
+    text                      = status.text if text.nil?
+    mentions                  = mentions.to_set
+
+    text.gsub(Account::MENTION_RE) do |match|
+      username, domain = Regexp.last_match(1).split('@')
+
+      domain = begin
+        if TagManager.instance.local_domain?(domain)
+          nil
+        else
+          TagManager.instance.normalize_domain(domain)
+        end
+      end
+
+      mentioned_account = Account.find_remote(username, domain)
+
+      if mention_undeliverable?(mentioned_account)
+        begin
+          mentioned_account = resolve_account_service.call(Regexp.last_match(1))
+        rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
+          mentioned_account = nil
+        end
+      end
+
+      next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
+
+      mention = mentioned_account.mentions.where(status: status).first_or_create(status: status, silent: false)
+      mention.update(silent: false) if mention.silent?
+
+      mentions << mention
+      implicit_mention_acct_ids.delete(mentioned_account.id)
+
+      "@#{mentioned_account.acct}"
+    end
+
+    Mention.where(id: implicit_mention_acct_ids).update_all(silent: true)
+
+    [text, mentions]
+  end
+
+  private
+
+  def mention_undeliverable?(mentioned_account)
+    mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?)
+  end
+
+  def resolve_account_service
+    ResolveAccountService.new
+  end
+end
diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb
index 2b10ac1e0..8e7ae82de 100644
--- a/app/services/resolve_url_service.rb
+++ b/app/services/resolve_url_service.rb
@@ -23,7 +23,7 @@ class ResolveURLService < BaseService
     if equals_or_includes_any?(type, ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
       ActivityPub::FetchRemoteAccountService.new.call(resource_url, prefetched_body: body)
     elsif equals_or_includes_any?(type, ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
-      status = FetchRemoteStatusService.new.call(resource_url, body)
+      status = FetchRemoteStatusService.new.call(resource_url, body, @on_behalf_of)
       authorize_with @on_behalf_of, status, :show? unless status.nil?
       status
     end
@@ -52,7 +52,7 @@ class ResolveURLService < BaseService
   end
 
   def fetched_resource
-    @fetched_resource ||= fetch_resource_service.call(@url)
+    @fetched_resource ||= fetch_resource_service.call(@url, on_behalf_of: @on_behalf_of)
   end
 
   def fetch_resource_service
diff --git a/app/services/revoke_status_service.rb b/app/services/revoke_status_service.rb
new file mode 100644
index 000000000..d860ab278
--- /dev/null
+++ b/app/services/revoke_status_service.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class RevokeStatusService < BaseService
+  include Redisable
+  include Payloadable
+
+  # Unpublish a status from a given set of local accounts' timelines and public, if visibility changed.
+  # @param   [Status] status
+  # @param   [Enumerable] account_ids
+  def call(status, account_ids)
+    @payload      = Oj.dump(event: :delete, payload: status.id.to_s)
+    @status       = status
+    @account      = status.account
+    @account_ids  = account_ids
+    @mentions     = status.mentions.where(account_id: account_ids)
+    @reblogs      = status.reblogs.where(account_id: account_ids)
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        remove_from_followers
+        remove_from_lists
+        remove_from_affected
+        remove_reblogs
+        remove_from_hashtags
+        remove_from_public
+        remove_from_media
+        remove_from_direct if status.direct_visibility?
+      else
+        raise Mastodon::RaceConditionError
+      end
+    end
+  end
+
+  private
+
+  def remove_from_followers
+    @account.followers_for_local_distribution.where(id: @account_ids).reorder(nil).find_each do |follower|
+      FeedManager.instance.unpush_from_home(follower, @status)
+    end
+  end
+
+  def remove_from_lists
+    @account.lists_for_local_distribution.where(account_id: @account_ids).select(:id, :account_id).reorder(nil).find_each do |list|
+      FeedManager.instance.unpush_from_list(list, @status)
+    end
+  end
+
+  def remove_from_affected
+    @mentions.map(&:account).select(&:local?).each do |account|
+      redis.publish("timeline:#{account.id}", @payload)
+    end
+  end
+
+  def remove_reblogs
+    @reblogs.each do |reblog|
+      RemoveStatusService.new.call(reblog)
+    end
+  end
+
+  def remove_from_hashtags
+    @account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag|
+      featured_tag.decrement(@status.id)
+    end
+
+    return unless @status.public_visibility?
+
+    @tags.each do |hashtag|
+      redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
+      redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
+    end
+  end
+
+  def remove_from_public
+    return if @status.public_visibility?
+
+    redis.publish('timeline:public', @payload)
+    redis.publish('timeline:public:local', @payload) if @status.local?
+    redis.publish('timeline:public:remote', @payload)
+  end
+
+  def remove_from_media
+    return if @status.public_visibility?
+
+    redis.publish('timeline:public:media', @payload)
+    redis.publish('timeline:public:local:media', @payload) if @status.local?
+    redis.publish('timeline:public:remote:media', @payload)
+  end
+
+  def remove_from_direct
+    @mentions.each do |mention|
+      FeedManager.instance.unpush_from_direct(mention.account, @status) if mention.account.local?
+    end
+  end
+
+  def lock_options
+    { redis: Redis.current, key: "distribute:#{@status.id}" }
+  end
+end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 19500a8d4..819ce2c16 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -53,7 +53,7 @@ class SearchService < BaseService
     account_domains     = results.map(&:account_domain)
     preloaded_relations = relations_map_for_account(@account, account_ids, account_domains)
 
-    results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
+    results.reject { |status| StatusFilter.new(status, @account, true, preloaded_relations).filtered? }
   rescue Faraday::ConnectionFailed, Parslet::ParseFailed
     []
   end
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 19d65280d..52fed7c26 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -66,9 +66,11 @@ class SuspendAccountService < BaseService
         styles     = [:original] | attachment.styles.keys
 
         styles.each do |style|
+          next if attachment.path(style).blank?
+
           case Paperclip::Attachment.default_options[:storage]
           when :s3
-            attachment.s3_object(style).acl.put(acl: 'private')
+            attachment.s3_object(style).acl.put({ acl: 'private' })
           when :fog
             # Not supported
           when :filesystem
diff --git a/app/services/unallow_domain_service.rb b/app/services/unallow_domain_service.rb
index fc5260761..0d2d8f254 100644
--- a/app/services/unallow_domain_service.rb
+++ b/app/services/unallow_domain_service.rb
@@ -4,7 +4,7 @@ class UnallowDomainService < BaseService
   include DomainControlHelper
 
   def call(domain_allow)
-    suspend_accounts!(domain_allow.domain) if whitelist_mode?
+    suspend_accounts!(domain_allow.domain)
 
     domain_allow.destroy
   end
@@ -12,6 +12,7 @@ class UnallowDomainService < BaseService
   private
 
   def suspend_accounts!(domain)
+    DomainDefederationWorker.perform_async(domain)
     Account.where(domain: domain).in_batches.update_all(suspended_at: Time.now.utc)
     AfterUnallowDomainWorker.perform_async(domain)
   end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 151f3674f..c3e70d414 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -13,13 +13,15 @@ class UnfollowService < BaseService
     @target_account = target_account
     @options        = options
 
-    unfollow! || undo_follow_request!
+    unfollow!
+    undo_follow_request!
   end
 
   private
 
   def unfollow!
     follow = Follow.find_by(account: @source_account, target_account: @target_account)
+    follow = Follow.create!(account: @source_account, target_account: @target_account) if follow.blank? && @options[:force]
 
     return unless follow
 
@@ -34,6 +36,7 @@ class UnfollowService < BaseService
 
   def undo_follow_request!
     follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account)
+    follow_request = FollowRequest.create!(account: @source_account, target_account: @target_account) if follow_request.blank? && @options[:force]
 
     return unless follow_request
 
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
new file mode 100644
index 000000000..cedd534ea
--- /dev/null
+++ b/app/services/update_status_service.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+class UpdateStatusService < BaseService
+  include Redisable
+  include ImgProxyHelper
+
+  ALLOWED_ATTRIBUTES = %i(
+    spoiler_text
+    title
+    text
+    original_text
+    footer
+    content_type
+    language
+    sensitive
+    visibility
+    local_only
+    media_attachments
+    media_attachment_ids
+    application
+    expires_at
+  ).freeze
+
+  # Updates the content of an existing status.
+  # @param [Status] status The status to update.
+  # @param [Hash] params The attributes of the new status.
+  # @param [Enumerable] mentions Additional mentions added to the status.
+  # @param [Enumerable] tags New tags for the status to belong to (implicit tags are preserved).
+  def call(status, params, mentions = nil, tags = nil)
+    raise ActiveRecord::RecordNotFound if status.blank? || status.discarded? || status.destroyed?
+    return status if params.blank?
+
+    @status                 = status
+    @account                = @status.account
+    @params                 = params.with_indifferent_access.slice(*ALLOWED_ATTRIBUTES).compact
+    @mentions               = (@status.mentions | (mentions || [])).to_set
+    @tags                   = (tags.nil? ? @status.tags : (tags || [])).to_set
+
+    @params[:text]        ||= ''
+    @params[:original_text] = @params[:text]
+    @params[:published]     = true if @status.published?
+    @params[:edited]      ||= 1 + @status.edited if @params[:published].presence || @status.published?
+    @params[:expires_at]  ||= Time.now.utc + (@status.expires_at - @status.created_at) if @status.expires_at.present?
+
+    @params[:originally_local_only] = @params[:local_only] unless @status.published?
+
+    RemoveStatusService.new.call(@status, unpublish: true) if @status.published? && !@status.local_only? && @params[:local_only]
+    update_tags if @status.local?
+
+    @delete_payload         = Oj.dump(event: :delete, payload: @status.id.to_s)
+    @deleted_tag_ids        = @status.tags.pluck(:id) - @tags.pluck(:id)
+    @deleted_tag_names      = @status.tags.pluck(:name) - @tags.pluck(:name)
+    @deleted_attachment_ids = @status.media_attachment_ids - (@params[:media_attachment_ids] || @params[:media_attachments]&.pluck(:id) || [])
+
+    ApplicationRecord.transaction do
+      @status.update!(@params)
+
+      if @account.local?
+        ProcessCommandTagsService.new.call(@account, @status)
+      else
+        process_inline_images!
+      end
+
+      update_mentions
+      @status.save!
+
+      detach_deleted_tags
+      attach_updated_tags
+    end
+
+    prune_tags
+    prune_attachments
+    reset_status_caches
+
+    SpamCheck.perform(@status) if @status.published?
+    distribute
+
+    @status
+  end
+
+  private
+
+  def prune_attachments
+    @new_inline_ids = @status.inlined_attachments.pluck(:media_attachment_id)
+    RemoveMediaAttachmentsWorker.perform_async(@deleted_attachment_ids) if @deleted_attachment_ids.present?
+  end
+
+  def detach_deleted_tags
+    @status.tags -= Tag.where(id: @deleted_tag_ids) if @deleted_tag_ids.present?
+  end
+
+  def prune_tags
+    @account.featured_tags.where(tag_id: @deleted_tag_ids).each do |featured_tag|
+      featured_tag.decrement(@status.id)
+    end
+
+    return unless @status.distributable? && @deleted_tag_names.present?
+
+    @deleted_tag_names.each do |hashtag|
+      redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @delete_payload)
+      redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @delete_payload) if @status.local?
+    end
+  end
+
+  def update_tags
+    old_explicit_tags = Tag.matching_name(Extractor.extract_hashtags(@status.text))
+    @tags |= Tag.find_or_create_by_names(Extractor.extract_hashtags(@params[:text]))
+
+    # Preserve implicit tags attached to the original status.
+    # TODO: Let locals remove them from edits.
+    @tags |= @status.tags.where.not(id: old_explicit_tags.select(:id))
+  end
+
+  def update_mentions
+    @new_mention_ids = @mentions.pluck(:id) - @status.mention_ids
+    @status.text, @mentions = ResolveMentionsService.new.call(@status, mentions: @mentions)
+    @new_mention_ids |= (@mentions.pluck(:id) - @new_mention_ids)
+  end
+
+  def attach_updated_tags
+    tag_ids = @status.tag_ids.to_set
+    new_tag_ids = []
+    now = Time.now.utc
+
+    @tags.each do |tag|
+      next if tag_ids.include?(tag.id) || /\A(#{Tag::HASHTAG_NAME_RE})\z/i =~ $LAST_READ_LINE
+
+      @status.tags << tag
+      new_tag_ids << tag.id
+      TrendingTags.record_use!(tag, @account, now) if @status.distributable?
+    end
+
+    return unless @status.local? && @status.distributable?
+
+    @account.featured_tags.where(tag_id: new_tag_ids).each do |featured_tag|
+      featured_tag.increment(now)
+    end
+  end
+
+  def reset_status_caches
+    Rails.cache.delete_matched("statuses/#{@status.id}-*")
+    Rails.cache.delete("statuses/#{@status.id}")
+    Rails.cache.delete(@status)
+    Rails.cache.delete_matched("format:#{@status.id}:*")
+    redis.zremrangebyscore("spam_check:#{@account.id}", @status.id, @status.id)
+  end
+
+  def distribute
+    LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text?
+    DistributionWorker.perform_async(@status.id)
+
+    return unless @status.published?
+
+    ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only?
+
+    return unless @status.notify?
+
+    mentions = @status.active_mentions.includes(:account).where(id: @new_mention_ids, accounts: { domain: nil })
+    mentions.each { |mention| LocalNotificationWorker.perform_async(mention.account.id, mention.id, mention.class.name) }
+  end
+end
diff --git a/app/validators/poll_validator.rb b/app/validators/poll_validator.rb
index 8259a62e5..898c0c67b 100644
--- a/app/validators/poll_validator.rb
+++ b/app/validators/poll_validator.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 class PollValidator < ActiveModel::Validator
-  MAX_OPTIONS      = 5
-  MAX_OPTION_CHARS = 100
-  MAX_EXPIRATION   = 1.month.freeze
-  MIN_EXPIRATION   = 5.minutes.freeze
+  MAX_OPTIONS      = 33
+  MAX_OPTION_CHARS = 202
+  MAX_EXPIRATION   = 6.months.freeze
+  MIN_EXPIRATION   = 1.minute.freeze
 
   def validate(poll)
     current_time = Time.now.utc
diff --git a/app/views/about/_domain_allows.html.haml b/app/views/about/_domain_allows.html.haml
new file mode 100644
index 000000000..ab5755b41
--- /dev/null
+++ b/app/views/about/_domain_allows.html.haml
@@ -0,0 +1,12 @@
+%table
+  %thead
+    %tr
+      %th{colspan: 3}= t('about.unavailable_content_description.domain')
+  %tbody
+    - domain_allows.in_groups_of(3) do |group|
+      %tr
+      - group.each do |domain_allow|
+        %td.nowrap
+          - unless domain_allow.nil?
+            %span
+              %a{ title: domain_allow.domain, href: "https://#{domain_allow.domain}", rel: 'noopener nofollow' }= domain_allow.domain
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index 5d159e9e6..c3bd3ed60 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -6,14 +6,17 @@
       = f.simple_fields_for :account do |account_fields|
         = account_fields.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations?
 
+      = f.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_confirmation'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username_confirmation'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations?
       = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
       = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: closed_registrations?
       = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
+      = f.hidden_field :kobold, input_html: { :autocomplete => 'off' }
+
 
     - if approved_registrations?
       .fields-group
         = f.simple_fields_for :invite_request do |invite_request_fields|
-          = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false
+          = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: true
 
     .fields-group
       = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true, disabled: closed_registrations?
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 0a12ab8d6..0e4465a4a 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -42,13 +42,18 @@
   .column-3
     = render 'application/flashes'
 
-    - if @contents.blank? && (!display_blocks? || @blocks&.empty?)
+    - if @contents.blank? && ((!display_allows? || @allows&.empty?) && (!display_blocks? || @blocks&.empty?))
       = nothing_here
     - else
       .box-widget
         .rich-formatting
           = @contents.html_safe
 
+          - if display_allows? && !@allows.empty?
+            %h2#available-content= t('about.available_content')
+            %p= t('about.available_content_html')
+            = render partial: 'domain_allows', locals: { domain_allows: @allows }
+
           - if display_blocks? && !@blocks.empty?
             %h2#unavailable-content= t('about.unavailable_content')
 
@@ -78,5 +83,8 @@
               - item.children.each do |sub_item|
                 %li= link_to sub_item.title, "##{sub_item.anchor}"
 
+      - if display_allows? && !@allows.empty?
+        %li= link_to t('about.available_content'), '#available-content'
+
       - if display_blocks? && !@blocks.empty?
         %li= link_to t('about.unavailable_content'), '#unavailable-content'
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 565c4ed59..4cacb6f3c 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -3,77 +3,116 @@
 
 - content_for :header_tags do
   %link{ rel: 'canonical', href: about_url }/
+  %script{ src: '/registration.js', type: 'text/javascript', crossorigin: 'anonymous' }
   = render partial: 'shared/og'
 
-.landing
-  .landing__brand
-    = link_to root_url, class: 'brand' do
-      = svg_logo_full
-      %span.brand__tagline=t 'about.tagline'
+.grid-4
+  .column-0
+    .public-account-header.public-account-header--no-bar
+      .public-account-header__image
+        = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title, class: 'parallax'
 
-  .landing__grid
-    .landing__grid__column.landing__grid__column-registration
+  .column-1
+    .landing-page__call-to-action{ dir: 'ltr' }
+      .row
+        .row__information-board
+          .information-board__section
+            %span= t 'about.user_count_before'
+            %strong= number_with_delimiter @instance_presenter.user_count
+            %span= t 'about.user_count_after', count: @instance_presenter.user_count
+          .information-board__section
+            %span= t 'about.status_count_before'
+            %strong= number_with_delimiter @instance_presenter.status_count
+            %span= t 'about.status_count_after', count: @instance_presenter.status_count
+        .row__mascot
+          .landing-page__mascot
+            = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg'), alt: ''
+
+  .column-2
+    .contact-widget
+      %h4= t 'about.administered_by'
+
+      = account_link_to(@instance_presenter.contact_account)
+
+      - if @instance_presenter.site_contact_email.present?
+        %h4
+          = succeed ':' do
+            = t 'about.contact'
+
+        = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email
+
+  .column-3
+    = render 'application/flashes'
+
+    .box-widget
+      = render 'registration'
+
+    %br
+
+    - if @contents.blank? && ((!display_allows? || @allows&.empty?) && (!display_blocks? || @blocks&.empty?))
+      = nothing_here
+    - else
       .box-widget
-        = render 'registration'
-
-      .directory
-        - if Setting.profile_directory
-          .directory__tag
-            = optional_link_to Setting.profile_directory, explore_path do
-              %h4
-                = fa_icon 'address-book fw'
-                = t('about.discover_users')
-                %small= t('about.browse_directory')
-
-              .avatar-stack
-                - @instance_presenter.sample_accounts.each do |account|
-                  = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, alt: '', class: 'account__avatar'
-
-        - if Setting.timeline_preview
-          .directory__tag
-            = optional_link_to Setting.timeline_preview, public_timeline_path do
-              %h4
-                = fa_icon 'globe fw'
-                = t('about.see_whats_happening')
-                %small= t('about.browse_public_posts')
+        .rich-formatting
+          = @contents.html_safe
+
+          - if display_allows? && !@allows.empty?
+            %h2#available-content= t('about.available_content')
+            %p= t('about.available_content_html')
+            = render partial: 'domain_allows', locals: { domain_allows: @allows }
+
+          - if display_blocks? && !@blocks.empty?
+            %h2#unavailable-content= t('about.unavailable_content')
 
+            - if (blocks = @blocks.select(&:reject_media?)) && !blocks.empty?
+              %h3= t('about.unavailable_content_description.rejecting_media_title')
+              %p= t('about.unavailable_content_description.rejecting_media')
+              = render partial: 'domain_blocks', locals: { domain_blocks: blocks }
+            - if (blocks = @blocks.select(&:silence?)) && !blocks.empty?
+              %h3= t('about.unavailable_content_description.silenced_title')
+              %p= t('about.unavailable_content_description.silenced')
+              = render partial: 'domain_blocks', locals: { domain_blocks: blocks }
+            - if (blocks = @blocks.select(&:suspend?)) && !blocks.empty?
+              %h3= t('about.unavailable_content_description.suspended_title')
+              %p= t('about.unavailable_content_description.suspended')
+              = render partial: 'domain_blocks', locals: { domain_blocks: blocks }
+
+  .column-4
+    .box-widget
+      = render 'login'
+
+    %br
+
+    %ul.table-of-contents
+      - @table_of_contents.each do |item|
+        %li
+          = link_to item.title, "##{item.anchor}"
+
+          - unless item.children.empty?
+            %ul
+              - item.children.each do |sub_item|
+                %li= link_to sub_item.title, "##{sub_item.anchor}"
+
+      - if display_allows? && !@allows.empty?
+        %li= link_to t('about.available_content'), '#available-content'
+
+      - if display_blocks? && !@blocks.empty?
+        %li= link_to t('about.unavailable_content'), '#unavailable-content'
+
+    %br
+
+    .directory
+      - if Setting.profile_directory
         .directory__tag
-          = link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener noreferrer' do
+          = optional_link_to Setting.profile_directory, explore_path do
             %h4
-              = fa_icon 'tablet fw'
-              = t('about.get_apps')
-              %small= t('about.apps_platforms')
+              = fa_icon 'address-book fw'
+              = t('about.discover_users')
 
-    .landing__grid__column.landing__grid__column-login
-      .box-widget
-        = render 'login'
-
-      .hero-widget
-        .hero-widget__img
-          = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title
-
-        .hero-widget__text
-          %p
-            = @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
-            = link_to about_more_path do
-              = t('about.learn_more')
-              = fa_icon 'angle-double-right'
-
-        .hero-widget__footer
-          .hero-widget__footer__column
-            %h4= t 'about.administered_by'
-
-            = account_link_to @instance_presenter.contact_account
-
-          .hero-widget__footer__column
-            %h4= t 'about.server_stats'
-
-            .hero-widget__counters__wrapper
-              .hero-widget__counter
-                %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true
-                %span= t 'about.user_count_after', count: @instance_presenter.user_count
-              .hero-widget__counter
-                %strong= number_to_human @instance_presenter.active_user_count, strip_insignificant_zeros: true
-                %span
-                  = t 'about.active_count_after'
-                  %abbr{ title: t('about.active_footnote') } *
+      .directory__tag
+        = link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener noreferrer' do
+          %h4
+            = fa_icon 'tablet fw'
+            = t('about.get_apps')
+
+    %br
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index 52fb0d946..27a29c061 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -13,19 +13,16 @@
             = fa_icon('lock') if account.locked?
       .public-account-header__tabs__tabs
         .details-counters
-          .counter{ class: active_nav_class(short_account_url(account), short_account_with_replies_url(account), short_account_media_url(account)) }
+          .counter{ class: active_nav_class(short_account_url(account), short_account_threads_url(account), short_account_with_replies_url(account), short_account_reblogs_url(account), short_account_mentions_url(account), short_account_media_url(account)) }
             = link_to short_account_url(account), class: 'u-url u-uid', title: number_with_delimiter(account.statuses_count) do
-              %span.counter-number= number_to_human account.statuses_count, strip_insignificant_zeros: true
               %span.counter-label= t('accounts.posts', count: account.statuses_count)
 
           .counter{ class: active_nav_class(account_following_index_url(account)) }
             = link_to account_following_index_url(account), title: number_with_delimiter(account.following_count) do
-              %span.counter-number= number_to_human account.following_count, strip_insignificant_zeros: true
               %span.counter-label= t('accounts.following', count: account.following_count)
 
           .counter{ class: active_nav_class(account_followers_url(account)) }
             = link_to account_followers_url(account), title: hide_followers_count?(account) ? nil : number_with_delimiter(account.followers_count) do
-              %span.counter-number= hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
               %span.counter-label= t('accounts.followers', count: account.followers_count)
         .spacer
         .public-account-header__tabs__tabs__buttons
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 1a81b96f6..d38d57894 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -16,6 +16,9 @@
   = opengraph 'og:type', 'profile'
   = render 'og', account: @account, url: short_account_url(@account, only_path: false)
 
+- content_for :header_overrides do
+  - if @account&.user&.setting_style_css_profile.present?
+    = stylesheet_link_tag user_profile_css_path(id: @account.id), media: 'all'
 
 = render 'header', account: @account, with_bio: true
 
@@ -26,8 +29,13 @@
 
       .account__section-headline
         = active_link_to t('accounts.posts_tab_heading'), short_account_url(@account)
-        = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account)
+        = active_link_to t('accounts.threads'), short_account_threads_url(@account)
+        - if @account.show_replies?
+          = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account)
+        - if current_account.present? && @account.id != current_account.id
+          = active_link_to t('accounts.mentions'), short_account_mentions_url(@account)
         = active_link_to t('accounts.media'), short_account_media_url(@account)
+        = active_link_to t('accounts.reblogs'), short_account_reblogs_url(@account)
 
       - if user_signed_in? && @account.blocking?(current_account)
         .nothing-here.nothing-here--under-tabs= t('accounts.unavailable')
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 8eac226e0..bff1f2b20 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -10,7 +10,7 @@
   .filter-subset
     %strong= t('admin.accounts.moderation.title')
     %ul
-      %li= link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), admin_pending_accounts_path
+      %li= link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.confirmed.count)})"], ' '), admin_pending_accounts_path
       %li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil, pending: nil
       %li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil, pending: nil
       %li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil, pending: nil
diff --git a/app/views/admin/domain_allows/new.html.haml b/app/views/admin/domain_allows/new.html.haml
index 85ab7e464..0540765d7 100644
--- a/app/views/admin/domain_allows/new.html.haml
+++ b/app/views/admin/domain_allows/new.html.haml
@@ -6,6 +6,7 @@
 
   .fields-group
     = f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), required: true
+    = f.input :hidden, wrapper: :with_label, label: t('admin.domain_allows.hidden')
 
   .actions
     = f.button :button, t('admin.domain_allows.add_new'), type: :submit
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index 696ba3c7f..5aec735e4 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -2,19 +2,15 @@
   = t('admin.instances.title')
 
 - content_for :heading_actions do
-  - if whitelist_mode?
-    = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button'
-  - else
-    = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
+  = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button'
+  = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
 
 .filters
   .filter-subset
     %strong= t('admin.instances.moderation.title')
     %ul
       %li= filter_link_to t('admin.instances.moderation.all'), limited: nil
-
-      - unless whitelist_mode?
-        %li= filter_link_to t('admin.instances.moderation.limited'), limited: '1'
+      %li= filter_link_to t('admin.instances.moderation.limited'), limited: '1'
 
 - unless whitelist_mode?
   = form_tag admin_instances_url, method: 'GET', class: 'simple_form' do
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index 92e14c0df..e5a5a6129 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -52,8 +52,11 @@
   %div
     - if @domain_allow
       = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }
-    - elsif @domain_block
+    - else
+      = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path(_domain: @instance.domain), class: 'button'
+
+    - if @domain_block
       = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@domain_block), class: 'button'
       = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@domain_block), class: 'button'
     - else
-      = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button'
+      = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button button--destructive'
diff --git a/app/views/admin/pending_accounts/index.html.haml b/app/views/admin/pending_accounts/index.html.haml
index 8101d7f99..2f73d12b4 100644
--- a/app/views/admin/pending_accounts/index.html.haml
+++ b/app/views/admin/pending_accounts/index.html.haml
@@ -1,5 +1,5 @@
 - content_for :page_title do
-  = t('admin.pending_accounts.title', count: User.pending.count)
+  = t('admin.pending_accounts.title', count: User.pending.confirmed.count)
 
 = form_for(@form, url: batch_admin_pending_accounts_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 108846ca9..0d36e4551 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -47,12 +47,11 @@
 
   %hr.spacer/
 
-  - unless whitelist_mode?
-    .fields-group
-      = f.input :timeline_preview, as: :boolean, wrapper: :with_label, label: t('admin.settings.timeline_preview.title'), hint: t('admin.settings.timeline_preview.desc_html')
+  .fields-group
+    = f.input :timeline_preview, as: :boolean, wrapper: :with_label, label: t('admin.settings.timeline_preview.title'), hint: t('admin.settings.timeline_preview.desc_html')
 
-    .fields-group
-      = f.input :show_known_fediverse_at_about_page, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_known_fediverse_at_about_page.title'), hint: t('admin.settings.show_known_fediverse_at_about_page.desc_html')
+  .fields-group
+    = f.input :show_known_fediverse_at_about_page, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_known_fediverse_at_about_page.title'), hint: t('admin.settings.show_known_fediverse_at_about_page.desc_html')
 
   .fields-group
     = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html')
@@ -60,27 +59,26 @@
   .fields-group
     = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html')
 
-  - unless whitelist_mode?
-    .fields-group
-      = f.input :activity_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.activity_api_enabled.title'), hint: t('admin.settings.activity_api_enabled.desc_html')
+  .fields-group
+    = f.input :activity_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.activity_api_enabled.title'), hint: t('admin.settings.activity_api_enabled.desc_html')
 
-    .fields-group
-      = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html')
+  .fields-group
+    = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html')
 
-    .fields-group
-      = f.input :preview_sensitive_media, as: :boolean, wrapper: :with_label, label: t('admin.settings.preview_sensitive_media.title'), hint: t('admin.settings.preview_sensitive_media.desc_html')
+  .fields-group
+    = f.input :preview_sensitive_media, as: :boolean, wrapper: :with_label, label: t('admin.settings.preview_sensitive_media.title'), hint: t('admin.settings.preview_sensitive_media.desc_html')
 
-    .fields-group
-      = f.input :profile_directory, as: :boolean, wrapper: :with_label, label: t('admin.settings.profile_directory.title'), hint: t('admin.settings.profile_directory.desc_html')
+  .fields-group
+    = f.input :profile_directory, as: :boolean, wrapper: :with_label, label: t('admin.settings.profile_directory.title'), hint: t('admin.settings.profile_directory.desc_html')
 
-    .fields-group
-      = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html')
+  .fields-group
+    = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html')
 
-    .fields-group
-      = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, label: t('admin.settings.trendable_by_default.title'), hint: t('admin.settings.trendable_by_default.desc_html')
+  .fields-group
+    = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, label: t('admin.settings.trendable_by_default.title'), hint: t('admin.settings.trendable_by_default.desc_html')
 
-    .fields-group
-      = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html')
+  .fields-group
+    = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html')
 
   .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')
@@ -99,8 +97,11 @@
 
   %hr.spacer/
 
-  .fields-group
-    = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+  .fields-row
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :show_domain_allows, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_allows.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
 
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
@@ -112,7 +113,7 @@
     = f.input :outgoing_spoilers, wrapper: :with_label, label: t('admin.settings.outgoing_spoilers.title'), hint: t('admin.settings.outgoing_spoilers.desc_html')
 
   .fields-group
-    = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } unless whitelist_mode?
+    = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 }
     = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 }
     = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 }
     = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }, label: t('admin.settings.custom_css.title'), hint: t('admin.settings.custom_css.desc_html')
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index cc72b87ce..ab2d33d69 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -2,6 +2,7 @@
   = t('auth.register')
 
 - content_for :header_tags do
+  %script{ src: '/registration.js', type: 'text/javascript', crossorigin: 'anonymous' }
   = render partial: 'shared/og', locals: { description: description_for_sign_up }
 
 = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f|
@@ -15,6 +16,7 @@
   = f.simple_fields_for :account do |ff|
     .fields-group
       = ff.input :username, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: t('simple_form.hints.defaults.username', domain: site_hostname)
+      = f.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_confirmation'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username_confirmation'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations?
 
   .fields-group
     = f.input :email, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }
@@ -28,9 +30,10 @@
   - if approved_registrations? && !@invite.present?
     .fields-group
       = f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields|
-        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false
+        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: true
 
   = f.input :invite_code, as: :hidden
+  = f.hidden_field :kobold, input_html: { :autocomplete => 'off' }
 
   .fields-group
     = f.input :agreement, as: :boolean, wrapper: :with_label, label: whitelist_mode? ? t('auth.checkbox_agreement_without_rules_html', terms_path: terms_path) : t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/custom_emojis/_custom_emoji.html.haml
index 526c844e9..e124373c6 100644
--- a/app/views/admin/custom_emojis/_custom_emoji.html.haml
+++ b/app/views/custom_emojis/_custom_emoji.html.haml
@@ -7,6 +7,7 @@
 
     .batch-table__row__content__text
       %samp= ":#{custom_emoji.shortcode}:"
+      %p.hint.muted-hint{ title: t('admin.custom_emojis.owner') }= custom_emoji.account_id.present? ? "@#{custom_emoji.account.username}" : t('admin.custom_emojis.unclaimed') if custom_emoji.local?
 
       - if custom_emoji.local?
         %span.account-role.bot= custom_emoji.category&.name || t('admin.custom_emojis.uncategorized')
diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/custom_emojis/index.html.haml
index b6cf7ba64..f81d91d53 100644
--- a/app/views/admin/custom_emojis/index.html.haml
+++ b/app/views/custom_emojis/index.html.haml
@@ -3,7 +3,9 @@
 
 - if can?(:create, :custom_emoji)
   - content_for :heading_actions do
-    = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
+    = link_to t('admin.custom_emojis.upload'), new_custom_emoji_path, class: 'button'
+
+%p= t('admin.custom_emojis.ownership_warning')
 
 .filters
   .filter-subset
@@ -11,17 +13,27 @@
     %ul
       %li= filter_link_to t('admin.accounts.location.all'), local: nil, remote: nil
       %li
-        - if selected? local: '1', remote: nil
-          = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil}, {local: '1', remote: nil}
+        - if selected? local: '1', remote: nil, claimed: nil, unclaimed: nil
+          = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: nil, unclaimed: nil}
+        - else
+          = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil, claimed: nil, unclaimed: nil
+      %li
+        - if selected? remote: '1', local: nil, claimed: nil, unclaimed: nil
+          = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil, claimed: nil, unclaimed: nil}, {remote: '1', local: nil, claimed: nil, unclaimed: nil}
         - else
-          = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil
+          = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil, claimed: nil, unclained: nil
       %li
-        - if selected? remote: '1', local: nil
-          = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil}, {remote: '1', local: nil}
+        - if selected? local: '1', remote: nil, claimed: '1', unclaimed: nil
+          = filter_link_to t('admin.accounts.location.claimed'), {local: '1', remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: '1', unclaimed: nil}
         - else
-          = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil
+          = filter_link_to t('admin.accounts.location.claimed'), local: '1', remote: nil, claimed: '1', unclaimed: nil
+      %li
+        - if selected? local: '1', remote: nil, claimed: nil, unclaimed: '1'
+          = filter_link_to t('admin.accounts.location.unclaimed'), {local: '1', remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: nil, unclaimed: '1'}
+        - else
+          = filter_link_to t('admin.accounts.location.unclaimed'), local: '1', remote: nil, claimed: nil, unclaimed: '1'
 
-= form_tag admin_custom_emojis_url, method: 'GET', class: 'simple_form' do
+= form_tag custom_emojis_url, method: 'GET', class: 'simple_form' do
   .fields-group
     - CustomEmojiFilter::KEYS.each do |key|
       = hidden_field_tag key, params[key] if params[key].present?
@@ -32,9 +44,9 @@
 
     .actions
       %button.button= t('admin.accounts.search')
-      = link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative'
+      = link_to t('admin.accounts.reset'), custom_emojis_path, class: 'button negative'
 
-= form_for(@form, url: batch_admin_custom_emojis_path) do |f|
+= form_for(@form, url: batch_custom_emojis_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
 
   - CustomEmojiFilter::KEYS.each do |key|
@@ -45,6 +57,10 @@
       %label.batch-table__toolbar__select.batch-checkbox-all
         = check_box_tag :batch_checkbox_all, nil, false
       .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('lock'), t('admin.custom_emojis.claim')]), name: :claim, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+        = f.button safe_join([fa_icon('unlock'), t('admin.custom_emojis.unclaim')]), name: :unclaim, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
         - if params[:local] == '1'
           = f.button safe_join([fa_icon('save'), t('generic.save_changes')]), name: :update, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
@@ -56,10 +72,9 @@
 
         = f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.disable')]), name: :disable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
-        - if can?(:destroy, :custom_emoji)
-          = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
-        - if can?(:copy, :custom_emoji) && params[:local] != '1'
+        - if params[:local] != '1'
           = f.button safe_join([fa_icon('copy'), t('admin.custom_emojis.copy')]), name: :copy, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
     - if params[:local] == '1'
diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/custom_emojis/new.html.haml
index e15a07cb8..fe9d8fc64 100644
--- a/app/views/admin/custom_emojis/new.html.haml
+++ b/app/views/custom_emojis/new.html.haml
@@ -1,7 +1,7 @@
 - content_for :page_title do
   = t('.title')
 
-= simple_form_for @custom_emoji, url: admin_custom_emojis_path do |f|
+= simple_form_for @custom_emoji, url: custom_emojis_path do |f|
   = render 'shared/error_messages', object: @custom_emoji
 
   .fields-group
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 32681773f..ce152f407 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -41,6 +41,11 @@
     - if Setting.custom_css.present?
       = stylesheet_link_tag custom_css_path, media: 'all'
 
+    - if current_account&.user.present?
+      = stylesheet_link_tag user_webapp_css_path(current_account.id), media: 'all'
+
+    = yield :header_overrides
+
   %body{ class: body_classes }
     = content_for?(:content) ? yield(:content) : yield
 
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index eaa0437c2..e820285cb 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -10,10 +10,9 @@
             = link_to root_url, class: 'brand' do
               = svg_logo_full
 
-            - unless whitelist_mode?
-              = link_to t('directories.directory'), explore_path, class: 'nav-link optional' if Setting.profile_directory
-              = link_to t('about.about_this'), about_more_path, class: 'nav-link optional'
-              = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional'
+            = link_to t('directories.directory'), explore_path, class: 'nav-link optional' if Setting.profile_directory
+            = link_to t('about.about_this'), about_more_path, class: 'nav-link optional'
+            = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional'
 
           .nav-center
 
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index ccea2e9b7..7658b8017 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -14,10 +14,8 @@
 
   %h4= t 'appearance.advanced_web_interface'
 
-  %p.hint= t 'appearance.advanced_web_interface_hint'
-
   .fields-group
-    = f.input :setting_advanced_layout, as: :boolean, wrapper: :with_label, hint: false
+    = f.input :setting_advanced_layout, as: :boolean, wrapper: :with_label, hint: t('appearance.advanced_web_interface_hint')
 
   %h4= t 'appearance.animations_and_accessibility'
 
@@ -31,6 +29,12 @@
     = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label
     = f.input :setting_system_emoji_font, as: :boolean, wrapper: :with_label
 
+  .fields-group
+    = f.input :setting_style_wide_media, as: :boolean, wrapper: :with_label
+    = f.input :setting_style_dashed_nest, as: :boolean, wrapper: :with_label
+    = f.input :setting_style_underline_a, as: :boolean, wrapper: :with_label
+    = f.input :setting_style_lowercase, as: :boolean, wrapper: :with_label
+
   %h4= t 'appearance.toot_layout'
 
   .fields-group
@@ -60,5 +64,29 @@
   .fields-group
     = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label
 
+  %h4= t 'appearance.custom_css'
+
+  .fields-group
+    = f.input :setting_style_css_profile, as: :text, wrapper: :with_label
+
+    - if current_user.setting_style_css_profile_errors.present?
+      %p
+        %strong= t('appearance.custom_css_error')
+
+      %ul
+        - current_user.setting_style_css_profile_errors.each do |error|
+          %li.hint= error
+
+  .fields-group
+    = f.input :setting_style_css_webapp, as: :text, wrapper: :with_label
+
+    - if current_user&.setting_style_css_webapp_errors.present?
+      %p
+        %strong= t('appearance.custom_css_error')
+
+      %ul
+        - current_user.setting_style_css_webapp_errors.each do |error|
+          %li.hint= error
+
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/filters/show.html.haml b/app/views/settings/preferences/filters/show.html.haml
new file mode 100644
index 000000000..17fa4c4a6
--- /dev/null
+++ b/app/views/settings/preferences/filters/show.html.haml
@@ -0,0 +1,24 @@
+- content_for :page_title do
+  = t('settings.preferences')
+
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_preferences'
+
+= simple_form_for current_user, url: settings_preferences_filters_path, html: { method: :put, id: 'edit_preferences' } do |f|
+  = render 'shared/error_messages', object: current_user
+
+  %h4= t 'preferences.filtering'
+
+  .fields-group
+    = f.input :setting_home_reblogs, as: :boolean, wrapper: :with_label
+
+  .fields-group
+    = f.input :setting_filter_unknown, as: :boolean, wrapper: :with_label
+
+  %h4= t 'preferences.public_timelines'
+
+  .fields-group
+    = f.input :chosen_languages, collection: filterable_languages.sort, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index d7cc1ed5d..9cbdfc3cd 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -7,6 +7,11 @@
 = simple_form_for current_user, url: settings_preferences_notifications_path, html: { method: :put, id: 'edit_notification' } do |f|
   = render 'shared/error_messages', object: current_user
 
+  %h4= t 'notifications.web_settings'
+
+  .fields-group
+    = f.input :setting_web_push, as: :boolean, wrapper: :with_label
+
   %h4= t 'notifications.email_events'
 
   %p.hint= t 'notifications.email_events_hint'
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 3b5c7016d..b85b24a58 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -7,41 +7,16 @@
 = simple_form_for current_user, url: settings_preferences_other_path, html: { method: :put, id: 'edit_preferences' } do |f|
   = render 'shared/error_messages', object: current_user
 
-  .fields-group
-    = f.input :setting_noindex, as: :boolean, wrapper: :with_label
-
-  .fields-group
-    = f.input :setting_hide_network, as: :boolean, wrapper: :with_label
-
-  .fields-group
-    = f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label, recommended: true
-
-  - unless Setting.hide_followers_count
-    .fields-group
-      = f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label
-
   %h4= t 'preferences.posting_defaults'
 
-  .fields-row
-    .fields-group.fields-row__column.fields-row__column-6
-      = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false
-
-    .fields-group.fields-row__column.fields-row__column-6
-      = f.input :setting_default_language, collection: [nil] + filterable_languages.sort, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.language_detection') : human_locale(locale) }, required: false, include_blank: false, hint: false
-
   .fields-group
     = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
 
   .fields-group
-    = f.input :setting_show_application, as: :boolean, wrapper: :with_label, recommended: true
+    = f.input :setting_default_language, collection: [nil] + filterable_languages.sort, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.language_detection') : human_locale(locale) }, required: false, include_blank: false, hint: false
 
   .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'
 
-  %h4= t 'preferences.public_timelines'
-
-  .fields-group
-    = f.input :chosen_languages, collection: filterable_languages.sort, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
-
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/privacy/show.html.haml b/app/views/settings/preferences/privacy/show.html.haml
new file mode 100644
index 000000000..55d27844a
--- /dev/null
+++ b/app/views/settings/preferences/privacy/show.html.haml
@@ -0,0 +1,37 @@
+- content_for :page_title do
+  = t('settings.preferences')
+
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_preferences'
+
+= simple_form_for current_user, url: settings_preferences_privacy_path, html: { method: :put, id: 'edit_preferences' } do |f|
+  = render 'shared/error_messages', object: current_user
+
+  %h4= t 'preferences.privacy'
+
+  .fields-row
+    .fields-group.fields-row__column.fields-row__column-6
+      = f.input :setting_max_history_public, collection: Status::HISTORY_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("history.#{m}") }, required: false, include_blank: false
+
+    .fields-group.fields-row__column.fields-row__column-6
+      = f.input :setting_max_history_private, collection: Status::HISTORY_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("history.#{m}") }, required: false, include_blank: false
+
+
+  .fields-group
+    = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false
+
+  .fields-group
+    = f.input :setting_show_application, as: :boolean, wrapper: :with_label
+
+  .fields-group
+    = f.input :setting_noindex, as: :boolean, wrapper: :with_label
+
+  .fields-group
+    = f.input :setting_hide_network, as: :boolean, wrapper: :with_label
+
+  - unless Setting.hide_followers_count
+    .fields-group
+      = f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/publishing/show.html.haml b/app/views/settings/preferences/publishing/show.html.haml
new file mode 100644
index 000000000..9fe76f385
--- /dev/null
+++ b/app/views/settings/preferences/publishing/show.html.haml
@@ -0,0 +1,23 @@
+- content_for :page_title do
+  = t('settings.preferences')
+
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_preferences'
+
+= simple_form_for current_user, url: settings_preferences_publishing_path, html: { method: :put, id: 'edit_preferences' } do |f|
+  = render 'shared/error_messages', object: current_user
+
+  %h4= t 'preferences.advanced_publishing'
+
+  .fields-row
+    .fields-group.fields-row__column.fields-row__column-6
+      = f.input :setting_manual_publish, as: :boolean, wrapper: :with_label
+      = f.input :setting_unpublish_on_delete, as: :boolean, wrapper: :with_label
+
+    .fields-group.fields-row__column.fields-row__column-6
+      = f.input :setting_publish_in, collection: Status::TIMER_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("timer.#{m}") }, required: false, include_blank: false, hint: false
+      = f.input :setting_unpublish_in, collection: Status::TIMER_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("timer.#{m}") }, required: false, include_blank: false, hint: false
+      = f.input :setting_unpublish_delete, as: :boolean, wrapper: :with_label
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 6061e9cfd..8c6c4c933 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -24,14 +24,37 @@
   %hr.spacer/
 
   .fields-group
-    = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked')
+    = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
+  
+  %h4= t 'settings.profiles.privacy'
+
+  %p.hint= t 'settings.profiles.privacy_html'
 
   .fields-group
-    = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
+    = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked')
 
   - if Setting.profile_directory
     .fields-group
-      = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true
+      = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable')
+
+  .fields-group
+    = f.input :show_replies, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.show_replies')
+  
+  .fields-group
+    = f.input :show_unlisted, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.show_unlisted')
+
+  .fields-group
+    = f.input :private, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.private')
+
+  %h4= t 'settings.profiles.compatibility'
+
+  %p.hint= t 'settings.profiles.compatibility_html'
+
+  .fields-group
+    = f.input :no_verify_auth, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.no_verify_auth_html')
+
+  .fields-group
+    = f.input :allow_anonymous, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.allow_anonymous_html')
 
   %hr.spacer/
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index a4dd8534f..b66a3c5ee 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -15,13 +15,16 @@
 
   = account_action_button(status.account)
 
-  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
-    - if status.spoiler_text?
-      %p<
-        %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
+  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.title? || status.spoiler_text?) }
+    - if status.title? || status.spoiler_text?
+      %div.spoiler
+        = fa_icon 'info-circle fw'
+        %span.p-summary= Formatter.instance.format_spoiler(status, autoplay: autoplay)
+    - if status.title? || status.spoiler_text? || parent_status&.spoiler_text?
+      %div
         %button.status__content__spoiler-link= t('statuses.show_more')
     .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
-      = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
+      = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay, article_content: true)
       - if status.preloadable_poll
         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
           = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
@@ -29,17 +32,17 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 670, height: 380, detailed: true, inline: true, alt: video.description do
+      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), width: 670, height: 380, detailed: true, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
-      = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
+      = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta&.dig('colors', 'background'), foregroundColor: audio.file.meta&.dig('colors', 'foreground'), accentColor: audio.file.meta&.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta&.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
-      = react_component :media_gallery, height: 380, sensitive: sensitized?(status, current_account), standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
+      = react_component :media_gallery, height: 380, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 199061c46..8a5b65b64 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -21,13 +21,20 @@
           %span.display-name__account
             = acct(status.account)
             = fa_icon('lock') if status.account.locked?
-  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
-    - if status.spoiler_text?
-      %p<
-        %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
+  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.title? || status.spoiler_text? || parent_status&.spoiler_text?) }<
+    - if parent_status&.spoiler_text?
+      %div.spoiler.reblog-spoiler
+        = fa_icon 'retweet fw'
+        %span.p-summary= Formatter.instance.format_spoiler(parent_status, autoplay: autoplay)
+    - if status.title? || status.spoiler_text?
+      %div.spoiler
+        = fa_icon 'info-circle fw'
+        %span.p-summary= Formatter.instance.format_spoiler(status, autoplay: autoplay)
+    - if status.title? || status.spoiler_text? || parent_status&.spoiler_text?
+      %div
         %button.status__content__spoiler-link= t('statuses.show_more')
     .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }<
-      = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
+      = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay, article_content: true)
       - if status.preloadable_poll
         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
           = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
@@ -35,17 +42,17 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 610, height: 343, inline: true, alt: video.description do
+      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), width: 610, height: 343, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
-      = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
+      = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta&.dig('colors', 'background'), foregroundColor: audio.file.meta&.dig('colors', 'foreground'), accentColor: audio.file.meta&.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta&.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
-      = react_component :media_gallery, height: 343, sensitive: sensitized?(status, current_account), autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
+      = react_component :media_gallery, height: 343, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   - if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id
     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
diff --git a/app/views/statuses/_status.html.haml b/app/views/statuses/_status.html.haml
index 650f9b679..64f92782b 100644
--- a/app/views/statuses/_status.html.haml
+++ b/app/views/statuses/_status.html.haml
@@ -27,19 +27,12 @@
     .status__prepend
       .status__prepend-icon-wrapper
         %i.status__prepend-icon.fa.fa-fw.fa-retweet
-      %span
-        = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name muted' do
-          %bdi
-            %strong.emojify= display_name(status.account, custom_emojify: true)
-        = t('stream_entries.reblogged')
   - elsif pinned
     .status__prepend
       .status__prepend-icon-wrapper
         %i.status__prepend-icon.fa.fa-fw.fa-thumb-tack
-      %span
-        = t('stream_entries.pinned')
 
-  = render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, autoplay: autoplay
+  = render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, autoplay: autoplay, parent_status: status
 
 - if include_threads
   - if @since_descendant_thread_id
diff --git a/app/views/statuses/show.html.haml b/app/views/statuses/show.html.haml
index 7ef7b09a2..b7c2ed68d 100644
--- a/app/views/statuses/show.html.haml
+++ b/app/views/statuses/show.html.haml
@@ -17,6 +17,10 @@
   = render 'og_description', activity: @status
   = render 'og_image', activity: @status, account: @account
 
+- content_for :header_overrides do
+  - if @account&.user&.setting_style_css_profile.present?
+    = stylesheet_link_tag user_profile_css_path(id: @account.id), media: 'all'
+
 .grid
   .column-0
     .activity-stream.h-entry
diff --git a/app/workers/account_defederation_worker.rb b/app/workers/account_defederation_worker.rb
new file mode 100644
index 000000000..150ed8ff0
--- /dev/null
+++ b/app/workers/account_defederation_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AccountDefederationWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, domains)
+    DefederateAccountService.new.call(Account.find(account_id), domains)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/activitypub/distribute_poll_update_worker.rb b/app/workers/activitypub/distribute_poll_update_worker.rb
index 601075ea6..ed5447341 100644
--- a/app/workers/activitypub/distribute_poll_update_worker.rb
+++ b/app/workers/activitypub/distribute_poll_update_worker.rb
@@ -24,7 +24,7 @@ class ActivityPub::DistributePollUpdateWorker
   private
 
   def relayable?
-    @status.public_visibility?
+    @status.public_visibility? && !@account.private?
   end
 
   def inboxes
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index 9b4814644..4d7527b46 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -6,14 +6,16 @@ class ActivityPub::DistributionWorker
 
   sidekiq_options queue: 'push'
 
-  def perform(status_id)
+  def perform(status_id, options = {})
+    @options = options.with_indifferent_access
     @status  = Status.find(status_id)
     @account = @status.account
+    @payload = {}
 
     return if skip_distribution?
 
     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }]
+      [payload(inbox_url), @account.id, inbox_url, { synchronize_followers: !@status.distributable? }]
     end
 
     relay! if relayable?
@@ -24,31 +26,34 @@ class ActivityPub::DistributionWorker
   private
 
   def skip_distribution?
-    @status.direct_visibility? || @status.limited_visibility?
+    !@status.published? || @status.direct_visibility? || @status.limited_visibility?
   end
 
   def relayable?
-    @status.public_visibility?
+    @status.public_visibility? && !@account.private?
   end
 
   def inboxes
+    return Account.remote.without_suspended.inboxes if @options[:all_servers] || @account.id == -99
+
     # Deliver the status to all followers.
     # If the status is a reply to another local status, also forward it to that
     # status' authors' followers.
-    @inboxes ||= if @status.reply? && @status.thread.account.local? && @status.distributable?
+    @inboxes ||= if @status.reply? && @status.thread&.account&.local? && @status.distributable?
                    @account.followers.or(@status.thread.account.followers).inboxes
                  else
                    @account.followers.inboxes
                  end
   end
 
-  def payload
-    @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account))
+  def payload(inbox_url)
+    domain = Addressable::URI.parse(inbox_url).normalized_host
+    @payload[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, domain, update: true), ActivityPub::ActivitySerializer, signer: @account, domain: domain))
   end
 
   def relay!
     ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
-      [payload, @account.id, inbox_url]
+      [payload(inbox_url), @account.id, inbox_url]
     end
   end
 end
diff --git a/app/workers/activitypub/process_collection_items_for_account_worker.rb b/app/workers/activitypub/process_collection_items_for_account_worker.rb
new file mode 100644
index 000000000..4b5710c1d
--- /dev/null
+++ b/app/workers/activitypub/process_collection_items_for_account_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+class ActivityPub::ProcessCollectionItemsForAccountWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: 3
+
+  def perform(account_id)
+    @account_id = account_id
+    on_behalf_of = nil
+
+    if account_id.present?
+      account = Account.find(account_id)
+      on_behalf_of = account.followers.local.random.first
+    end
+
+    ActivityPub::ProcessCollectionItemsService.new.call(account_id, on_behalf_of)
+  rescue ActiveRecord::RecordNotFound
+    nil
+  end
+end
diff --git a/app/workers/activitypub/process_collection_items_worker.rb b/app/workers/activitypub/process_collection_items_worker.rb
new file mode 100644
index 000000000..d830edaec
--- /dev/null
+++ b/app/workers/activitypub/process_collection_items_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+class ActivityPub::ProcessCollectionItemsWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: 0
+
+  def perform
+    return if Sidekiq::Stats.new.workers_size > 3
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        account_id = random_unprocessed_account_id
+        ActivityPub::ProcessCollectionItemsForAccountWorker.perform_async(account_id) if account_id.present?
+      end
+    end
+  end
+
+  private
+
+  def random_unprocessed_account_id
+    CollectionItem.unprocessed.pluck(:account_id).sample
+  end
+
+  def lock_options
+    { redis: Redis.current, key: 'process_collection_items' }
+  end
+end
diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb
index 41e61132f..3c65e4cd0 100644
--- a/app/workers/activitypub/raw_distribution_worker.rb
+++ b/app/workers/activitypub/raw_distribution_worker.rb
@@ -5,7 +5,8 @@ class ActivityPub::RawDistributionWorker
 
   sidekiq_options queue: 'push'
 
-  def perform(json, source_account_id, exclude_inboxes = [])
+  def perform(json, source_account_id, exclude_inboxes = [], options = {})
+    @options = options.with_indifferent_access
     @account = Account.find(source_account_id)
 
     ActivityPub::DeliveryWorker.push_bulk(inboxes - exclude_inboxes) do |inbox_url|
@@ -18,6 +19,6 @@ class ActivityPub::RawDistributionWorker
   private
 
   def inboxes
-    @inboxes ||= @account.followers.inboxes
+    @inboxes ||= (@options[:all_servers] || @account.id == -99 ? Account.remote.without_suspended.inboxes : @account.followers.inboxes)
   end
 end
diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
index d4d0148ac..437a47a6e 100644
--- a/app/workers/activitypub/reply_distribution_worker.rb
+++ b/app/workers/activitypub/reply_distribution_worker.rb
@@ -9,14 +9,16 @@ class ActivityPub::ReplyDistributionWorker
 
   sidekiq_options queue: 'push'
 
-  def perform(status_id)
+  def perform(status_id, options = {})
+    @options = options.with_indifferent_access
     @status  = Status.find(status_id)
     @account = @status.thread&.account
+    @payload = {}
 
     return unless @account.present? && @status.distributable?
 
     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [payload, @status.account_id, inbox_url]
+      [payload(inbox_url), @status.account_id, inbox_url]
     end
   rescue ActiveRecord::RecordNotFound
     true
@@ -25,10 +27,11 @@ class ActivityPub::ReplyDistributionWorker
   private
 
   def inboxes
-    @inboxes ||= @account.followers.inboxes
+    @inboxes ||= (@options[:all_servers] || @account.id == -99 ? Account.remote.without_suspended.inboxes : @account.followers.inboxes)
   end
 
-  def payload
-    @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
+  def payload(inbox_url)
+    domain = Addressable::URI.parse(inbox_url).normalized_host
+    @payload[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, domain, update: true), ActivityPub::ActivitySerializer, signer: @status.account, domain: domain))
   end
 end
diff --git a/app/workers/activitypub/sync_account_worker.rb b/app/workers/activitypub/sync_account_worker.rb
new file mode 100644
index 000000000..18825b20d
--- /dev/null
+++ b/app/workers/activitypub/sync_account_worker.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+class ActivityPub::SyncAccountWorker
+  include Sidekiq::Worker
+  include ExponentialBackoff
+
+  sidekiq_options queue: 'pull', retry: 5
+
+  def perform(account_id, every_page = false, skip_cooldown = false)
+    @account = Account.find(account_id)
+    return if @account.local?
+
+    @from_migrated_account = @account.moved_to_account&.local?
+    return unless @from_migrated_account || @account.followers.local.exists?
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        fetch_collection_items(every_page, skip_cooldown)
+      elsif @from_migrated_account
+        # Cause a retry so server-to-server migrations can complete.
+        raise Mastodon::RaceConditionError
+      end
+    end
+  rescue ActiveRecord::RecordNotFound
+    nil
+  end
+
+  private
+
+  def lock_options
+    { redis: Redis.current, key: "account_sync:#{@account.id}" }
+  end
+
+  # Limits for an account moving to this server.
+  def limits_migrated
+    {
+      page_limit: 2_000,
+      item_limit: 40_000,
+      look_ahead: true,
+    }
+  end
+
+  # Limits for an account someone locally follows.
+  def limits_followed
+    {
+      page_limit: 25,
+      item_limit: 500,
+      look_ahead: @account.last_synced_at.blank?,
+    }
+  end
+
+  def fetch_collection_items(every_page, skip_cooldown)
+    opts = @from_migrated_account && every_page ? limits_migrated : limits_followed
+    opts.merge!({ every_page: every_page, skip_cooldown: skip_cooldown })
+    ActivityPub::FetchCollectionItemsService.new.call(@account.outbox_url, @account, **opts)
+    @account.update(last_synced_at: Time.now.utc)
+  end
+end
diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb
index 3a207f071..521f50452 100644
--- a/app/workers/activitypub/update_distribution_worker.rb
+++ b/app/workers/activitypub/update_distribution_worker.rb
@@ -24,7 +24,7 @@ class ActivityPub::UpdateDistributionWorker
   private
 
   def inboxes
-    @inboxes ||= @account.followers.inboxes
+    @inboxes ||= (@options[:all_servers] || @account.id == -99 ? Account.remote.without_suspended.inboxes : @account.followers.inboxes)
   end
 
   def signed_payload
diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb
index 25f5dd808..20bb10408 100644
--- a/app/workers/block_worker.rb
+++ b/app/workers/block_worker.rb
@@ -3,10 +3,11 @@
 class BlockWorker
   include Sidekiq::Worker
 
-  def perform(account_id, target_account_id)
+  def perform(account_id, target_account_id, options = {})
     AfterBlockService.new.call(
       Account.find(account_id),
-      Account.find(target_account_id)
+      Account.find(target_account_id),
+      defederate: options['defederate']
     )
   end
 end
diff --git a/app/workers/clear_reblogs_worker.rb b/app/workers/clear_reblogs_worker.rb
new file mode 100644
index 000000000..69c8afc59
--- /dev/null
+++ b/app/workers/clear_reblogs_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ClearReblogsWorker
+  include Sidekiq::Worker
+
+  def perform(account_id)
+    FeedManager.instance.clear_reblogs_from_home(Account.find(account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb
index 4e20ef31b..049d2732b 100644
--- a/app/workers/distribution_worker.rb
+++ b/app/workers/distribution_worker.rb
@@ -3,10 +3,11 @@
 class DistributionWorker
   include Sidekiq::Worker
 
-  def perform(status_id)
+  def perform(status_id, only_to_self = false)
     RedisLock.acquire(redis: Redis.current, key: "distribute:#{status_id}") do |lock|
       if lock.acquired?
-        FanOutOnWriteService.new.call(Status.find(status_id))
+        status = Status.find(status_id)
+        FanOutOnWriteService.new.call(status, only_to_self: !status.published? || only_to_self || !status.notify?)
       else
         raise Mastodon::RaceConditionError
       end
diff --git a/app/workers/domain_defederation_worker.rb b/app/workers/domain_defederation_worker.rb
new file mode 100644
index 000000000..ec49d0265
--- /dev/null
+++ b/app/workers/domain_defederation_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class DomainDefederationWorker
+  include Sidekiq::Worker
+
+  def perform(domains)
+    DefederateDomainService.new.call(domains)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/fetch_reply_worker.rb b/app/workers/fetch_reply_worker.rb
index f7aa25e81..67db042fd 100644
--- a/app/workers/fetch_reply_worker.rb
+++ b/app/workers/fetch_reply_worker.rb
@@ -6,7 +6,12 @@ class FetchReplyWorker
 
   sidekiq_options queue: 'pull', retry: 3
 
-  def perform(child_url)
-    FetchRemoteStatusService.new.call(child_url)
+  def perform(child_url, account_id = nil)
+    account = account_id.blank? ? nil : Account.find_by(id: account_id)
+    on_behalf_of = account.blank? ? nil : account.followers.local.random.first
+
+    FetchRemoteStatusService.new.call(child_url, nil, on_behalf_of)
+  rescue ActiveRecord::RecordNotFound
+    nil
   end
 end
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
index b3d8aa264..32e51537d 100644
--- a/app/workers/link_crawl_worker.rb
+++ b/app/workers/link_crawl_worker.rb
@@ -6,7 +6,8 @@ class LinkCrawlWorker
   sidekiq_options queue: 'pull', retry: 0
 
   def perform(status_id)
-    FetchLinkCardService.new.call(Status.find(status_id))
+    status = Status.find(status_id)
+    FetchLinkCardService.new.call(status) if status.published?
   rescue ActiveRecord::RecordNotFound
     true
   end
diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb
index 39e321316..4e155546f 100644
--- a/app/workers/move_worker.rb
+++ b/app/workers/move_worker.rb
@@ -16,6 +16,9 @@ class MoveWorker
     copy_account_notes!
     carry_blocks_over!
     carry_mutes_over!
+    return unless @target_account.local?
+
+    ActivityPub::SyncAccountWorker.perform_async(@source_account.id, every_page: true, skip_cooldown: true)
   rescue ActiveRecord::RecordNotFound
     true
   end
diff --git a/app/workers/mute_conversation_worker.rb b/app/workers/mute_conversation_worker.rb
new file mode 100644
index 000000000..efe6dd539
--- /dev/null
+++ b/app/workers/mute_conversation_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class MuteConversationWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, conversation_id)
+    FeedManager.instance.unpush_conversation(Account.find(account_id), Conversation.find(conversation_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/publish_scheduled_status_worker.rb b/app/workers/publish_scheduled_status_worker.rb
index ce42f7be7..a5166f6a8 100644
--- a/app/workers/publish_scheduled_status_worker.rb
+++ b/app/workers/publish_scheduled_status_worker.rb
@@ -21,6 +21,8 @@ class PublishScheduledStatusWorker
     options.tap do |options_hash|
       options_hash[:application] = Doorkeeper::Application.find(options_hash.delete(:application_id)) if options[:application_id]
       options_hash[:thread]      = Status.find(options_hash.delete(:in_reply_to_id)) if options_hash[:in_reply_to_id]
+      options_hash[:mentions]    = Mention.where(id: options_hash.delete(:mention_ids)) if options_hash[:mention_ids]
+      options_hash[:status]      = Status.find_by(id: options_hash.delete(:status_id)) if options_hash[:status_id]
     end
   end
 end
diff --git a/app/workers/redownload_media_worker.rb b/app/workers/redownload_media_worker.rb
index 0638cd0f0..0ead9a7a8 100644
--- a/app/workers/redownload_media_worker.rb
+++ b/app/workers/redownload_media_worker.rb
@@ -11,10 +11,27 @@ class RedownloadMediaWorker
 
     return if media_attachment.remote_url.blank?
 
+    orig_small_url = media_attachment.file.url(:small)
+
     media_attachment.download_file!
     media_attachment.download_thumbnail!
-    media_attachment.save
+
+    if media_attachment.save && media_attachment.inline? && media_attachment.status.present?
+      if unsupported_media_type?(media_attachment.file.content_type)
+        media_attachment.destroy
+        true
+      else
+        media_attachment.status.text.gsub!("#{orig_small_url}##{media_attachment.id}", media_attachment.file.url(:small))
+        media_attachment.status.save
+      end
+    end
   rescue ActiveRecord::RecordNotFound
     true
   end
+
+  private
+
+  def unsupported_media_type?(mime_type)
+    mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
+  end
 end
diff --git a/app/workers/remove_media_attachments_worker.rb b/app/workers/remove_media_attachments_worker.rb
new file mode 100644
index 000000000..d5bac6ab8
--- /dev/null
+++ b/app/workers/remove_media_attachments_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RemoveMediaAttachmentsWorker
+  include Sidekiq::Worker
+
+  def perform(attachment_ids)
+    RemoveMediaAttachmentsService.new.call(attachment_ids)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/reset_account_worker.rb b/app/workers/reset_account_worker.rb
new file mode 100644
index 000000000..f63d8682a
--- /dev/null
+++ b/app/workers/reset_account_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class ResetAccountWorker
+  include Sidekiq::Worker
+
+  def perform(account_id)
+    account = Account.find(account_id)
+    return if account.local?
+
+    account_uri = account.uri
+    SuspendAccountService.new.call(account)
+    ResolveAccountService.new.call(account_uri)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/revoke_status_worker.rb b/app/workers/revoke_status_worker.rb
new file mode 100644
index 000000000..8cc2b1623
--- /dev/null
+++ b/app/workers/revoke_status_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RevokeStatusWorker
+  include Sidekiq::Worker
+
+  def perform(status_id, account_ids)
+    RevokeStatusService.new.call(Status.find(status_id), account_ids)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/scheduler/ambassador_scheduler.rb b/app/workers/scheduler/ambassador_scheduler.rb
new file mode 100644
index 000000000..f00d0912a
--- /dev/null
+++ b/app/workers/scheduler/ambassador_scheduler.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class Scheduler::AmbassadorScheduler
+  include Sidekiq::Worker
+  sidekiq_options lock: :until_executed, retry: 0
+
+  def perform
+    @ambassador = find_ambassador_acct
+    return if @ambassador.nil?
+
+    status = next_boost
+    return if status.nil?
+
+    ReblogService.new.call(@ambassador, status)
+  end
+
+  private
+
+  def find_ambassador_acct
+    ambassador = ENV['AMBASSADOR_USER'].to_i
+    return Account.find_by(id: ambassador) unless ambassador.zero?
+
+    ambassador = ENV['AMBASSADOR_USER']
+    return if ambassador.blank?
+
+    Account.find_local(ambassador)
+  end
+
+  def next_boost
+    ambassador_boost_candidates.first
+  end
+
+  def ambassador_boost_candidates
+    ambassador_boostable.joins(:status_stat).where('favourites_count + reblogs_count >= ?', ENV.fetch('AMBASSADOR_THRESHOLD', 3).to_i)
+  end
+
+  def ambassador_boostable
+    ambassador_unboosted.excluding_silenced_accounts.not_excluded_by_account(@ambassador)
+  end
+
+  def ambassador_unboosted
+    locally_boostable.where.not(id: ambassador_boosts)
+  end
+
+  def ambassador_boosts
+    @ambassador.statuses.where('statuses.reblog_of_id IS NOT NULL').reorder(nil).select(:reblog_of_id)
+  end
+
+  def locally_boostable
+    Status.local
+          .public_visibility
+          .without_replies
+          .without_reblogs
+          .where('statuses.created_at > ?', ENV.fetch('AMBASSADOR_RANGE', 14).days.ago)
+  end
+end
diff --git a/app/workers/scheduler/database_cleanup_scheduler.rb b/app/workers/scheduler/database_cleanup_scheduler.rb
new file mode 100644
index 000000000..033556099
--- /dev/null
+++ b/app/workers/scheduler/database_cleanup_scheduler.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Scheduler::DatabaseCleanupScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options lock: :until_executed, retry: 0
+
+  def perform
+    Conversation.left_outer_joins(:statuses).where(statuses: { id: nil }).destroy_all
+    Tag.left_outer_joins(:statuses).where(statuses: { id: nil }).destroy_all
+    StatusStat.left_outer_joins(:status).where(statuses: { id: nil }).destroy_all
+    Setting.rewhere(thing_type: 'User').where.not(thing_id: User.select(:id)).destroy_all
+  end
+end
diff --git a/app/workers/scheduler/publish_status_scheduler.rb b/app/workers/scheduler/publish_status_scheduler.rb
new file mode 100644
index 000000000..27fac39e1
--- /dev/null
+++ b/app/workers/scheduler/publish_status_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Scheduler::PublishStatusScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options lock: :until_executed, retry: 0
+
+  def perform
+    Status.ready_to_publish.find_each { |status| PublishStatusService.new.call(status) }
+  end
+end
diff --git a/app/workers/scheduler/status_cleanup_scheduler.rb b/app/workers/scheduler/status_cleanup_scheduler.rb
new file mode 100644
index 000000000..161818355
--- /dev/null
+++ b/app/workers/scheduler/status_cleanup_scheduler.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Scheduler::StatusCleanupScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options lock: :until_executed, retry: 0
+
+  def perform
+    Status.with_discarded.expired.find_each do |status|
+      RemoveStatusService.new.call(status, unpublish: !(status.discarded? || status.account&.user&.setting_unpublish_delete))
+    end
+  end
+end
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index 8571b59e1..4213c243e 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -17,6 +17,11 @@ class Scheduler::UserCleanupScheduler
       Account.where(id: batch.map(&:account_id)).delete_all
       User.where(id: batch.map(&:id)).delete_all
     end
+
+    User.where(kobold: '', approved: false).find_in_batches do |batch|
+      Account.where(id: batch.map(&:account_id)).delete_all
+      User.where(id: batch.map(&:id)).delete_all
+    end
   end
 
   def clean_suspended_accounts!
diff --git a/app/workers/softblock_worker.rb b/app/workers/softblock_worker.rb
new file mode 100644
index 000000000..a4624868c
--- /dev/null
+++ b/app/workers/softblock_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class SoftblockWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, target_account_id)
+    account        = Account.find(account_id)
+    target_account = Account.find(target_account_id)
+
+    BlockService.new.call(account, target_account, softblock: true)
+    sleep 1
+    UnblockService.new.call(account, target_account)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 8bba9ca75..a1915a16f 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -6,13 +6,16 @@ class ThreadResolveWorker
 
   sidekiq_options queue: 'pull', retry: 3
 
-  def perform(child_status_id, parent_url)
+  def perform(child_status_id, parent_url, on_behalf_of = nil)
     child_status  = Status.find(child_status_id)
-    parent_status = FetchRemoteStatusService.new.call(parent_url)
+    on_behalf_of  = child_status.account.followers.local.random.first if on_behalf_of.nil? && !child_status.distributable?
+    parent_status = FetchRemoteStatusService.new.call(parent_url, nil, on_behalf_of)
 
     return if parent_status.nil?
 
     child_status.thread = parent_status
     child_status.save!
+  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound
+    nil
   end
 end
diff --git a/config/application.rb b/config/application.rb
index af7735221..1fb79a4aa 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -47,6 +47,7 @@ module Mastodon
     # All translations from config/locales/*.rb,yml are auto loaded.
     # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
     config.i18n.available_locales = [
+      :'en-MP',
       :ar,
       :ast,
       :bg,
@@ -122,10 +123,11 @@ module Mastodon
       :'zh-TW',
     ]
 
-    config.i18n.default_locale = ENV['DEFAULT_LOCALE']&.to_sym
+    config.i18n.default_locale = ENV.fetch('DEFAULT_LOCALE', 'en-MP')&.to_sym
+    config.i18n.fallbacks = [:'en-MP', :en]
 
     unless config.i18n.available_locales.include?(config.i18n.default_locale)
-      config.i18n.default_locale = :en
+      config.i18n.default_locale = :'en-MP'
     end
 
     # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 0791b82ab..a1a9bad5b 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -7,7 +7,7 @@ Rails.application.configure do
   config.cache_classes = false
 
   # Do not eager load code on boot.
-  config.eager_load = false
+  config.eager_load = true
 
   # Show full error reports.
   config.consider_all_requests_local = true
diff --git a/config/environments/production.rb b/config/environments/production.rb
index c2e8210f8..e5900a0bb 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -60,7 +60,7 @@ Rails.application.configure do
 
   # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
   # English when a translation cannot be found).
-  config.i18n.fallbacks = [:en]
+  config.i18n.fallbacks = [:'en-MP', :en]
 
   # Send deprecation notices to registered listeners.
   config.active_support.deprecation = :notify
diff --git a/config/environments/test.rb b/config/environments/test.rb
index a35cadcfa..7bdfe15e7 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -54,8 +54,8 @@ Rails.application.configure do
   # Raises error for missing translations
   # config.action_view.raise_on_missing_translations = true
 
-  config.i18n.default_locale = :en
-  config.i18n.fallbacks = true
+  config.i18n.default_locale = :'en-MP'
+  config.i18n.fallbacks = [:'en-MP', :en]
 end
 
 Paperclip::Attachment.default_options[:path] = "#{Rails.root}/spec/test_files/:class/:id_partition/:style.:extension"
diff --git a/config/initializers/2_whitelist_mode.rb b/config/initializers/2_whitelist_mode.rb
index 1cc6a8e72..3ac6d7a09 100644
--- a/config/initializers/2_whitelist_mode.rb
+++ b/config/initializers/2_whitelist_mode.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 Rails.application.configure do
-  config.x.whitelist_mode = (ENV['LIMITED_FEDERATION_MODE'] || ENV['WHITELIST_MODE']) == 'true'
+  config.x.whitelist_mode = true
 end
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index 63cff7c59..1c790e90a 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -76,6 +76,10 @@ Doorkeeper.configure do
                   :'write:notifications',
                   :'write:reports',
                   :'write:statuses',
+                  :'write:statuses:publish',
+                  :'write:domain_permissions',
+                  :'write:domain_permissions:account',
+                  :'write:domain_permissions:statuses',
                   :read,
                   :'read:accounts',
                   :'read:blocks',
@@ -88,11 +92,16 @@ Doorkeeper.configure do
                   :'read:notifications',
                   :'read:search',
                   :'read:statuses',
+                  :'read:domain_permissions',
+                  :'read:domain_permissions:account',
+                  :'read:domain_permissions:statuses',
                   :follow,
                   :push,
                   :'admin:read',
                   :'admin:read:accounts',
                   :'admin:read:reports',
+                  :'admin:read:domain_blocks',
+                  :'admin:read:domain_allows',
                   :'admin:write',
                   :'admin:write:accounts',
                   :'admin:write:reports',
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index ebb7541eb..4170b69ba 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -22,4 +22,6 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
   inflect.acronym 'Ed25519'
 
   inflect.singular 'data', 'data'
+
+  inflect.irregular 'publish', 'publishing'
 end
diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb
index fed182a71..aa271dc56 100644
--- a/config/initializers/locale.rb
+++ b/config/initializers/locale.rb
@@ -5,3 +5,4 @@ I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'nam
 I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names.{rb,yml}').to_s]
 I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names', '*.{rb,yml}').to_s]
 I18n.load_path += Dir[Rails.root.join('config', 'locales-glitch', '*.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('config', 'locales-monsterfork', '*.{rb,yml}').to_s]
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 6662ef40b..4904b8d57 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -109,6 +109,10 @@ class Rack::Attack
     req.session[:attempt_user_id] || req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/sign_in'
   end
 
+  throttle('throttle_matrix_auth_attempts/ip', limit: 5, period: 1.minute) do |req|
+    req.remote_ip if req.path == '/_matrix-internal/identity/v1/check_credentials'
+  end
+
   self.throttled_response = lambda do |env|
     now        = Time.now.utc
     match_data = env['rack.attack.match_data']
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index f2733562f..2ed0554de 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -15,8 +15,8 @@ Sidekiq.configure_server do |config|
   end
 
   config.death_handlers << lambda do |job, _ex|
-    digest = job['lock_digest']
-    SidekiqUniqueJobs::Digests.delete_by_digest(digest) if digest
+    SidekiqUniqueJobs::Digests.delete_by_digest(job['lock_digest']) if job['lock_digest']
+    SidekiqUniqueJobs::Digests.delete_by_digest(job['unique_digest']) if job['unique_digest']
   end
 end
 
diff --git a/config/locales/en-MP.yml b/config/locales/en-MP.yml
new file mode 100644
index 000000000..5cab7771c
--- /dev/null
+++ b/config/locales/en-MP.yml
@@ -0,0 +1,190 @@
+---
+en-MP:
+  about:
+    about_hashtag_html: These are public roars tagged with <strong>#%{hashtag}</strong> from around the fediverse.
+    available_content: Connected servers
+    available_content_html: 'Users and content from these servers can be interacted with from here:'
+    browse_local_posts: Browse a live stream of public roars from Monsterpit
+    browse_public_posts: Browse a live stream of public roars on the fediverse
+    federation_hint_html: Join Monsterpit and meet creatures around the fediverse.
+    hosted_on: Monsterfork hosted on %{domain}
+    instance_actor_flash: This account is a virtual actor used to represent the server itself. It is used for federation purposes and should not be blocked unless you want to block the whole server, in which case you should use a domain block.
+    unavailable_content: Admin overrides
+    unavailable_content_description:
+      silenced: "Posts from these servers will be hidden in public timelines and no notifications will be generated from their users' interactions, unless you are following them or vise-versa.  These are typically set for curation purposes rather than "
+  accounts:
+    endorsements_hint: You can endorse creatures you follow from the web interface, and they will show up here.
+    location:
+      claimed: Claimed
+      unclaimed: Unclaimed
+    people_followed_by: Creatures whom %{name} follows
+    people_who_follow: Creatures who follow %{name}
+    pin_errors:
+      following: You must be already following the creature you want to endorse
+    posts:
+      one: Roar
+      other: Roars
+    posts_tab_heading: Blog
+    posts_with_replies: Replies
+    reblogs: Boosts
+    threads: Threads
+    mentions: Mentions
+  admin:
+    accounts:
+      search_same_email_domain: Other creatures with the same e-mail domain
+      search_same_ip: Other creatures with the same IP
+    action_logs:
+      actions:
+        update_status: "%{name} updated roar by %{target}"
+      deleted_status: "(deleted roar)"
+    custom_emojis:
+      claim: Claim
+      owner: Contributor
+      ownership_warning: "NOTE: You can only make changes to custom emoji that you upload or copy unless you are a moderator."
+      unclaim: Unclaim
+      unclaimed: (unclaimed)
+    dashboard:
+      pending_users: creatures waiting for review
+      feature_hcaptcha: hCaptcha
+      recent_users: Recent creatures
+      single_user_mode: Single creature mode
+      total_users: creatures in total
+      week_users_new: creatures this week
+    domain_allows:
+      hidden: Exclude from public server list
+    relays:
+      description_html: A <strong>federation relay</strong> is an intermediary server that exchanges large volumes of public roars between servers that subscribe and publish to it. <strong>It can help small and medium servers discover content from the fediverse</strong>, which would otherwise require local users manually following other people on remote servers.
+      enable_hint: Once enabled, your server will subscribe to all public roars from this relay, and will begin sending this server's public toots to it.
+    settings:
+      activity_api_enabled:
+        desc_html: Counts of locally posted roars, active creatures, and new registrations in weekly buckets
+        title: Publish aggregate statistics about creature activity
+      bootstrap_timeline_accounts:
+        title: Default follows for new creatures
+      default_noindex:
+        desc_html: Affects all creatures who have not changed this setting themselves
+        title: Opt creatures out of search engine indexing by default
+      domain_allows:
+        title: Show allowed domains
+      domain_blocks:
+        users: To logged-in local creatures
+      enable_bootstrap_timeline_accounts:
+        title: Enable default follows for new creatures
+      profile_directory:
+        desc_html: Allow creatures to be discoverable
+      registrations:
+        errors:
+          captcha_fail: Captcha verification failed
+      show_staff_badge:
+        desc_html: Display staff badges on profiles
+  appearance:
+    toot_layout: Roar layout
+    custom_css: Custom CSS
+    custom_css_error: "There are problems with the above CSS that must be fixed before it can be applied:"
+    advanced_web_interface: Web interface
+  auth:
+    description:
+      prefix_invited_by_user: "@%{name} invites you to join Monsterpit!"
+      prefix_sign_up: Roar with Monsterpit!
+      suffix: On Monsterpit, you'll be able to commune with creatures across the fediverse!
+  authorize_follow:
+    already_following: You are already following this creature
+    already_requested: You have already sent a follow request to that creature
+    error: Unfortunately, there was an error looking up that creature's account
+    post_follow:
+      return: Show the creature's profile
+  domain_permissions:
+    success: Domain permissions saved!
+  existing_username_validator:
+    not_found: could not find a local creature with that username
+  exports:
+    archive_takeout:
+      hint_html: You can request an archive of your <strong>roars and media</strong>. The exported data will be in the ActivityPub format, readable by any compliant software. You can request an archive every 7 days.
+  history:
+    '0': All
+    1: 1 week
+    2: 2 weeks
+    3: 3 weeks
+    6: 6 weeks
+    12: 12 weeks
+    18: 18 weeks
+    24: 24 weeks
+    36: 36 weeks
+    52: 52 weeks
+    104: 104 weeks
+    156: 156 weeks
+  notifications:
+    web_settings: "Web interface options"
+  notification_mailer:
+    favourite:
+      body: 'Your status was admired by %{name}:'
+      subject: "%{name} admired your roar"
+      title: New admiration
+    reblog:
+      body: 'Your roar was boosted by %{name}:'
+      subject: "%{name} boosted your roar"
+      title: New roar
+  preferences:
+    advanced_publishing: Advanced publishing options
+    filtering: Filtering options
+    publishing: Advanced publishing
+    privacy: Privacy options
+  remote_interaction:
+    favourite:
+      proceed: Proceed to admire
+      prompt: 'You want to admire this roar:'
+    reblog:
+      proceed: Proceed to boost
+      prompt: 'You want to boost this roar:'
+    reply:
+      proceed: Proceed to reply
+      prompt: 'You want to reply to this roar:'
+  scheduled_statuses:
+    over_daily_limit: You have exceeded the limit of %{limit} scheduled roars for that day
+    over_total_limit: You have exceeded the limit of %{limit} scheduled roars
+  stream_entries:
+    pinned: ''
+    reblogged: ''
+  pin_errors:
+      limit: You have already pinned the maximum number of roars
+      ownership: Someone else's roar cannot be pinned
+      private: Non-public roar cannot be pinned
+  settings:
+    monsterfork: Monsterfork
+    profiles:
+      privacy: Privacy
+      privacy_html: These options allow you to adjust how much information is visible on your public profile on Monsterpit.  <strong>Note that the "show replies" and "show unlisted" settings are only honored by servers that support Monsterfork privacy extensions.</strong>
+      compatibility: Compatability
+      compatibility_html: These options will increase the compatability of your account and roars with older Fediverse servers at the expense of your privacy. <strong>Only use them if you're fully aware of their possible side effects, which are beyond the scope of support from Monsterpit's developers!</strong>
+  timer:
+    '0': Never
+    1: 1 minute
+    2: 2 minutes
+    3: 3 minutes
+    5: 5 minutes
+    10: 10 minutes
+    15: 15 minutes
+    30: 30 minutes
+    60: 1 hour
+    120: 2 hours
+    180: 3 hours
+    360: 6 hours
+    720: 12 hours
+    1440: 1 day
+    2880: 2 days
+    4320: 3 days
+    7200: 5 days
+    10080: 1 week
+    20160: 2 weeks
+    30240: 3 weeks
+    60480: 6 weeks
+    120960: 12 weeks
+    181440: 18 weeks
+    241920: 24 weeks
+    362880: 36 weeks
+    524160: 52 weeks
+  user_mailer:
+    warning:
+      explanation:
+        silence: While your account is limited, only creatures who are already following you will see your roars on this server, and you may be excluded from various public listings. However, others may still manually follow you.
+        suspend: Your account has been suspended, and all of your roars and your uploaded media files have been irreversibly removed from this server, and servers where you had followers.
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 263ffcdc7..ee855cc98 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -51,6 +51,8 @@ en:
       other: users
     user_count_before: Home to
     what_is_mastodon: What is Mastodon?
+    registration:
+      failed_kobold: 'Your application failed our anti-spam check.  You may try registering again.  If you continue to receive this error, contact the admin at the email listed above.'
   accounts:
     choices_html: "%{name}'s choices:"
     endorsements_hint: You can endorse people you follow from the web interface, and they will show up here.
diff --git a/config/locales/simple_form.en-MP.yml b/config/locales/simple_form.en-MP.yml
new file mode 100644
index 000000000..140861102
--- /dev/null
+++ b/config/locales/simple_form.en-MP.yml
@@ -0,0 +1,90 @@
+---
+en-MP:
+  simple_form:
+    hints:
+      account_warning_preset:
+        text: You can use roar syntax, such as URLs, hashtags and mentions
+      admin_account_action:
+        include_statuses: The creature will see which roars have caused the moderation action or warning
+        send_email_notification: The creature will receive an explanation of what happened with their account
+        text_html: Optional. You can use roar syntax. You can <a href="%{path}">add warning presets</a> to save time
+      announcement:
+        text: You can use roar syntax. Please be mindful of the space the announcement will take up on the user's screen
+      defaults:
+        allow_anonymous_html: "If enabled, public and unlisted roars can be read by anyone and any software without authentication. <strong>Be aware that this will allow accounts and servers you've blocked to continue accessing and redistributing your public roars!</strong>"
+        irreversible: Filtered roars will disappear irreversibly, even if filter is later removed
+        no_verify_auth_html: "Deliver roars to participants on other servers without requiring their software to prove the authenticity of their accounts to Monsterpit.  Enabling this option makes it possible to interact with folks on servers using old versions of Mastodon (&lt; 3.2.0), but <strong>doing so can allow an attacker to trick Monsterpit into sending them your private roars</strong>."
+        phrase: Will be matched regardless of casing in text or content warning of a roar
+        private: Limit the visibility of your profile to followers and force other Fediverse servers to make your roars private, reguardless of their local visibility.
+        setting_aggregate_reblogs: Do not show new boosts for roars that have been recently boosted (only affects newly-received boosts)
+        setting_default_content_type_html: When composing roars, assume they are written in raw HTML, unless specified otherwise
+        setting_default_content_type_markdown: When composing roars, assume they are using Markdown for rich text formatting, unless specified otherwise
+        setting_default_content_type_plain: When composing roars, assume they are plain text with no special formatting, unless specified otherwise (default)
+        setting_default_content_type_html_html: "<strong>&lt;strong&gt;Bold&lt;/strong&gt;</strong>, <u>&lt;u&gt;Underline&lt;/u&gt;</u>, <em>&lt;em&gt;Italic&lt;/em&gt;</em>, <code>&lt;code&gt;Console&lt;/code&gt;</code>, ..."
+        setting_default_content_type_markdown_html: "<strong>**Bold**</strong>, <u>_Underline_</u>, <em>*Italic*</em>, <code>`Console`</code>, ..."
+        setting_default_content_type_plain_html: No formatting.
+        setting_default_content_type_console_html: <code>Plain-text console formatting.</code>
+        setting_default_content_type_bbcode_html: "<strong>[b]Bold[/b]</strong>, <u>[u]Underline[/u]</u>, <em>[i]Italic[/i]</em>, <code>[code]Console[/code]</code>, ..."
+        setting_default_language: The language of your roars can be detected automatically, but it's not always accurate
+        setting_filter_unknown: Strictly filter unfollowed authors, including those of boosts, from your home and list timelines.  Takes effect for newly-pushed items.
+        setting_home_reblogs: Allow packmates to boost unfollowed authors and Rowdy Tavern participants to your home timeline.  Note that enabling this option has the potential to place offensive or unsettling Fediverse content in your home timeline without warning!
+        setting_manual_publish: This allows you to draft, proofread, and edit your roars before publishing them.  You can publish a roar from its <strong>action menu</strong> (the three dots).
+        setting_max_history_public: How long public users and other servers may directly access roars on your public profile or ActivityPub outbox.
+        setting_max_history_private: How long followers may directly access roars from your public profile or ActivityPub outbox. Takes precedence if shorter than the public setting. 
+        setting_rss_disabled: Improves privacy by turning off your account's public RSS feed.
+        setting_show_application: The application you use to toot will be displayed in the detailed view of your roars
+        setting_style_lowercase: Forces all but case-sensitive text (input fields, computer code, keycaps, etc.) to be rendered lowercase.
+        setting_skin: Reskins the selected UI flavour
+        setting_unpublish_on_delete: When enabled, deleting a published roar will unpublish it then make it local-only. Deleting an unpublished roar will permanently destroy it.
+        setting_web_push: After changing this option, you will need to reload the Web interface tab or window if you have it open.
+        show_replies: Disable if you'd prefer your replies not be a part of your public profile.
+        show_unlisted: Disable if you'd prefer to only show unlisted roars on your profile page to visitors who are logged-in or are your followers.
+        text: This helps us determine if registrations are made in sincerity and prevents spam. It is only visible to admins.
+      user:
+        chosen_languages: When checked, only roars in selected languages will be displayed in public timelines
+    labels:
+      admin_account_action:
+        include_statuses: Include reported roars in the e-mail
+      defaults:
+        allow_anonymous: Allow anonymous access to public roars
+        bot: This is an automated account
+        no_verify_auth: Do not verify message recipients
+        private: Private mode
+        setting_crop_images: Crop images in non-expanded roars to 16x9
+        setting_default_content_type: Default format for roars
+        setting_default_language: Roar language
+        setting_default_privacy: Roar privacy
+        setting_delete_modal: Show confirmation dialog before deleting a roar
+        setting_display_media_hide_all: Hide all
+        setting_display_media_show_all: Reveal all
+        setting_expand_spoilers: Always expand roars marked with content warnings
+        setting_favourite_modal: Show confirmation dialog before admiring (applies to Glitch flavour only)
+        setting_filter_unknown: Filter unfollowed authors
+        setting_manual_publish: Manually publish roars
+        setting_max_history_public: Roar history visible to public
+        setting_max_history_private: Roar history visible to followers
+        setting_home_reblogs: Allow Rowdy Tavern content in home
+        setting_publish_in: Auto-publish
+        setting_show_application: Disclose application used to send roars
+        setting_style_css_profile: Custom CSS for profile page
+        setting_style_css_webapp: Custom CSS for web interface
+        setting_style_dashed_nest: Use dashed nest level indicators
+        setting_style_underline_a: Underline hyperlinks
+        setting_style_wide_media: Wide media attachments
+        setting_style_lowercase: Lowercase mode
+        setting_boost_every: Automatically queue and space out boosts
+        setting_boost_jitter: Add a random delay up to
+        setting_boost_random: Boost in random order
+        setting_rss_disabled: Disable RSS feed
+        setting_unpublish_delete: Delete after unpublishing
+        setting_unpublish_in: Then unpublish after
+        setting_unpublish_on_delete: Unpublish on delete
+        setting_use_pending_items: Relax mode
+        setting_web_push: Enable desktop push notifications
+        show_replies: Show replies on profile
+        show_unlisted: Show unlisted roars to anonymous visitors
+        username_confirmation: Confirm your username
+      invite_request:
+        text: "Introduce yourself and let the admins know what brings you to Monsterpit."
+      notification_emails:
+        favourite: Someone admired your roar
diff --git a/config/navigation.rb b/config/navigation.rb
index be429cfc4..cb97709d6 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -12,8 +12,11 @@ SimpleNavigation::Configuration.run do |navigation|
 
     n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url, if: -> { current_user.functional? } do |s|
       s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_url
+      s.item :other, safe_join([fa_icon('cog fw'), t('preferences.posting_defaults')]), settings_preferences_other_url
+      s.item :privacy, safe_join([fa_icon('lock fw'), t('preferences.privacy')]), settings_preferences_privacy_url
+      s.item :publishing, safe_join([fa_icon('pencil-square-o fw'), t('preferences.publishing')]), settings_preferences_publishing_url
       s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url
-      s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url
+      s.item :filters, safe_join([fa_icon('filter fw'), t('preferences.filters')]), settings_preferences_filters_url, highlights_on: %r{/settings/preferences/filters}
     end
 
     n.item :flavours, safe_join([fa_icon('paint-brush fw'), t('settings.flavours')]), settings_flavours_url do |flavours|
@@ -23,7 +26,7 @@ SimpleNavigation::Configuration.run do |navigation|
     end
 
     n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url, 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 :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{^/filters}, if: -> { current_user.functional? }
 
     n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
       s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases}
@@ -36,6 +39,7 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url
     end
 
+    n.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), custom_emojis_url, highlights_on: %r{/custom_emojis}
     n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' && current_user.functional? }
     n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? }
 
@@ -45,7 +49,7 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts}
       s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
-      s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
+      s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
       s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
       s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? }
     end
@@ -54,7 +58,6 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_url
       s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? }, highlights_on: %r{/admin/settings}
       s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}
-      s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis}
       s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_url, if: -> { current_user.admin? && !whitelist_mode? }, highlights_on: %r{/admin/relays}
       s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
       s.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? }
diff --git a/config/routes.rb b/config/routes.rb
index b3eef3364..3a591adef 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -35,6 +35,9 @@ Rails.application.routes.draw do
   get 'intent', to: 'intents#show'
   get 'custom.css', to: 'custom_css#show', as: :custom_css
 
+  get '/custom/:id/profile.css', to: 'user_profile_css#show', as: :user_profile_css
+  get '/custom/:id/webapp.css', to: 'user_webapp_css#show', as: :user_webapp_css
+
   resource :instance_actor, path: 'actor', only: [:show] do
     resource :inbox, only: [:create], module: :activitypub
     resource :outbox, only: [:show], module: :activitypub
@@ -89,8 +92,11 @@ Rails.application.routes.draw do
   resource :inbox, only: [:create], module: :activitypub
 
   get '/@:username', to: 'accounts#show', as: :short_account
+  get '/@:username/threads', to: 'accounts#show', as: :short_account_threads
   get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
   get '/@:username/media', to: 'accounts#show', as: :short_account_media
+  get '/@:username/reblogs', to: 'accounts#show', as: :short_account_reblogs
+  get '/@:username/mentions', to: 'accounts#show', as: :short_account_mentions
   get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag
   get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status
   get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status
@@ -114,6 +120,9 @@ Rails.application.routes.draw do
       resource :appearance, only: [:show, :update], controller: :appearance
       resource :notifications, only: [:show, :update]
       resource :other, only: [:show, :update], controller: :other
+      resource :filters, only: [:show, :update], controller: :filters
+      resource :publishing, only: [:show, :update], controller: :publishing
+      resource :privacy, only: [:show, :update], controller: :privacy
     end
 
     resource :import, only: [:show, :create]
@@ -182,6 +191,12 @@ Rails.application.routes.draw do
   resources :filters, except: [:show]
   resource :relationships, only: [:show, :update]
 
+  resources :custom_emojis, only: [:index, :new, :create] do
+    collection do
+      post :batch
+    end
+  end
+
   get '/public', to: 'public_timelines#show', as: :public_timeline
   get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
 
@@ -282,12 +297,6 @@ Rails.application.routes.draw do
       resource :two_factor_authentication, only: [:destroy]
     end
 
-    resources :custom_emojis, only: [:index, :new, :create] do
-      collection do
-        post :batch
-      end
-    end
-
     resources :ip_blocks, only: [:index, :new, :create] do
       collection do
         post :batch
@@ -316,7 +325,7 @@ Rails.application.routes.draw do
 
     # JSON / REST API
     namespace :v1 do
-      resources :statuses, only: [:create, :show, :destroy] do
+      resources :statuses, only: [:create, :update, :show, :destroy] do
         scope module: :statuses do
           resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
           resources :favourited_by, controller: :favourited_by_accounts, only: :index
@@ -329,11 +338,16 @@ Rails.application.routes.draw do
           resource :bookmark, only: :create
           post :unbookmark, to: 'bookmarks#destroy'
 
-          resource :mute, only: :create
+          resource :mute, only: [:create, :update]
           post :unmute, to: 'mutes#destroy'
 
           resource :pin, only: :create
           post :unpin, to: 'pins#destroy'
+
+          resource :hide, only: :create
+          post :unhide, to: 'mutes#destroy'
+
+          resource :publish, only: :create
         end
 
         member do
@@ -413,6 +427,8 @@ Rails.application.routes.draw do
       resource :domain_blocks, only: [:show, :create, :destroy]
       resource :directory, only: [:show]
 
+      resource :domain_permissions, only: [:show, :create, :update, :destroy]
+
       resources :follow_requests, only: [:index] do
         member do
           post :authorize
@@ -492,6 +508,9 @@ Rails.application.routes.draw do
           resource :action, only: [:create], controller: 'account_actions'
         end
 
+        resource :domain_blocks, only: [:show]
+        resource :domain_allows, only: [:show]
+
         resources :reports, only: [:index, :show] do
           member do
             post :assign_to_self
@@ -519,10 +538,18 @@ Rails.application.routes.draw do
     end
   end
 
+  namespace :matrix, path: '_matrix-internal' do
+    namespace :identity do
+      namespace :v1 do
+        resource :check_credentials, only: [:create]
+      end
+    end
+  end
+
   get '/web/(*any)', to: 'home#index', as: :web
 
   get '/about',        to: 'about#show'
-  get '/about/more',   to: 'about#more'
+  get '/about/more',   to: redirect('/about')
   get '/terms',        to: 'about#terms'
 
   match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false
diff --git a/config/settings.yml b/config/settings.yml
index 4d6a1cffc..0877fe111 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -80,6 +80,8 @@ defaults: &defaults
   show_domain_blocks_rationale: 'disabled'
   outgoing_spoilers: ''
 
+  show_domain_allows: 'disabled'
+
 development:
   <<: *defaults
 
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 5de25de23..73f87de34 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -36,3 +36,15 @@
   pghero_scheduler:
     cron: '0 0 * * *'
     class: Scheduler::PgheroScheduler
+  ambassador_scheduler:
+    every: '<%= ENV.fetch('AMBASSADOR_DELAY', '30m') %>'
+    class: Scheduler::AmbassadorScheduler
+  database_cleanup_scheduler:
+    every: '1d'
+    class: Scheduler::DatabaseCleanupScheduler
+  status_cleanup_scheduler:
+    every: '1m'
+    class: Scheduler::StatusCleanupScheduler
+  publish_status_scheduler:
+    every: '1m'
+    class: Scheduler::PublishStatusScheduler
\ No newline at end of file
diff --git a/db/migrate/20200628105849_add_hidden_to_domain_allows.rb b/db/migrate/20200628105849_add_hidden_to_domain_allows.rb
new file mode 100644
index 000000000..8fd4b79cc
--- /dev/null
+++ b/db/migrate/20200628105849_add_hidden_to_domain_allows.rb
@@ -0,0 +1,7 @@
+class AddHiddenToDomainAllows < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :domain_allows, :hidden, :boolean, default: false, allow_null: false
+    end
+  end
+end
diff --git a/db/migrate/20200630222227_add_edited_to_statuses.rb b/db/migrate/20200630222227_add_edited_to_statuses.rb
new file mode 100644
index 000000000..c0a5abb97
--- /dev/null
+++ b/db/migrate/20200630222227_add_edited_to_statuses.rb
@@ -0,0 +1,10 @@
+class AddEditedToStatuses < ActiveRecord::Migration[5.2]
+  def up
+    add_column :statuses, :edited, :int
+    change_column_default :statuses, :edited, 0
+  end
+
+  def down
+    remove_column :statuses, :edited
+  end
+end
diff --git a/db/migrate/20200630222517_backfill_default_statuses_edited.rb b/db/migrate/20200630222517_backfill_default_statuses_edited.rb
new file mode 100644
index 000000000..cbcbd600b
--- /dev/null
+++ b/db/migrate/20200630222517_backfill_default_statuses_edited.rb
@@ -0,0 +1,14 @@
+class BackfillDefaultStatusesEdited < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    Rails.logger.info('Backfilling "edited" column of table "statuses" to default value 0...')
+    Status.unscoped.in_batches do |statuses|
+      statuses.update_all(edited: 0)
+    end
+  end
+
+  def down
+    true
+  end
+end
diff --git a/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb b/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb
new file mode 100644
index 000000000..f35a2fc99
--- /dev/null
+++ b/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddConversationIdIndexToStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured { add_index :statuses, :conversation_id, where: 'deleted_at IS NULL', algorithm: :concurrently, name: :index_statuses_on_conversation_id }
+  end
+end
diff --git a/db/migrate/20200706171939_add_not_null_to_monsterfork_additions.rb b/db/migrate/20200706171939_add_not_null_to_monsterfork_additions.rb
new file mode 100644
index 000000000..40b62f93a
--- /dev/null
+++ b/db/migrate/20200706171939_add_not_null_to_monsterfork_additions.rb
@@ -0,0 +1,11 @@
+class AddNotNullToMonsterforkAdditions < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      Rails.logger.info("Setting NOT NULL on domain_allows.hidden")
+      change_column_null :domain_allows, :hidden, false
+
+      Rails.logger.info("Setting NOT NULL on statuses.edited")
+      change_column_null :statuses, :edited, false
+    end
+  end
+end
diff --git a/db/migrate/20200717014609_add_nest_level_to_statuses.rb b/db/migrate/20200717014609_add_nest_level_to_statuses.rb
new file mode 100644
index 000000000..0b2196ad6
--- /dev/null
+++ b/db/migrate/20200717014609_add_nest_level_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddNestLevelToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :statuses, :nest_level, :integer, limit: 1, null: false, default: 0
+    end
+  end
+end
diff --git a/db/migrate/20200718011317_add_require_dereference_to_accounts.rb b/db/migrate/20200718011317_add_require_dereference_to_accounts.rb
new file mode 100644
index 000000000..9fcabd891
--- /dev/null
+++ b/db/migrate/20200718011317_add_require_dereference_to_accounts.rb
@@ -0,0 +1,7 @@
+class AddRequireDereferenceToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :accounts, :require_dereference, :boolean, null: false, default: false
+    end
+  end
+end
diff --git a/db/migrate/20200719024610_add_show_replies_to_accounts.rb b/db/migrate/20200719024610_add_show_replies_to_accounts.rb
new file mode 100644
index 000000000..ac6c5906b
--- /dev/null
+++ b/db/migrate/20200719024610_add_show_replies_to_accounts.rb
@@ -0,0 +1,7 @@
+class AddShowRepliesToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :accounts, :show_replies, :boolean, null: false, default: true
+    end
+  end
+end
diff --git a/db/migrate/20200719033609_add_show_unlisted_to_accounts.rb b/db/migrate/20200719033609_add_show_unlisted_to_accounts.rb
new file mode 100644
index 000000000..a9bb16720
--- /dev/null
+++ b/db/migrate/20200719033609_add_show_unlisted_to_accounts.rb
@@ -0,0 +1,7 @@
+class AddShowUnlistedToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :accounts, :show_unlisted, :boolean, null: false, default: true
+    end
+  end
+end
diff --git a/db/migrate/20200719114344_add_timelines_only_to_mute.rb b/db/migrate/20200719114344_add_timelines_only_to_mute.rb
new file mode 100644
index 000000000..20bbfcd59
--- /dev/null
+++ b/db/migrate/20200719114344_add_timelines_only_to_mute.rb
@@ -0,0 +1,7 @@
+class AddTimelinesOnlyToMute < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :mutes, :timelines_only, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200719181947_add_published_to_statuses.rb b/db/migrate/20200719181947_add_published_to_statuses.rb
new file mode 100644
index 000000000..129840a0c
--- /dev/null
+++ b/db/migrate/20200719181947_add_published_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddPublishedToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :statuses, :published, :boolean, default: true, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb b/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb
new file mode 100644
index 000000000..ee6d3e942
--- /dev/null
+++ b/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddUnpublishedIndexToStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    add_index :statuses, [:account_id, :id], where: '(deleted_at IS NULL) AND (published = FALSE)', order: { id: :desc }, algorithm: :concurrently, name: :index_unpublished_statuses
+  end
+end
diff --git a/db/migrate/20200720212317_create_status_mutes.rb b/db/migrate/20200720212317_create_status_mutes.rb
new file mode 100644
index 000000000..efd8f15c8
--- /dev/null
+++ b/db/migrate/20200720212317_create_status_mutes.rb
@@ -0,0 +1,10 @@
+class CreateStatusMutes < ActiveRecord::Migration[5.2]
+  def change
+    create_table :status_mutes do |t|
+      t.integer :account_id, null: false, index: true
+      t.bigint :status_id, null: false, index: true
+    end
+
+    add_index :status_mutes, [:account_id, :status_id], unique: true
+  end
+end
diff --git a/db/migrate/20200721184347_limit_visibility_of_replies_to_private_statuses.rb b/db/migrate/20200721184347_limit_visibility_of_replies_to_private_statuses.rb
new file mode 100644
index 000000000..d22242bdd
--- /dev/null
+++ b/db/migrate/20200721184347_limit_visibility_of_replies_to_private_statuses.rb
@@ -0,0 +1,13 @@
+class LimitVisibilityOfRepliesToPrivateStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    Status.includes(:thread).where.not(visibility: :direct).where(reply: true).where('statuses.in_reply_to_account_id != statuses.account_id').find_each do |status|
+      status.update!(visibility: status.thread.visibility) unless status.thread.nil? || %w(public unlisted).include?(status.thread.visibility) || ['direct', 'limited', status.thread.visibility].include?(status.visibility)
+    end
+  end
+
+  def down
+    true
+  end
+end
diff --git a/db/migrate/20200721195456_add_index_on_statuses_visibility.rb b/db/migrate/20200721195456_add_index_on_statuses_visibility.rb
new file mode 100644
index 000000000..c45405e95
--- /dev/null
+++ b/db/migrate/20200721195456_add_index_on_statuses_visibility.rb
@@ -0,0 +1,7 @@
+class AddIndexOnStatusesVisibility < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    add_index :statuses, :visibility, where: 'deleted_at IS NULL', algorithm: :concurrently, name: :index_statuses_on_visibility
+  end
+end
diff --git a/db/migrate/20200721202723_add_account_id_to_conversations.rb b/db/migrate/20200721202723_add_account_id_to_conversations.rb
new file mode 100644
index 000000000..afddf4823
--- /dev/null
+++ b/db/migrate/20200721202723_add_account_id_to_conversations.rb
@@ -0,0 +1,9 @@
+class AddAccountIdToConversations < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured do
+      add_reference :conversations, :account, foreign_key: true, index: {algorithm: :concurrently}
+    end
+  end
+end
diff --git a/db/migrate/20200721221427_add_public_to_conversations.rb b/db/migrate/20200721221427_add_public_to_conversations.rb
new file mode 100644
index 000000000..392bd7418
--- /dev/null
+++ b/db/migrate/20200721221427_add_public_to_conversations.rb
@@ -0,0 +1,7 @@
+class AddPublicToConversations < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :conversations, :public, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200721221659_backfill_conversation_visibility.rb b/db/migrate/20200721221659_backfill_conversation_visibility.rb
new file mode 100644
index 000000000..93394b825
--- /dev/null
+++ b/db/migrate/20200721221659_backfill_conversation_visibility.rb
@@ -0,0 +1,15 @@
+class BackfillConversationVisibility < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    Rails.logger.info('Backfilling thread visibility...')
+
+    safety_assured do
+      execute('UPDATE conversations SET public = true FROM (SELECT account_id, conversation_id FROM statuses WHERE NOT reply AND visibility IN (0, 1)) AS s WHERE conversations.id = s.conversation_id')
+    end
+  end
+
+  def down
+    true
+  end
+end
diff --git a/db/migrate/20200723225552_add_title_to_statuses.rb b/db/migrate/20200723225552_add_title_to_statuses.rb
new file mode 100644
index 000000000..16ae7264b
--- /dev/null
+++ b/db/migrate/20200723225552_add_title_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddTitleToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    add_column :statuses, :title, :text
+  end
+end
diff --git a/db/migrate/20200724035808_add_inline_to_media_attachments.rb b/db/migrate/20200724035808_add_inline_to_media_attachments.rb
new file mode 100644
index 000000000..171eca4b5
--- /dev/null
+++ b/db/migrate/20200724035808_add_inline_to_media_attachments.rb
@@ -0,0 +1,7 @@
+class AddInlineToMediaAttachments < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :media_attachments, :inline, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200724045955_create_inline_media_attachments.rb b/db/migrate/20200724045955_create_inline_media_attachments.rb
new file mode 100644
index 000000000..a894c3868
--- /dev/null
+++ b/db/migrate/20200724045955_create_inline_media_attachments.rb
@@ -0,0 +1,12 @@
+class CreateInlineMediaAttachments < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    create_table :inline_media_attachments do |t|
+      t.references :status, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade }
+      t.references :media_attachment, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade }
+    end
+
+    add_index :inline_media_attachments, [:status_id, :media_attachment_id], unique: true, algorithm: :concurrently, name: 'uniq_index_on_status_and_attachment'
+  end
+end
diff --git a/db/migrate/20200725071818_create_status_domain_permissions.rb b/db/migrate/20200725071818_create_status_domain_permissions.rb
new file mode 100644
index 000000000..e8faf3e00
--- /dev/null
+++ b/db/migrate/20200725071818_create_status_domain_permissions.rb
@@ -0,0 +1,13 @@
+class CreateStatusDomainPermissions < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    create_table :status_domain_permissions do |t|
+      t.references :status, null: false, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade }
+      t.string :domain, null: false, default: '', index: { algorithm: :concurrently }
+      t.integer :visibility, null: false, default: 0, index: { algorithm: :concurrently }
+    end
+
+    add_index :status_domain_permissions, [:status_id, :domain], unique: true, algorithm: :concurrently
+  end
+end
diff --git a/db/migrate/20200725080000_create_account_domain_permissions.rb b/db/migrate/20200725080000_create_account_domain_permissions.rb
new file mode 100644
index 000000000..2497eda69
--- /dev/null
+++ b/db/migrate/20200725080000_create_account_domain_permissions.rb
@@ -0,0 +1,13 @@
+class CreateAccountDomainPermissions < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    create_table :account_domain_permissions do |t|
+      t.references :account, null: false, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade }
+      t.string :domain, null: false, default: '', index: { algorithm: :concurrently }
+      t.integer :visibility, null: false, default: 0, index: { algorithm: :concurrently }
+    end
+
+    add_index :account_domain_permissions, [:account_id, :domain], unique: true, algorithm: :concurrently
+  end
+end
diff --git a/db/migrate/20200726094737_add_semiprivate_to_statuses.rb b/db/migrate/20200726094737_add_semiprivate_to_statuses.rb
new file mode 100644
index 000000000..facde265c
--- /dev/null
+++ b/db/migrate/20200726094737_add_semiprivate_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddSemiprivateToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :statuses, :semiprivate, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200728135753_add_original_text_to_statuses.rb b/db/migrate/20200728135753_add_original_text_to_statuses.rb
new file mode 100644
index 000000000..6bf210191
--- /dev/null
+++ b/db/migrate/20200728135753_add_original_text_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddOriginalTextToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    add_column :statuses, :original_text, :text
+  end
+end
diff --git a/db/migrate/20200728171900_add_private_to_accounts.rb b/db/migrate/20200728171900_add_private_to_accounts.rb
new file mode 100644
index 000000000..482d09576
--- /dev/null
+++ b/db/migrate/20200728171900_add_private_to_accounts.rb
@@ -0,0 +1,7 @@
+class AddPrivateToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :accounts, :private, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200728173757_add_require_auth_to_accounts.rb b/db/migrate/20200728173757_add_require_auth_to_accounts.rb
new file mode 100644
index 000000000..00a3c1642
--- /dev/null
+++ b/db/migrate/20200728173757_add_require_auth_to_accounts.rb
@@ -0,0 +1,7 @@
+class AddRequireAuthToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :accounts, :require_auth, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200731064236_create_account_metadata.rb b/db/migrate/20200731064236_create_account_metadata.rb
new file mode 100644
index 000000000..c2eb32b79
--- /dev/null
+++ b/db/migrate/20200731064236_create_account_metadata.rb
@@ -0,0 +1,10 @@
+class CreateAccountMetadata < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    create_table :account_metadata do |t|
+      t.references :account, null: false, unique: true, foreign_key: { on_delete: :cascade }
+      t.jsonb :fields, null: false, default: {}
+    end
+  end
+end
diff --git a/db/migrate/20200731135033_backfill_account_metadata.rb b/db/migrate/20200731135033_backfill_account_metadata.rb
new file mode 100644
index 000000000..2ddfa6081
--- /dev/null
+++ b/db/migrate/20200731135033_backfill_account_metadata.rb
@@ -0,0 +1,11 @@
+class BackfillAccountMetadata < ActiveRecord::Migration[5.2]
+  def up
+    safety_assured do
+      execute("INSERT INTO account_metadata (account_id) SELECT id FROM accounts WHERE domain IS NULL OR domain = ''")
+    end
+  end
+
+  def down
+    true
+  end
+end
diff --git a/db/migrate/20200731163700_create_destructing_statuses.rb b/db/migrate/20200731163700_create_destructing_statuses.rb
new file mode 100644
index 000000000..4923eb393
--- /dev/null
+++ b/db/migrate/20200731163700_create_destructing_statuses.rb
@@ -0,0 +1,11 @@
+class CreateDestructingStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    create_table :destructing_statuses do |t|
+      t.references :status, null: false, unique: true, foreign_key: { on_delete: :cascade }
+      t.datetime :after, null: false, index: { algorithm: :concurrently }
+      t.boolean :defederate_only, null: false, default: false
+    end
+  end
+end
diff --git a/db/migrate/20200731205913_create_queued_boosts.rb b/db/migrate/20200731205913_create_queued_boosts.rb
new file mode 100644
index 000000000..33ddbb966
--- /dev/null
+++ b/db/migrate/20200731205913_create_queued_boosts.rb
@@ -0,0 +1,10 @@
+class CreateQueuedBoosts < ActiveRecord::Migration[5.2]
+  def change
+    create_table :queued_boosts do |t|
+      t.references :account, null: false, foreign_key: { on_delete: :cascade }
+      t.references :status, null: false, foreign_key: { on_delete: :cascade }
+    end
+
+    add_index :queued_boosts, [:account_id, :status_id], unique: true
+  end
+end
diff --git a/db/migrate/20200731211100_create_publishing_delays.rb b/db/migrate/20200731211100_create_publishing_delays.rb
new file mode 100644
index 000000000..9561ca0b2
--- /dev/null
+++ b/db/migrate/20200731211100_create_publishing_delays.rb
@@ -0,0 +1,10 @@
+class CreatePublishingDelays < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    create_table :publishing_delays do |t|
+      t.references :status, null: false, unique: true, foreign_key: { on_delete: :cascade }
+      t.datetime :after, index: { algorithm: :concurrently }
+    end
+  end
+end
diff --git a/db/migrate/20200801210543_add_accounts_to_publishing_delays.rb b/db/migrate/20200801210543_add_accounts_to_publishing_delays.rb
new file mode 100644
index 000000000..21f29aab8
--- /dev/null
+++ b/db/migrate/20200801210543_add_accounts_to_publishing_delays.rb
@@ -0,0 +1,9 @@
+class AddAccountsToPublishingDelays < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured do
+      add_reference :publishing_delays, :account, null: false, foreign_key: { on_delete: :cascade }, index: { algorithm: :concurrently }
+    end
+  end
+end
diff --git a/db/migrate/20200801220000_add_accounts_to_destructing_statuses.rb b/db/migrate/20200801220000_add_accounts_to_destructing_statuses.rb
new file mode 100644
index 000000000..42298b274
--- /dev/null
+++ b/db/migrate/20200801220000_add_accounts_to_destructing_statuses.rb
@@ -0,0 +1,9 @@
+class AddAccountsToDestructingStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured do
+      add_reference :destructing_statuses, :account, null: false, foreign_key: { on_delete: :cascade }, index: { algorithm: :concurrently }
+    end
+  end
+end
diff --git a/db/migrate/20200811024642_update_status_indexes.rb b/db/migrate/20200811024642_update_status_indexes.rb
new file mode 100644
index 000000000..264f583a4
--- /dev/null
+++ b/db/migrate/20200811024642_update_status_indexes.rb
@@ -0,0 +1,23 @@
+class UpdateStatusIndexes < ActiveRecord::Migration[5.2]
+  def up
+    safety_assured do
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_local", order: { id: :desc }, where: "((published = TRUE) AND (local = TRUE OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = FALSE) OR (in_reply_to_account_id = account_id)))"
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_public", order: { id: :desc }, where: "((published = TRUE) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = FALSE) OR (in_reply_to_account_id = account_id)))"
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_local_reblogs", where: "(((local = TRUE) OR (uri IS NULL)) AND (statuses.reblog_of_id IS NOT NULL))"
+
+      remove_index :statuses, name: "index_statuses_local_20190824"
+      remove_index :statuses, name: "index_statuses_public_20200119"
+    end
+  end
+
+  def down
+    safety_assured do
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
+
+      remove_index :statuses, name: "index_statuses_local"
+      remove_index :statuses, name: "index_statuses_local_reblogs"
+      remove_index :statuses, name: "index_statuses_public"
+    end
+  end
+end
diff --git a/db/migrate/20200816200108_add_root_to_conversations.rb b/db/migrate/20200816200108_add_root_to_conversations.rb
new file mode 100644
index 000000000..f45a3b476
--- /dev/null
+++ b/db/migrate/20200816200108_add_root_to_conversations.rb
@@ -0,0 +1,7 @@
+class AddRootToConversations < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :conversations, :root, :string, index: true
+    end
+  end
+end
diff --git a/db/migrate/20200816200239_backfill_root_to_conversations.rb b/db/migrate/20200816200239_backfill_root_to_conversations.rb
new file mode 100644
index 000000000..2056e0765
--- /dev/null
+++ b/db/migrate/20200816200239_backfill_root_to_conversations.rb
@@ -0,0 +1,19 @@
+class BackfillRootToConversations < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    Rails.logger.info("Adding URI to statuses without one...")
+    Status.where(uri: nil).or(Status.where(uri: '')).find_each do |status|
+      status.update(uri: ActivityPub::TagManager.instance.uri_for(status))
+    end
+
+    Rails.logger.info('Setting root of all conversations...')
+    safety_assured do
+      execute('UPDATE conversations SET root = s.uri FROM (SELECT conversation_id, uri FROM statuses WHERE NOT reply) AS s WHERE conversations.id = s.conversation_id')
+    end
+  end
+
+  def down
+    true
+  end
+end
diff --git a/db/migrate/20200817003033_add_defaults_to_conversations.rb b/db/migrate/20200817003033_add_defaults_to_conversations.rb
new file mode 100644
index 000000000..fc3c0ceee
--- /dev/null
+++ b/db/migrate/20200817003033_add_defaults_to_conversations.rb
@@ -0,0 +1,8 @@
+class AddDefaultsToConversations < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      change_column :conversations, :account_id, :bigint, default: nil
+      change_column :conversations, :root, :string, default: nil
+    end
+  end
+end
diff --git a/db/migrate/20200817003653_status_mute_account_id_bigint.rb b/db/migrate/20200817003653_status_mute_account_id_bigint.rb
new file mode 100644
index 000000000..e46d17845
--- /dev/null
+++ b/db/migrate/20200817003653_status_mute_account_id_bigint.rb
@@ -0,0 +1,7 @@
+class StatusMuteAccountIdBigint < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      change_column :status_mutes, :account_id, :bigint, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200817225525_add_footer_to_statuses.rb b/db/migrate/20200817225525_add_footer_to_statuses.rb
new file mode 100644
index 000000000..e85d225bc
--- /dev/null
+++ b/db/migrate/20200817225525_add_footer_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddFooterToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    add_column :statuses, :footer, :text
+  end
+end
diff --git a/db/migrate/20200818040629_add_last_synced_at_to_accounts.rb b/db/migrate/20200818040629_add_last_synced_at_to_accounts.rb
new file mode 100644
index 000000000..0d64b5109
--- /dev/null
+++ b/db/migrate/20200818040629_add_last_synced_at_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddLastSyncedAtToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    add_column :accounts, :last_synced_at, :datetime
+  end
+end
diff --git a/db/migrate/20200818160057_create_collection_items.rb b/db/migrate/20200818160057_create_collection_items.rb
new file mode 100644
index 000000000..88796ce0e
--- /dev/null
+++ b/db/migrate/20200818160057_create_collection_items.rb
@@ -0,0 +1,12 @@
+class CreateCollectionItems < ActiveRecord::Migration[5.2]
+  def change
+    create_table :collection_items do |t|
+      t.references :account, index: true, foreign_key: { on_delete: :cascade }
+      t.string :uri, null: false, index: { unique: true }
+      t.boolean :processed, null: false, default: false
+    end
+
+    add_index :collection_items, :id, name: 'unprocessed_collection_item_ids', where: 'processed = FALSE', order: { id: :desc }
+    add_index :collection_items, :account_id, name: 'unprocessed_collection_item_account_ids', where: 'processed = FALSE'
+  end
+end
diff --git a/db/migrate/20200818160106_create_collection_pages.rb b/db/migrate/20200818160106_create_collection_pages.rb
new file mode 100644
index 000000000..d00e1ca1c
--- /dev/null
+++ b/db/migrate/20200818160106_create_collection_pages.rb
@@ -0,0 +1,13 @@
+class CreateCollectionPages < ActiveRecord::Migration[5.2]
+  def change
+    create_table :collection_pages do |t|
+      t.references :account, index: true, foreign_key: { on_delete: :cascade }
+      t.string :uri, null: false, index: { unique: true }
+      t.string :next
+    end
+
+    add_index :collection_pages, :id, name: 'unprocessed_collection_page_ids', where: 'next IS NULL'
+    add_index :collection_pages, :account_id, name: 'unprocessed_collection_page_account_ids', where: 'next IS NULL'
+    add_index :collection_pages, :uri, name: 'unprocessed_collection_pages_uris', where: 'next IS NULL'
+  end
+end
diff --git a/db/migrate/20200821051721_add_retries_to_collection_items.rb b/db/migrate/20200821051721_add_retries_to_collection_items.rb
new file mode 100644
index 000000000..9cee437d9
--- /dev/null
+++ b/db/migrate/20200821051721_add_retries_to_collection_items.rb
@@ -0,0 +1,5 @@
+class AddRetriesToCollectionItems < ActiveRecord::Migration[5.2]
+  def change
+    add_column :collection_items, :retries, :integer, limit: 1, default: 0, null: false
+  end
+end
diff --git a/db/migrate/20200822054516_remove_public_column_from_conversations.rb b/db/migrate/20200822054516_remove_public_column_from_conversations.rb
new file mode 100644
index 000000000..e015f3f63
--- /dev/null
+++ b/db/migrate/20200822054516_remove_public_column_from_conversations.rb
@@ -0,0 +1,7 @@
+class RemovePublicColumnFromConversations < ActiveRecord::Migration[5.2]
+  def change
+    def safety_assured
+      remove_column :conversations, :public
+    end
+  end
+end
diff --git a/db/migrate/20200823002835_unlink_blocked_replies.rb b/db/migrate/20200823002835_unlink_blocked_replies.rb
new file mode 100644
index 000000000..6968fc93f
--- /dev/null
+++ b/db/migrate/20200823002835_unlink_blocked_replies.rb
@@ -0,0 +1,28 @@
+class UnlinkBlockedReplies < ActiveRecord::Migration[5.2]
+  def up
+    Block.find_each do |block|
+      next if block.account.nil? || block.target_account.nil?
+
+      unlink_replies!(block.account, block.target_account)
+      unlink_mentions!(block.account, block.target_account)
+    end
+  end
+
+  def down
+    nil
+  end
+
+  private
+
+  def unlink_replies!(account, target_account)
+    target_account.statuses.where(in_reply_to_account_id: account.id)
+      .or(account.statuses.where(in_reply_to_account_id: target_account.id))
+      .in_batches.update_all(in_reply_to_account_id: nil)
+  end
+
+  def unlink_mentions!(account, target_account)
+    account.mentions.where(account_id: target_account.id)
+      .or(target_account.mentions.where(account_id: account.id))
+      .in_batches.destroy_all
+  end
+end
diff --git a/db/migrate/20200826125821_add_username_and_nospam_to_users.rb b/db/migrate/20200826125821_add_username_and_nospam_to_users.rb
new file mode 100644
index 000000000..9a964b980
--- /dev/null
+++ b/db/migrate/20200826125821_add_username_and_nospam_to_users.rb
@@ -0,0 +1,6 @@
+class AddUsernameAndNospamToUsers < ActiveRecord::Migration[5.2]
+  def change
+    add_column :users, :username, :string
+    add_column :users, :kobold, :string
+  end
+end
diff --git a/db/migrate/20200901035527_add_sticky_to_account_domain_permissions.rb b/db/migrate/20200901035527_add_sticky_to_account_domain_permissions.rb
new file mode 100644
index 000000000..2acfce329
--- /dev/null
+++ b/db/migrate/20200901035527_add_sticky_to_account_domain_permissions.rb
@@ -0,0 +1,7 @@
+class AddStickyToAccountDomainPermissions < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :account_domain_permissions, :sticky, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200901183004_backfill_user_username.rb b/db/migrate/20200901183004_backfill_user_username.rb
new file mode 100644
index 000000000..e206aaae8
--- /dev/null
+++ b/db/migrate/20200901183004_backfill_user_username.rb
@@ -0,0 +1,11 @@
+class BackfillUserUsername < ActiveRecord::Migration[5.2]
+  def up
+    User.find_each do |user|
+      user.update!(username: user.account.username)
+    end
+  end
+
+  def down
+    nil
+  end
+end
diff --git a/db/migrate/20200904002209_add_expires_at_to_statuses.rb b/db/migrate/20200904002209_add_expires_at_to_statuses.rb
new file mode 100644
index 000000000..53049b159
--- /dev/null
+++ b/db/migrate/20200904002209_add_expires_at_to_statuses.rb
@@ -0,0 +1,8 @@
+class AddExpiresAtToStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    add_column :statuses, :expires_at, :datetime
+    add_index :statuses, :expires_at, algorithm: :concurrently, where: 'expires_at IS NOT NULL'
+  end
+end
diff --git a/db/migrate/20200904004330_add_publish_at_to_statuses.rb b/db/migrate/20200904004330_add_publish_at_to_statuses.rb
new file mode 100644
index 000000000..35a32eb0e
--- /dev/null
+++ b/db/migrate/20200904004330_add_publish_at_to_statuses.rb
@@ -0,0 +1,8 @@
+class AddPublishAtToStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    add_column :statuses, :publish_at, :datetime
+    add_index :statuses, :publish_at, algorithm: :concurrently, where: 'publish_at IS NOT NULL'
+  end
+end
diff --git a/db/migrate/20200904005553_drop_publishing_delay.rb b/db/migrate/20200904005553_drop_publishing_delay.rb
new file mode 100644
index 000000000..509e591c7
--- /dev/null
+++ b/db/migrate/20200904005553_drop_publishing_delay.rb
@@ -0,0 +1,5 @@
+class DropPublishingDelay < ActiveRecord::Migration[5.2]
+  def change
+    drop_table :publishing_delays
+  end
+end
diff --git a/db/migrate/20200904005706_drop_destructing_status.rb b/db/migrate/20200904005706_drop_destructing_status.rb
new file mode 100644
index 000000000..39885aabd
--- /dev/null
+++ b/db/migrate/20200904005706_drop_destructing_status.rb
@@ -0,0 +1,5 @@
+class DropDestructingStatus < ActiveRecord::Migration[5.2]
+  def change
+    drop_table :destructing_statuses
+  end
+end
diff --git a/db/migrate/20200904184045_add_originally_local_only_to_statuses.rb b/db/migrate/20200904184045_add_originally_local_only_to_statuses.rb
new file mode 100644
index 000000000..abff57b45
--- /dev/null
+++ b/db/migrate/20200904184045_add_originally_local_only_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddOriginallyLocalOnlyToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :statuses, :originally_local_only, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200904184155_backfill_originally_local_only.rb b/db/migrate/20200904184155_backfill_originally_local_only.rb
new file mode 100644
index 000000000..d87609db9
--- /dev/null
+++ b/db/migrate/20200904184155_backfill_originally_local_only.rb
@@ -0,0 +1,14 @@
+class BackfillOriginallyLocalOnly < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      execute('UPDATE statuses SET originally_local_only = false WHERE originally_local_only IS NULL')
+      execute('UPDATE statuses SET originally_local_only = true WHERE local_only')
+    end
+  end
+
+  def down
+    nil
+  end
+end
diff --git a/db/migrate/20200904200803_backfill_default_false_to_local_only.rb b/db/migrate/20200904200803_backfill_default_false_to_local_only.rb
new file mode 100644
index 000000000..236a01c14
--- /dev/null
+++ b/db/migrate/20200904200803_backfill_default_false_to_local_only.rb
@@ -0,0 +1,13 @@
+class BackfillDefaultFalseToLocalOnly < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      execute('UPDATE statuses SET local_only = false WHERE local_only IS NULL')
+    end
+  end
+
+  def down
+    nil
+  end
+end
diff --git a/db/migrate/20200904201028_add_default_false_to_local_only.rb b/db/migrate/20200904201028_add_default_false_to_local_only.rb
new file mode 100644
index 000000000..7f9bb99d4
--- /dev/null
+++ b/db/migrate/20200904201028_add_default_false_to_local_only.rb
@@ -0,0 +1,7 @@
+class AddDefaultFalseToLocalOnly < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      change_column :statuses, :local_only, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200907195410_add_index_to_users_username.rb b/db/migrate/20200907195410_add_index_to_users_username.rb
new file mode 100644
index 000000000..06452e0dd
--- /dev/null
+++ b/db/migrate/20200907195410_add_index_to_users_username.rb
@@ -0,0 +1,8 @@
+class AddIndexToUsersUsername < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    add_index :users, :username, unique: true, algorithm: :concurrently
+    add_index :users, 'lower(username)', unique: true, algorithm: :concurrently, name: 'index_on_users_username_lowercase'
+  end
+end
diff --git a/db/migrate/20200919234917_add_account_to_custom_emoji.rb b/db/migrate/20200919234917_add_account_to_custom_emoji.rb
new file mode 100644
index 000000000..b4466ee30
--- /dev/null
+++ b/db/migrate/20200919234917_add_account_to_custom_emoji.rb
@@ -0,0 +1,7 @@
+class AddAccountToCustomEmoji < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_reference :custom_emojis, :account, foreign_key: { on_delete: :nullify }, index: true
+    end
+  end
+end
diff --git a/db/migrate/20200920084007_backfill_custom_emoji_ownership.rb b/db/migrate/20200920084007_backfill_custom_emoji_ownership.rb
new file mode 100644
index 000000000..1542bdb5e
--- /dev/null
+++ b/db/migrate/20200920084007_backfill_custom_emoji_ownership.rb
@@ -0,0 +1,12 @@
+class BackfillCustomEmojiOwnership < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    site_contact = Account.site_contact
+    CustomEmoji.local.in_batches.update_all(account_id: site_contact.id)
+  end
+
+  def down
+    nil
+  end
+end
diff --git a/db/migrate/20200921024447_add_curated_to_statuses.rb b/db/migrate/20200921024447_add_curated_to_statuses.rb
new file mode 100644
index 000000000..05558ded3
--- /dev/null
+++ b/db/migrate/20200921024447_add_curated_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddCuratedToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :statuses, :curated, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200921030158_backfill_curated_statuses.rb b/db/migrate/20200921030158_backfill_curated_statuses.rb
new file mode 100644
index 000000000..f9bf32afb
--- /dev/null
+++ b/db/migrate/20200921030158_backfill_curated_statuses.rb
@@ -0,0 +1,12 @@
+class BackfillCuratedStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    Status.with_public_visibility.joins(:status_stat).where('favourites_count != 0 OR reblogs_count != 0').in_batches.update_all(curated: true)
+    Status.with_public_visibility.where(curated: false).left_outer_joins(:bookmarks).where.not(bookmarks: { status_id: nil }).in_batches.update_all(curated: true)
+  end
+
+  def down
+    nil
+  end
+end
diff --git a/db/migrate/20200923000000_update_status_indexes_202009.rb b/db/migrate/20200923000000_update_status_indexes_202009.rb
new file mode 100644
index 000000000..9b6f58d3c
--- /dev/null
+++ b/db/migrate/20200923000000_update_status_indexes_202009.rb
@@ -0,0 +1,25 @@
+class UpdateStatusIndexes202009 < ActiveRecord::Migration[5.2]
+  def up
+    safety_assured do
+      remove_index :statuses, name: "index_statuses_local"
+      remove_index :statuses, name: "index_statuses_local_reblogs"
+      remove_index :statuses, name: "index_statuses_public"
+
+      add_index :statuses, :id, name: "index_statuses_local", order: { id: :desc }, where: "(published = TRUE) AND (local = TRUE OR (uri IS NULL)) AND (deleted_at IS NULL)"
+      add_index :statuses, :id, name: "index_statuses_curated", order: { id: :desc }, where: "(published = TRUE) AND (deleted_at IS NULL) AND (curated = TRUE)"
+      add_index :statuses, :id, name: "index_statuses_public", order: { id: :desc }, where: "(published = TRUE) AND (deleted_at IS NULL)"
+    end
+  end
+
+  def down
+    safety_assured do
+      remove_index :statuses, name: "index_statuses_local"
+      remove_index :statuses, name: "index_statuses_curated"
+      remove_index :statuses, name: "index_statuses_public"
+
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_local", order: { id: :desc }, where: "((published = TRUE) AND (local = TRUE OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = FALSE) OR (in_reply_to_account_id = account_id)))"
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_local_reblogs", where: "(((local = TRUE) OR (uri IS NULL)) AND (statuses.reblog_of_id IS NOT NULL))"
+      add_index :statuses, ["id", "account_id"], name: "index_statuses_public", order: { id: :desc }, where: "((published = TRUE) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = FALSE) OR (in_reply_to_account_id = account_id)))"
+    end
+  end
+end
diff --git a/db/migrate/20200923000001_remove_conversation_account.rb b/db/migrate/20200923000001_remove_conversation_account.rb
new file mode 100644
index 000000000..1b61d41bf
--- /dev/null
+++ b/db/migrate/20200923000001_remove_conversation_account.rb
@@ -0,0 +1,7 @@
+class RemoveConversationAccount < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      remove_column :conversations, :account_id
+    end
+  end
+end
diff --git a/db/migrate/20200923000002_remove_semiprivate_flag.rb b/db/migrate/20200923000002_remove_semiprivate_flag.rb
new file mode 100644
index 000000000..cd732c616
--- /dev/null
+++ b/db/migrate/20200923000002_remove_semiprivate_flag.rb
@@ -0,0 +1,7 @@
+class RemoveSemiprivateFlag < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      remove_column :statuses, :semiprivate
+    end
+  end
+end
diff --git a/db/migrate/20200923000003_add_reblogs_flag_to_lists.rb b/db/migrate/20200923000003_add_reblogs_flag_to_lists.rb
new file mode 100644
index 000000000..bb6bcc6c2
--- /dev/null
+++ b/db/migrate/20200923000003_add_reblogs_flag_to_lists.rb
@@ -0,0 +1,8 @@
+class AddReblogsFlagToLists < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :lists, :reblogs, :boolean, default: false, null: false
+      add_index :lists, :id, name: :lists_reblog_feeds, where: '(reblogs = TRUE)'
+    end
+  end
+end
diff --git a/db/migrate/20200925035221_drop_conversations_public.rb b/db/migrate/20200925035221_drop_conversations_public.rb
new file mode 100644
index 000000000..e09f6014a
--- /dev/null
+++ b/db/migrate/20200925035221_drop_conversations_public.rb
@@ -0,0 +1,7 @@
+class DropConversationsPublic < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      remove_column :conversations, :public
+    end
+  end
+end
diff --git a/db/migrate/20201119035441_drop_require_dereference_from_accounts.rb b/db/migrate/20201119035441_drop_require_dereference_from_accounts.rb
new file mode 100644
index 000000000..e677fbb16
--- /dev/null
+++ b/db/migrate/20201119035441_drop_require_dereference_from_accounts.rb
@@ -0,0 +1,7 @@
+class DropRequireDereferenceFromAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      remove_column :accounts, :require_dereference, :boolean, null: false, default: false
+    end
+  end
+end
diff --git a/db/migrate/20201123152231_add_no_verify_auth_to_accounts.rb b/db/migrate/20201123152231_add_no_verify_auth_to_accounts.rb
new file mode 100644
index 000000000..38f5bc046
--- /dev/null
+++ b/db/migrate/20201123152231_add_no_verify_auth_to_accounts.rb
@@ -0,0 +1,7 @@
+class AddNoVerifyAuthToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :accounts, :no_verify_auth, :boolean, null: false, default: false
+    end
+  end
+end
diff --git a/db/migrate/20201123171722_add_allow_anonymous_to_accounts.rb b/db/migrate/20201123171722_add_allow_anonymous_to_accounts.rb
new file mode 100644
index 000000000..f48783883
--- /dev/null
+++ b/db/migrate/20201123171722_add_allow_anonymous_to_accounts.rb
@@ -0,0 +1,7 @@
+class AddAllowAnonymousToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :accounts, :allow_anonymous, :boolean, null: false, default: false
+    end
+  end
+end
diff --git a/db/migrate/20201124005733_remove_require_auth_from_accounts.rb b/db/migrate/20201124005733_remove_require_auth_from_accounts.rb
new file mode 100644
index 000000000..3f64b539a
--- /dev/null
+++ b/db/migrate/20201124005733_remove_require_auth_from_accounts.rb
@@ -0,0 +1,7 @@
+class RemoveRequireAuthFromAccounts < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      remove_column :accounts, :require_auth, :boolean, null: false, default: false
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7d39126f0..346177241 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: 2020_10_17_234926) do
+ActiveRecord::Schema.define(version: 2020_11_24_005733) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -51,6 +51,17 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.index ["account_id", "domain"], name: "index_account_domain_blocks_on_account_id_and_domain", unique: true
   end
 
+  create_table "account_domain_permissions", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.string "domain", default: "", null: false
+    t.integer "visibility", default: 0, null: false
+    t.boolean "sticky", default: false, null: false
+    t.index ["account_id", "domain"], name: "index_account_domain_permissions_on_account_id_and_domain", unique: true
+    t.index ["account_id"], name: "index_account_domain_permissions_on_account_id"
+    t.index ["domain"], name: "index_account_domain_permissions_on_domain"
+    t.index ["visibility"], name: "index_account_domain_permissions_on_visibility"
+  end
+
   create_table "account_identity_proofs", force: :cascade do |t|
     t.bigint "account_id"
     t.string "provider", default: "", null: false
@@ -63,6 +74,12 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.index ["account_id", "provider", "provider_username"], name: "index_account_proofs_on_account_and_provider_and_username", unique: true
   end
 
+  create_table "account_metadata", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.jsonb "fields", default: {}, null: false
+    t.index ["account_id"], name: "index_account_metadata_on_account_id"
+  end
+
   create_table "account_migrations", force: :cascade do |t|
     t.bigint "account_id"
     t.string "acct", default: "", null: false
@@ -189,8 +206,14 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.integer "avatar_storage_schema_version"
     t.integer "header_storage_schema_version"
     t.string "devices_url"
-    t.integer "suspension_origin"
+    t.boolean "show_replies", default: true, null: false
+    t.boolean "show_unlisted", default: true, null: false
+    t.boolean "private", default: false, null: false
+    t.datetime "last_synced_at"
     t.datetime "sensitized_at"
+    t.integer "suspension_origin"
+    t.boolean "no_verify_auth", default: false, null: false
+    t.boolean "allow_anonymous", default: false, null: false
     t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
     t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
     t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id"
@@ -281,9 +304,32 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.index ["status_id"], name: "index_bookmarks_on_status_id"
   end
 
+  create_table "collection_items", force: :cascade do |t|
+    t.bigint "account_id"
+    t.string "uri", null: false
+    t.boolean "processed", default: false, null: false
+    t.integer "retries", limit: 2, default: 0, null: false
+    t.index ["account_id"], name: "index_collection_items_on_account_id"
+    t.index ["account_id"], name: "unprocessed_collection_item_account_ids", where: "(processed = false)"
+    t.index ["id"], name: "unprocessed_collection_item_ids", order: :desc, where: "(processed = false)"
+    t.index ["uri"], name: "index_collection_items_on_uri", unique: true
+  end
+
+  create_table "collection_pages", force: :cascade do |t|
+    t.bigint "account_id"
+    t.string "uri", null: false
+    t.string "next"
+    t.index ["account_id"], name: "index_collection_pages_on_account_id"
+    t.index ["account_id"], name: "unprocessed_collection_page_account_ids", where: "(next IS NULL)"
+    t.index ["id"], name: "unprocessed_collection_page_ids", where: "(next IS NULL)"
+    t.index ["uri"], name: "index_collection_pages_on_uri", unique: true
+    t.index ["uri"], name: "unprocessed_collection_pages_uris", where: "(next IS NULL)"
+  end
+
   create_table "conversation_mutes", force: :cascade do |t|
     t.bigint "conversation_id", null: false
     t.bigint "account_id", null: false
+    t.boolean "hidden", default: false, null: false
     t.index ["account_id", "conversation_id"], name: "index_conversation_mutes_on_account_id_and_conversation_id", unique: true
   end
 
@@ -291,6 +337,7 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.string "uri"
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
+    t.string "root"
     t.index ["uri"], name: "index_conversations_on_uri", unique: true
   end
 
@@ -316,6 +363,8 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.boolean "visible_in_picker", default: true, null: false
     t.bigint "category_id"
     t.integer "image_storage_schema_version"
+    t.bigint "account_id"
+    t.index ["account_id"], name: "index_custom_emojis_on_account_id"
     t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
   end
 
@@ -348,6 +397,7 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.string "domain", default: "", null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
+    t.boolean "hidden", default: false, null: false
     t.index ["domain"], name: "index_domain_allows_on_domain", unique: true
   end
 
@@ -451,6 +501,14 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.boolean "overwrite", default: false, null: false
   end
 
+  create_table "inline_media_attachments", force: :cascade do |t|
+    t.bigint "status_id"
+    t.bigint "media_attachment_id"
+    t.index ["media_attachment_id"], name: "index_inline_media_attachments_on_media_attachment_id"
+    t.index ["status_id", "media_attachment_id"], name: "uniq_index_on_status_and_attachment", unique: true
+    t.index ["status_id"], name: "index_inline_media_attachments_on_status_id"
+  end
+
   create_table "invites", force: :cascade do |t|
     t.bigint "user_id", null: false
     t.string "code", default: "", null: false
@@ -466,12 +524,12 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
   end
 
   create_table "ip_blocks", force: :cascade do |t|
-    t.datetime "created_at", null: false
-    t.datetime "updated_at", null: false
-    t.datetime "expires_at"
     t.inet "ip", default: "0.0.0.0", null: false
     t.integer "severity", default: 0, null: false
+    t.datetime "expires_at"
     t.text "comment", default: "", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
   end
 
   create_table "list_accounts", force: :cascade do |t|
@@ -489,7 +547,9 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.integer "replies_policy", default: 0, null: false
+    t.boolean "reblogs", default: false, null: false
     t.index ["account_id"], name: "index_lists_on_account_id"
+    t.index ["id"], name: "lists_reblog_feeds", where: "(reblogs = true)"
   end
 
   create_table "markers", force: :cascade do |t|
@@ -525,6 +585,7 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.integer "thumbnail_file_size"
     t.datetime "thumbnail_updated_at"
     t.string "thumbnail_remote_url"
+    t.boolean "inline", default: false, null: false
     t.index ["account_id"], name: "index_media_attachments_on_account_id"
     t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
     t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
@@ -547,6 +608,7 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.boolean "hide_notifications", default: true, null: false
     t.bigint "account_id", null: false
     t.bigint "target_account_id", null: false
+    t.boolean "timelines_only", default: false, null: false
     t.datetime "expires_at"
     t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true
     t.index ["target_account_id"], name: "index_mutes_on_target_account_id"
@@ -688,6 +750,14 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.index ["status_id", "preview_card_id"], name: "index_preview_cards_statuses_on_status_id_and_preview_card_id"
   end
 
+  create_table "queued_boosts", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.bigint "status_id", null: false
+    t.index ["account_id", "status_id"], name: "index_queued_boosts_on_account_id_and_status_id", unique: true
+    t.index ["account_id"], name: "index_queued_boosts_on_account_id"
+    t.index ["status_id"], name: "index_queued_boosts_on_status_id"
+  end
+
   create_table "relays", force: :cascade do |t|
     t.string "inbox_url", default: "", null: false
     t.string "follow_activity_id"
@@ -765,6 +835,24 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.index ["var"], name: "index_site_uploads_on_var", unique: true
   end
 
+  create_table "status_domain_permissions", force: :cascade do |t|
+    t.bigint "status_id", null: false
+    t.string "domain", default: "", null: false
+    t.integer "visibility", default: 0, null: false
+    t.index ["domain"], name: "index_status_domain_permissions_on_domain"
+    t.index ["status_id", "domain"], name: "index_status_domain_permissions_on_status_id_and_domain", unique: true
+    t.index ["status_id"], name: "index_status_domain_permissions_on_status_id"
+    t.index ["visibility"], name: "index_status_domain_permissions_on_visibility"
+  end
+
+  create_table "status_mutes", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.bigint "status_id", null: false
+    t.index ["account_id", "status_id"], name: "index_status_mutes_on_account_id_and_status_id", unique: true
+    t.index ["account_id"], name: "index_status_mutes_on_account_id"
+    t.index ["status_id"], name: "index_status_mutes_on_status_id"
+  end
+
   create_table "status_pins", force: :cascade do |t|
     t.bigint "account_id", null: false
     t.bigint "status_id", null: false
@@ -801,17 +889,34 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.bigint "account_id", null: false
     t.bigint "application_id"
     t.bigint "in_reply_to_account_id"
-    t.boolean "local_only"
+    t.boolean "local_only", default: false, null: false
     t.bigint "poll_id"
     t.string "content_type"
     t.datetime "deleted_at"
+    t.integer "edited", default: 0, null: false
+    t.integer "nest_level", limit: 2, default: 0, null: false
+    t.boolean "published", default: true, null: false
+    t.text "title"
+    t.text "original_text"
+    t.text "footer"
+    t.datetime "expires_at"
+    t.datetime "publish_at"
+    t.boolean "originally_local_only", default: false, null: false
+    t.boolean "curated", default: false, null: false
     t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
-    t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
-    t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
+    t.index ["account_id", "id"], name: "index_unpublished_statuses", order: { id: :desc }, where: "((deleted_at IS NULL) AND (published = false))"
+    t.index ["conversation_id"], name: "index_statuses_on_conversation_id", where: "(deleted_at IS NULL)"
+    t.index ["expires_at"], name: "index_statuses_on_expires_at", where: "(expires_at IS NOT NULL)"
+    t.index ["id", "account_id"], name: "index_statuses_on_id_and_account_id"
+    t.index ["id"], name: "index_statuses_curated", order: :desc, where: "((published = true) AND (deleted_at IS NULL) AND (curated = true))"
+    t.index ["id"], name: "index_statuses_local", order: :desc, where: "((published = true) AND ((local = true) OR (uri IS NULL)) AND (deleted_at IS NULL))"
+    t.index ["id"], name: "index_statuses_public", order: :desc, where: "((published = true) AND (deleted_at IS NULL))"
     t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
     t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
+    t.index ["publish_at"], name: "index_statuses_on_publish_at", where: "(publish_at IS NOT NULL)"
     t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
     t.index ["uri"], name: "index_statuses_on_uri", unique: true
+    t.index ["visibility"], name: "index_statuses_on_visibility", where: "(deleted_at IS NULL)"
   end
 
   create_table "statuses_tags", id: false, force: :cascade do |t|
@@ -904,14 +1009,18 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.boolean "approved", default: true, null: false
     t.string "sign_in_token"
     t.datetime "sign_in_token_sent_at"
+    t.string "username"
+    t.string "kobold"
     t.string "webauthn_id"
     t.inet "sign_up_ip"
+    t.index "lower((username)::text)", name: "index_on_users_username_lowercase", unique: true
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
     t.index ["email"], name: "index_users_on_email", unique: true
     t.index ["remember_token"], name: "index_users_on_remember_token", unique: true
     t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
+    t.index ["username"], name: "index_users_on_username", unique: true
   end
 
   create_table "web_push_subscriptions", force: :cascade do |t|
@@ -952,7 +1061,9 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
   add_foreign_key "account_conversations", "conversations", on_delete: :cascade
   add_foreign_key "account_deletion_requests", "accounts", on_delete: :cascade
   add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
+  add_foreign_key "account_domain_permissions", "accounts", on_delete: :cascade
   add_foreign_key "account_identity_proofs", "accounts", on_delete: :cascade
+  add_foreign_key "account_metadata", "accounts", on_delete: :cascade
   add_foreign_key "account_migrations", "accounts", column: "target_account_id", on_delete: :nullify
   add_foreign_key "account_migrations", "accounts", on_delete: :cascade
   add_foreign_key "account_moderation_notes", "accounts"
@@ -977,8 +1088,11 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
   add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
   add_foreign_key "bookmarks", "accounts", on_delete: :cascade
   add_foreign_key "bookmarks", "statuses", on_delete: :cascade
+  add_foreign_key "collection_items", "accounts", on_delete: :cascade
+  add_foreign_key "collection_pages", "accounts", on_delete: :cascade
   add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
   add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
+  add_foreign_key "custom_emojis", "accounts", on_delete: :nullify
   add_foreign_key "custom_filters", "accounts", on_delete: :cascade
   add_foreign_key "devices", "accounts", on_delete: :cascade
   add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
@@ -995,6 +1109,8 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
   add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
   add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade
   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
+  add_foreign_key "inline_media_attachments", "media_attachments", on_delete: :cascade
+  add_foreign_key "inline_media_attachments", "statuses", on_delete: :cascade
   add_foreign_key "invites", "users", on_delete: :cascade
   add_foreign_key "list_accounts", "accounts", on_delete: :cascade
   add_foreign_key "list_accounts", "follows", on_delete: :cascade
@@ -1020,6 +1136,8 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
   add_foreign_key "poll_votes", "polls", on_delete: :cascade
   add_foreign_key "polls", "accounts", on_delete: :cascade
   add_foreign_key "polls", "statuses", on_delete: :cascade
+  add_foreign_key "queued_boosts", "accounts", on_delete: :cascade
+  add_foreign_key "queued_boosts", "statuses", on_delete: :cascade
   add_foreign_key "report_notes", "accounts", on_delete: :cascade
   add_foreign_key "report_notes", "reports", on_delete: :cascade
   add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
@@ -1029,6 +1147,7 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
   add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade
   add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade
   add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade
+  add_foreign_key "status_domain_permissions", "statuses", on_delete: :cascade
   add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
   add_foreign_key "status_pins", "statuses", on_delete: :cascade
   add_foreign_key "status_stats", "statuses", on_delete: :cascade
diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
index 5433ddd9d..8a507b44e 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -41,6 +41,11 @@ module Mastodon
         end
       end
 
+      say('Scheduling account defederation messages to be sent to target domains...')
+      DefederateDomainService.new.call(scope.pluck(:domain).uniq)
+      say('Done!', :green)
+
+      say('Deleting accounts from target domains...')
       processed, = parallelize_with_progress(scope) do |account|
         DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
       end
diff --git a/lib/mastodon/snowflake.rb b/lib/mastodon/snowflake.rb
index 9e5bc7383..10ed51f0c 100644
--- a/lib/mastodon/snowflake.rb
+++ b/lib/mastodon/snowflake.rb
@@ -120,21 +120,10 @@ module Mastodon::Snowflake
 
         seq_name = data[:seq_prefix] + '_id_seq'
 
-        # If we were on Postgres 9.5+, we could do CREATE SEQUENCE IF
-        # NOT EXISTS, but we can't depend on that. Instead, catch the
-        # possible exception and ignore it.
         # Note that seq_name isn't a column name, but it's a
         # relation, like a column, and follows the same quoting rules
         # in Postgres.
-        connection.execute(<<~SQL)
-          DO $$
-            BEGIN
-              CREATE SEQUENCE #{connection.quote_column_name(seq_name)};
-            EXCEPTION WHEN duplicate_table THEN
-              -- Do nothing, we have the sequence already.
-            END
-          $$ LANGUAGE plpgsql;
-        SQL
+        connection.execute("CREATE SEQUENCE IF NOT EXISTS #{connection.quote_column_name(seq_name)};")
       end
     end
 
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 429bcb8a5..448518884 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -21,7 +21,7 @@ module Mastodon
     end
 
     def suffix
-      '+glitch'
+      '+glitch+monsterpit'
     end
 
     def to_a
@@ -33,11 +33,11 @@ module Mastodon
     end
 
     def repository
-      ENV.fetch('GITHUB_REPOSITORY', 'glitch-soc/mastodon')
+      ENV.fetch('GITHUB_REPOSITORY') { 'monsterpit/monsterpit-mastodon' }
     end
 
     def source_base_url
-      ENV.fetch('SOURCE_BASE_URL', "https://github.com/#{repository}")
+      ENV.fetch('SOURCE_BASE_URL') { "https://monsterware.dev/#{repository}" }
     end
 
     # specify git tag or commit hash here
@@ -56,5 +56,40 @@ module Mastodon
     def user_agent
       @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Version}; +http#{Rails.configuration.x.use_https ? 's' : ''}://#{Rails.configuration.x.web_domain}/)"
     end
+
+    def server_metadata_json
+      @server_metadata_json ||= [
+        {
+          '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' },
+          type: 'PropertyValue',
+          name: 'version',
+          value: to_s,
+        },
+        {
+          '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' },
+          type: 'PropertyValue',
+          name: 'monsterpit:extensions',
+          value: '2020.09.05.1',
+        },
+        {
+          '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' },
+          type: 'PropertyValue',
+          name: 'comment:0',
+          value: "big tails can't fail",
+        },
+        {
+          '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' },
+          type: 'PropertyValue',
+          name: 'comment:1',
+          value: 'trans rights!',
+        },
+        {
+          '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' },
+          type: 'PropertyValue',
+          name: 'comment:2',
+          value: 'gently the kobolds',
+        },
+      ]
+    end
   end
 end
diff --git a/lib/tasks/monsterfork.rake b/lib/tasks/monsterfork.rake
new file mode 100644
index 000000000..02ed97a9a
--- /dev/null
+++ b/lib/tasks/monsterfork.rake
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+namespace :monsterfork do
+  desc 'Compute post nesting levels (this may take a very long time!)'
+  task compute_nesting_levels: :environment do
+    Rails.logger.info('Setting post nesting level for orphaned replies...')
+    Status.select(:id, :account_id).where(reply: true, in_reply_to_id: nil).reorder(nil).in_batches.update_all(nest_level: 1)
+
+    count = 1.0
+    total = Conversation.count
+
+    Conversation.reorder('conversations.id DESC').find_each do |conversation|
+      Rails.logger.info("(#{(count / total * 100).to_i}%) Computing post nesting levels for all threads...")
+
+      conversation.statuses.where(reply: true).reorder('statuses.id ASC').find_each do |status|
+        level = [status.thread&.account_id == status.account_id ? status.thread&.nest_level.to_i : status.thread&.nest_level.to_i + 1, 127].min
+        status.update(nest_level: level) if level != status.nest_level
+      end
+
+      count += 1
+    end
+  end
+
+  desc '(Re-)announce instance actor to allow-listed servers'
+  task announce_instance_actor: :environment  do
+    Rails.logger.info('Announcing instance actor to all allowed servers...')
+    ActivityPub::UpdateDistributionWorker.new.perform(Account.representative.id)
+    Rails.logger.info('Done!')
+  end
+
+  desc 'Update the accounts of allow-listed application and instance actors'
+  task refresh_application_actors: :environment  do
+    Account.remote.without_suspended.where(actor_type: 'Application').find_each do |account|
+      Rails.logger.info("Refetching application actor: #{account.acct}")
+      account.update!(last_webfingered_at: nil)
+      begin
+        ResolveAccountService.new.call(account)
+      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError => e
+        Rails.logger.info("  Failed: #{e.class} (#{e.message})")
+      end
+    end
+    Rails.logger.info('Done!')
+  end
+end
diff --git a/monsterfork.code-workspace b/monsterfork.code-workspace
new file mode 100644
index 000000000..e67eae18c
--- /dev/null
+++ b/monsterfork.code-workspace
@@ -0,0 +1,11 @@
+{
+	"folders": [
+		{
+			"path": "."
+		}
+	],
+	"settings": {
+		"typescript.surveys.enabled": false,
+		"javascript.format.enable": false
+	}
+}
diff --git a/package.json b/package.json
index bab4b3c66..1e3e3cfca 100644
--- a/package.json
+++ b/package.json
@@ -73,8 +73,8 @@
     "@github/webauthn-json": "^0.5.7",
     "@rails/ujs": "^6.0.3",
     "array-includes": "^3.1.1",
-    "atrament": "0.2.4",
     "arrow-key-navigation": "^1.2.0",
+    "atrament": "0.2.4",
     "autoprefixer": "^9.8.6",
     "axios": "^0.21.0",
     "babel-loader": "^8.2.1",
@@ -122,6 +122,7 @@
     "offline-plugin": "^5.0.7",
     "path-complete-extname": "^1.0.0",
     "pg": "^6.4.0",
+    "pg-native": "^3.0.0",
     "postcss-loader": "^3.0.0",
     "postcss-object-fit-images": "^1.1.2",
     "promise.prototype.finally": "^3.1.2",
diff --git a/public/registration.js b/public/registration.js
new file mode 100644
index 000000000..a859313a2
--- /dev/null
+++ b/public/registration.js
@@ -0,0 +1,54 @@
+function dragon(message) {
+  const msgBuffer = new TextEncoder('utf-8').encode(message);
+  return crypto.subtle.digest('SHA-512', msgBuffer).then(hashBuffer => {
+    const hashArray = Array.from(new Uint8Array(hashBuffer));
+    const hashHex = hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('');
+    return hashHex;
+  });
+}
+
+function getForm() {
+  return document.getElementById('registration_new_user') || document.getElementById('new_user');
+}
+
+function getField(name) {
+  return document.getElementById(`registration_user_${name}`) || document.getElementById(`user_${name}`);
+}
+
+function handleSubmit(e) {
+  e.preventDefault();
+
+  const form = getForm();
+  const u1 = getField('account_attributes_username');
+  const u2 = getField('username');
+  const kobold = getField('kobold');
+
+  if (!!u1 && !!u2 && u1.value.toLowerCase() === u2.value.toLowerCase()) {
+    u2.value = u1.value;
+  }
+
+  let values = [];
+
+  for (let i = 0; i < form.elements.length; i++) {
+    const element = form.elements[i];
+    const value = element.value;
+
+    if (!!element && ['text', 'email', 'textarea'].includes(element.type) && !!value) {
+      values.push(value.trim().toLowerCase().replace(/\r\n?/g, "\n"));
+    }
+  }
+
+  const value = values.join('\u{F0666}');
+  dragon(value).then(digest => {
+    if (!!kobold) { kobold.value = digest.toUpperCase(); }
+    form.submit();
+  }, _ => { form.submit(); });
+}
+
+function addSubmitHandler() {
+  const form = getForm();
+  if (!!form) { form.addEventListener('submit', handleSubmit); }
+}
+
+window.addEventListener('DOMContentLoaded', addSubmitHandler);
+
diff --git a/streaming/index.js b/streaming/index.js
index ee4100696..0cddb36b4 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -6,7 +6,7 @@ const dotenv = require('dotenv');
 const express = require('express');
 const http = require('http');
 const redis = require('redis');
-const pg = require('pg');
+const pg = require('pg').native;
 const log = require('npmlog');
 const url = require('url');
 const { WebSocketServer } = require('@clusterws/cws');
@@ -647,7 +647,7 @@ const startWorker = (workerId) => {
         }
 
         const queries = [
-          client.query(`SELECT 1 FROM blocks WHERE (account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 2)})) OR (account_id = $2 AND target_account_id = $1) UNION SELECT 1 FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 2)})`, [req.accountId, unpackedPayload.account.id].concat(targetAccountIds)),
+          client.query(`SELECT 1 FROM blocks WHERE (account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 4)})) OR (account_id = $2 AND target_account_id = $1) UNION SELECT 1 FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 4)}) UNION SELECT 1 FROM conversation_mutes WHERE account_id = $1 AND conversation_id = $3 UNION SELECT 1 FROM status_mutes WHERE account_id = $1 AND status_id = $4`, [req.accountId, unpackedPayload.account.id, unpackedPayload.conversation_id, unpackedPayload.id].concat(targetAccountIds)),
         ];
 
         if (accountDomain) {
diff --git a/yarn.lock b/yarn.lock
index ea505a72b..dc2672925 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2366,7 +2366,7 @@ binary-extensions@^2.0.0:
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9"
   integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==
 
-bindings@^1.5.0:
+bindings@1.5.0, bindings@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
   integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
@@ -6656,6 +6656,14 @@ levn@^0.4.1:
     prelude-ls "^1.2.1"
     type-check "~0.4.0"
 
+libpq@^1.7.0:
+  version "1.8.9"
+  resolved "https://registry.yarnpkg.com/libpq/-/libpq-1.8.9.tgz#6e0c6eecb176f6656ad092d67cc0131980cba897"
+  integrity sha512-herU0STiW3+/XBoYRycKKf49O9hBKK0JbdC2QmvdC5pyCSu8prb9idpn5bUSbxj8XwcEsWPWWWwTDZE9ZTwJ7g==
+  dependencies:
+    bindings "1.5.0"
+    nan "^2.14.0"
+
 line-column@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2"
@@ -7160,10 +7168,10 @@ mute-stream@0.0.5:
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"
   integrity sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=
 
-nan@^2.12.1:
-  version "2.14.1"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
-  integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
+nan@^2.12.1, nan@^2.14.0:
+  version "2.14.2"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19"
+  integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
 
 nanoid@^3.1.16:
   version "3.1.16"
@@ -7847,6 +7855,15 @@ pg-int8@1.0.1:
   resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"
   integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==
 
+pg-native@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pg-native/-/pg-native-3.0.0.tgz#20c64e651e20b28f5c060b3823522d1c8c4429c3"
+  integrity sha512-qZZyywXJ8O4lbiIN7mn6vXIow1fd3QZFqzRe+uET/SZIXvCa3HBooXQA4ZU8EQX8Ae6SmaYtDGLp5DwU+8vrfg==
+  dependencies:
+    libpq "^1.7.0"
+    pg-types "^1.12.1"
+    readable-stream "1.0.31"
+
 pg-pool@1.*:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-1.8.0.tgz#f7ec73824c37a03f076f51bfdf70e340147c4f37"
@@ -7855,7 +7872,7 @@ pg-pool@1.*:
     generic-pool "2.4.3"
     object-assign "4.1.0"
 
-pg-types@1.*:
+pg-types@1.*, pg-types@^1.12.1:
   version "1.13.0"
   resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-1.13.0.tgz#75f490b8a8abf75f1386ef5ec4455ecf6b345c63"
   integrity sha512-lfKli0Gkl/+za/+b6lzENajczwZHc7D5kiUCZfgm914jipD2kIOIvEkAhZ8GrW3/TUoP9w8FHjwpPObBye5KQQ==
@@ -8920,6 +8937,16 @@ read-pkg@^5.2.0:
     string_decoder "~1.1.1"
     util-deprecate "~1.0.1"
 
+readable-stream@1.0.31:
+  version "1.0.31"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.31.tgz#8f2502e0bc9e3b0da1b94520aabb4e2603ecafae"
+  integrity sha1-jyUC4LyeOw2huUUgqrtOJgPsr64=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
 readable-stream@^3.0.6, readable-stream@^3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
@@ -10077,6 +10104,11 @@ string_decoder@^1.0.0, string_decoder@^1.1.1:
   dependencies:
     safe-buffer "~5.2.0"
 
+string_decoder@~0.10.x:
+  version "0.10.31"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
+  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
+
 string_decoder@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"