about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.rubocop.yml42
-rw-r--r--Gemfile6
-rw-r--r--Gemfile.lock21
-rw-r--r--app/controllers/about_controller.rb17
-rw-r--r--app/controllers/accounts_controller.rb84
-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.rb39
-rw-r--r--app/controllers/activitypub/replies_controller.rb3
-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/pins_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/publishing_controller.rb26
-rw-r--r--app/controllers/api/v1/statuses_controller.rb100
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb3
-rw-r--r--app/controllers/application_controller.rb46
-rw-r--r--app/controllers/auth/registrations_controller.rb8
-rw-r--r--app/controllers/concerns/account_owned_concern.rb2
-rw-r--r--app/controllers/home_controller.rb2
-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/publishing_controller.rb9
-rw-r--r--app/controllers/settings/preferences_controller.rb16
-rw-r--r--app/controllers/settings/profiles_controller.rb4
-rw-r--r--app/controllers/statuses_controller.rb14
-rw-r--r--app/controllers/tags_controller.rb10
-rw-r--r--app/controllers/user_profile_css_controller.rb24
-rw-r--r--app/controllers/user_webapp_css_controller.rb73
-rw-r--r--app/helpers/domain_control_helper.rb2
-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.js5
-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/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.js16
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js5
-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/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/statuses.js3
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js5
-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/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.json176
-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.rb16
-rw-r--r--app/lib/activitypub/activity/create.rb168
-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.rb14
-rw-r--r--app/lib/activitypub/case_transform.rb4
-rw-r--r--app/lib/activitypub/tag_manager.rb30
-rw-r--r--app/lib/command_tag/command/account_tools.rb37
-rw-r--r--app/lib/command_tag/command/footer_tools.rb50
-rw-r--r--app/lib/command_tag/command/hello_world.rb11
-rw-r--r--app/lib/command_tag/command/parent_status_tools.rb80
-rw-r--r--app/lib/command_tag/command/status_tools.rb239
-rw-r--r--app/lib/command_tag/command/text_tools.rb58
-rw-r--r--app/lib/command_tag/command/variables.rb40
-rw-r--r--app/lib/command_tag/commands.rb11
-rw-r--r--app/lib/command_tag/processor.rb335
-rw-r--r--app/lib/feed_manager.rb127
-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.rb10
-rw-r--r--app/lib/user_settings_decorator.rb94
-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_interactions.rb39
-rw-r--r--app/models/concerns/status_threading_concern.rb2
-rw-r--r--app/models/conversation.rb5
-rw-r--r--app/models/conversation_mute.rb5
-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/inline_media_attachment.rb20
-rw-r--r--app/models/invite.rb2
-rw-r--r--app/models/media_attachment.rb32
-rw-r--r--app/models/mute.rb1
-rw-r--r--app/models/queued_boost.rb15
-rw-r--r--app/models/status.rb308
-rw-r--r--app/models/status_domain_permission.rb69
-rw-r--r--app/models/status_mute.rb20
-rw-r--r--app/models/user.rb36
-rw-r--r--app/policies/account_domain_permission_policy.rb17
-rw-r--r--app/policies/status_policy.rb66
-rw-r--r--app/presenters/activitypub/activity_presenter.rb14
-rw-r--r--app/presenters/status_relationships_presenter.rb8
-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/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/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.rb70
-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.rb57
-rw-r--r--app/services/activitypub/process_account_service.rb15
-rw-r--r--app/services/activitypub/process_collection_items_service.rb30
-rw-r--r--app/services/after_block_service.rb14
-rw-r--r--app/services/block_service.rb8
-rw-r--r--app/services/concerns/payloadable.rb2
-rw-r--r--app/services/fan_out_on_write_service.rb43
-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.rb4
-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.rb82
-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.rb4
-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.rb34
-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.rb104
-rw-r--r--app/services/search_service.rb2
-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
-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.haml29
-rw-r--r--app/views/settings/preferences/filters/show.html.haml22
-rw-r--r--app/views/settings/preferences/other/show.html.haml5
-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/activitypub/distribution_worker.rb13
-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/reply_distribution_worker.rb7
-rw-r--r--app/workers/activitypub/sync_account_worker.rb57
-rw-r--r--app/workers/distribution_worker.rb5
-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/sidekiq.rb4
-rw-r--r--config/locales/en-MP.yml164
-rw-r--r--config/locales/simple_form.en-MP.yml80
-rw-r--r--config/navigation.rb4
-rw-r--r--config/routes.rb32
-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/20200720211530_add_hidden_to_conversation_mute.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/20200721212401_backfill_account_id_on_conversations.rb15
-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/20200726095058_backfill_semiprivate_on_statuses.rb14
-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/schema.rb124
-rw-r--r--lib/mastodon/snowflake.rb13
-rw-r--r--lib/mastodon/version.rb41
-rw-r--r--lib/tasks/monsterfork.rake22
-rw-r--r--monsterfork.code-workspace11
-rw-r--r--package.json3
-rw-r--r--public/registration.js54
-rw-r--r--yarn.lock45
361 files changed, 8250 insertions, 807 deletions
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 6e0ece406..cab8eae87 100644
--- a/Gemfile
+++ b/Gemfile
@@ -162,3 +162,9 @@ end
 
 gem 'concurrent-ruby', require: false
 gem 'connection_pool', require: false
+
+gem "reek", "~> 6.0", :group => :development
+
+gem "w3c_validators", "~> 1.3"
+
+gem "activerecord-import", "~> 1.0"
diff --git a/Gemfile.lock b/Gemfile.lock
index e72a3be84..15973bdec 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -54,6 +54,8 @@ GEM
       activemodel (= 5.2.4.3)
       activesupport (= 5.2.4.3)
       arel (>= 9.0)
+    activerecord-import (1.0.6)
+      activerecord (>= 3.2)
     activestorage (5.2.4.3)
       actionpack (= 5.2.4.3)
       activerecord (= 5.2.4.3)
@@ -319,6 +321,7 @@ GEM
       activerecord
       kaminari-core (= 1.2.1)
     kaminari-core (1.2.1)
+    kwalify (0.7.2)
     launchy (2.5.0)
       addressable (~> 2.7)
     letter_opener (1.7.0)
@@ -426,6 +429,7 @@ GEM
       pry (~> 0.13.0)
     pry-rails (0.3.9)
       pry (>= 0.10.4)
+    psych (3.1.0)
     public_suffix (4.0.5)
     puma (4.3.5)
       nio4r (~> 2.0)
@@ -501,6 +505,11 @@ GEM
       redis-store (>= 1.2, < 2)
     redis-store (1.9.0)
       redis (>= 4, < 5)
+    reek (6.0.1)
+      kwalify (~> 0.7.0)
+      parser (>= 2.5.0.0, < 2.8, != 2.5.1.1)
+      psych (~> 3.1.0)
+      rainbow (>= 2.0, < 4.0)
     regexp_parser (1.7.1)
     request_store (1.5.0)
       rack (>= 1.4)
@@ -642,6 +651,9 @@ GEM
     unf_ext (0.0.7.7)
     unicode-display_width (1.7.0)
     uniform_notifier (1.13.0)
+    w3c_validators (1.3.5)
+      json (>= 1.8)
+      nokogiri (~> 1.6)
     warden (1.2.8)
       rack (>= 2.0.6)
     webauthn (3.0.0.alpha1)
@@ -679,6 +691,7 @@ PLATFORMS
 DEPENDENCIES
   active_model_serializers (~> 0.10)
   active_record_query_trace (~> 1.7)
+  activerecord-import (~> 1.0)
   addressable (~> 2.7)
   annotate (~> 3.1)
   aws-sdk-s3 (~> 1.79)
@@ -776,6 +789,7 @@ DEPENDENCIES
   redis (~> 4.2)
   redis-namespace (~> 1.8)
   redis-rails (~> 5.0)
+  reek (~> 6.0)
   rqrcode (~> 1.1)
   rspec-rails (~> 4.0)
   rspec-sidekiq (~> 3.1)
@@ -802,7 +816,14 @@ DEPENDENCIES
   tty-prompt (~> 0.22)
   twitter-text (~> 1.14)
   tzinfo-data (~> 1.2020)
+  w3c_validators (~> 1.3)
   webauthn (~> 3.0.0.alpha1)
   webmock (~> 3.8)
   webpacker (~> 5.2)
   webpush
+
+RUBY VERSION
+   ruby 2.6.6p146
+
+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 54106933c..232a5fc71 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -10,20 +10,24 @@ class AccountsController < ApplicationController
   before_action :set_cache_headers
   before_action :set_body_classes
 
+  before_action :require_authenticated!, if: -> { @account.require_auth? || @account.private? }
+
   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
@@ -39,16 +43,19 @@ class AccountsController < ApplicationController
       end
 
       format.rss do
-        expires_in 1.minute, public: true
+        return forbidden if 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, fields: restrict_fields_to
       end
     end
@@ -61,19 +68,28 @@ 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_semiprivate: true,
+      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
@@ -84,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
@@ -123,8 +131,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
@@ -134,7 +148,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
 
@@ -151,15 +171,31 @@ class AccountsController < ApplicationController
     )
   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 params_slice(*keys)
     params.slice(*keys).permit(*keys)
   end
 
   def restrict_fields_to
-    if signed_request_account.present? || public_fetch_mode?
+    if current_account&.id == @account.id || (signed_request_account.present? && !blocked?)
       # Return all fields
     else
       %i(id type preferred_username inbox public_key endpoints)
     end
   end
+
+  def blocked?
+    @blocked ||= current_account && @account.blocking?(current_account)
+  end
+
+  def unauthorized?
+    @unauthorized ||= blocked? || (@account.private? && !following?(@account))
+  end
 end
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 0a561e7f0..3e67f3909 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 e066860bf..51945656f 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -10,9 +10,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
   before_action :set_statuses
   before_action :set_cache_headers
 
+  before_action :require_authenticated!, if: -> { @account.require_auth? }
+  before_action -> { require_following!(@account) }, if: -> { @account.private? }
+
   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', target_domain: current_account&.domain
   end
 
   private
@@ -31,7 +34,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
       ActivityPub::CollectionPresenter.new(
         id: account_outbox_url(@account),
         type: :ordered,
-        size: @account.statuses_count,
         first: outbox_url(page: true),
         last: outbox_url(page: true, min_id: 0)
       )
@@ -54,12 +56,39 @@ 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?),
+      include_semiprivate: owner? || mutual_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 = @account.statuses.permitted_for(@account, signed_request_account)
     @statuses = cache_collection_paginated_by_id(
-      @statuses,
+      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 43bf4e657..4d553fc07 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
@@ -33,6 +33,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
   def set_replies
     @replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses
     @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted])
+    @replies = @replies.without_semiprivate unless authenticated_or_following?(@account)
     @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
   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 467225547..ac49a4dca 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..3c8187a99 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,
+                  :require_dereference, :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 85a9133e3..d7e973e31 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
     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,66 @@ 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)
+    return Status.none if unauthorized?
+
+    @account.statuses.permitted_for(
+      @account,
+      current_account,
+      include_semiprivate: true,
+      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.private && !following?(@account)) || (@account.require_auth && !current_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 +105,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 0080faf33..e9f848ac9 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -44,7 +44,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def mute
-    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications))
+    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only))
     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..73d9df734 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, hidden: truthy_param?(:hide))
     @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/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
index 51b1621b6..187b6145c 100644
--- a/app/controllers/api/v1/statuses/pins_controller.rb
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -9,7 +9,7 @@ class Api::V1::Statuses::PinsController < Api::BaseController
 
   def create
     StatusPin.create!(account: current_account, status: @status)
-    distribute_add_activity!
+    distribute_add_activity! unless @status.semiprivate?
     render json: @status, serializer: REST::StatusSerializer
   end
 
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..cbd232a50 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,80 @@ 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],
                                          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],
+                                         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)
+      @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 +140,17 @@ class Api::V1::StatusesController < Api::BaseController
       :in_reply_to_id,
       :sensitive,
       :spoiler_text,
+      :title,
+      :footer,
+      :notify,
+      :publish,
       :visibility,
       :scheduled_at,
       :content_type,
+      :expires_at,
+      :publish_at,
+      tags: [],
+      mentions: [],
       media_ids: [],
       poll: [
         :multiple,
@@ -100,4 +164,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/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index 52b5cb323..b53f7750f 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -41,7 +41,8 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def public_timeline_statuses
-    Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local))
+    local = truthy_param?(:local) ? true : :local_reblogs
+    Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : local)
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e996c2217..8154924b9 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
@@ -48,7 +49,7 @@ class ApplicationController < ActionController::Base
   end
 
   def authorized_fetch_mode?
-    ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode
+    !(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!(account)
+    forbidden unless 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 96d973394..55975b274 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 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 460f71f65..65168efff 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/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/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/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 75c3e2495..ddbf89665 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -11,6 +11,7 @@ class Settings::PreferencesController < Settings::BaseController
     user_settings.update(user_settings_params.to_h)
 
     if current_user.update(user_params)
+      Rails.cache.delete("filter_settings:#{current_user.account_id}")
       I18n.locale = current_user.locale
       redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg')
     else
@@ -61,6 +62,21 @@ 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_publish_in,
+      :setting_unpublish_in,
+      :setting_unpublish_delete,
+      :setting_boost_every,
+      :setting_boost_jitter,
+      :setting_boost_random,
+      :setting_filter_to_unknown,
+      :setting_filter_from_unknown,
+      :setting_unpublish_on_delete,
       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 19a7ce157..8c4efa21d 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -23,7 +23,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,
+                                    :require_dereference, :show_replies, :show_unlisted, :private, :require_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..6f8e74414 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -8,7 +8,9 @@ class StatusesController < ApplicationController
 
   layout 'public'
 
-  before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
+  before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? && current_user&.account_id != @account.id }
+  before_action :require_authenticated!, if: -> { @account.require_auth? }
+  before_action -> { require_following!(@account) }, if: -> { request.format != :json && @account.private? }
   before_action :set_status
   before_action :set_instance_presenter
   before_action :set_link_headers
@@ -19,7 +21,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 +39,18 @@ 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, target_domain: current_account&.domain
       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),
+                      content_type: 'application/activity+json',
+                      serializer: ActivityPub::ActivitySerializer,
+                      adapter: ActivityPub::Adapter,
+                      target_domain: current_account&.domain
   end
 
   def embed
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 69db89eb3..368419ef5 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -9,13 +9,13 @@ 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_tag
   before_action :set_local
   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|
@@ -37,10 +37,12 @@ class TagsController < ApplicationController
       format.json do
         expires_in 3.minutes, public: public_fetch_mode?
 
-        @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local).paginate_by_max_id(PAGE_SIZE, params[:max_id])
+        @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local)
+        @statuses = @statuses.without_semiprivate unless authenticated_or_following?(@account)
+        @statuses = @statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id])
         @statuses = cache_collection(@statuses, Status)
 
-        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', target_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..b2baa2843
--- /dev/null
+++ b/app/controllers/user_webapp_css_controller.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+class UserWebappCssController < 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: false
+    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_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_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..765ffa536 100644
--- a/app/helpers/domain_control_helper.rb
+++ b/app/helpers/domain_control_helper.rb
@@ -20,6 +20,6 @@ module DomainControlHelper
   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 87718dc05..bb98a71a5 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 e1012a80b..32e533bd0 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -264,11 +264,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id, notifications) {
+export function muteAccount(id, notifications, timelinesOnly) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly }).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..4c2cca9eb 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -147,6 +147,9 @@ 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;
@@ -156,7 +159,7 @@ export function submitCompose(routerHistory) {
     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),
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 927fc7415..645261627 100644
--- a/app/javascript/flavours/glitch/actions/mutes.js
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -13,6 +13,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_TOGGLE_TIMELINES_ONLY = 'MUTES_TOGGLE_TIMELINES_ONLY';
 
 export function fetchMutes() {
   return (dispatch, getState) => {
@@ -104,3 +105,9 @@ export function toggleHideNotifications() {
     dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
   };
 }
+
+export function toggleTimelinesOnly() {
+  return dispatch => {
+    dispatch({ type: MUTES_TOGGLE_TIMELINES_ONLY });
+  };
+}
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 96042f07a..1ab9a6adb 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -384,6 +384,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 })}>
@@ -396,6 +456,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 4e628a420..69f93a2f1 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -73,6 +73,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,
@@ -368,7 +370,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);
     }
   };
@@ -672,6 +674,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) {
@@ -692,6 +697,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'),
       read: unread === false,
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index cfb03c21b..0822239f5 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}' },
@@ -63,6 +65,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,
@@ -125,7 +129,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);
@@ -135,6 +139,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);
   }
@@ -221,10 +233,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) {
@@ -233,6 +243,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 2cbe3d094..bccaba92d 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';
@@ -38,6 +38,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}}' },
@@ -166,6 +168,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 6576bff8e..2f5a943fd 100644
--- a/app/javascript/flavours/glitch/features/account/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -31,14 +31,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>
       );
     }
@@ -51,17 +57,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 8195735a1..591f8dffc 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 {
 
@@ -123,9 +124,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 5558ba2a3..66bf55ec4 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -17,15 +17,15 @@ import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
-const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
-  const path = withReplies ? `${accountId}:with_replies` : accountId;
+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']),
   };
@@ -49,7 +49,7 @@ class AccountTimeline extends ImmutablePureComponent {
     featuredStatusIds: ImmutablePropTypes.list,
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
-    withReplies: PropTypes.bool,
+    filter: PropTypes.string,
     isAccount: PropTypes.bool,
     remote: PropTypes.bool,
     remoteUrl: PropTypes.string,
@@ -57,24 +57,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 }));
     }
   }
 
@@ -83,7 +83,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..1c05fdafc 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -71,10 +71,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 +151,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 +269,7 @@ class ComposeForm extends ImmutablePureComponent {
       handleSecondarySubmit,
       handleSelect,
       handleSubmit,
+      handleClearAll,
       handleRefTextarea,
     } = this;
     const {
@@ -281,6 +295,7 @@ class ComposeForm extends ImmutablePureComponent {
       suggestions,
       text,
       spoilersAlwaysOn,
+      clearTimeout,
     } = this.props;
 
     let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
@@ -356,6 +371,7 @@ class ComposeForm extends ImmutablePureComponent {
           disabled={disabledButton}
           onSecondarySubmit={handleSecondarySubmit}
           onSubmit={handleSubmit}
+          onClearAll={handleClearAll}
           privacy={privacy}
           sideArm={sideArm}
         />
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
index 97890f40d..e5a3d023f 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,6 +38,7 @@ class Publisher extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     onSecondarySubmit: PropTypes.func,
     onSubmit: PropTypes.func,
+    onClearAll: PropTypes.func,
     privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
     sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
   };
@@ -43,7 +48,7 @@ class Publisher extends ImmutablePureComponent {
   };
 
   render () {
-    const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props;
+    const { countText, disabled, intl, onClearAll, onSecondarySubmit, privacy, sideArm } = this.props;
 
     const diff = maxChars - length(countText || '');
     const computedClass = classNames('composer--publisher', {
@@ -53,6 +58,17 @@ 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'
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..3c641d7ec 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,6 +12,7 @@ import {
   selectComposeSuggestion,
   submitCompose,
   uploadCompose,
+  resetCompose,
 } from 'flavours/glitch/actions/compose';
 import {
   openModal,
@@ -82,6 +83,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(submitCompose(routerHistory));
   },
 
+  onClearAll() {
+    dispatch(resetCompose());
+  },
+
   onClearSuggestions() {
     dispatch(clearComposeSuggestions());
   },
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 e4aecbf94..4344e9cce 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';
 
 export default class DetailedStatus extends ImmutablePureComponent {
 
@@ -195,7 +195,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';
@@ -203,7 +203,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 = (
@@ -211,9 +211,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>
       );
@@ -223,37 +220,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} />
@@ -270,13 +273,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 3e2e95f35..3a6847e8d 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' },
@@ -304,6 +306,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));
   }
@@ -588,6 +604,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/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
index 2aab82751..eb4bc02d2 100644
--- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
@@ -7,19 +7,21 @@ import Button from 'flavours/glitch/components/button';
 import { closeModal } from 'flavours/glitch/actions/modal';
 import { muteAccount } from 'flavours/glitch/actions/accounts';
 import { toggleHideNotifications } from 'flavours/glitch/actions/mutes';
+import { toggleTimelinesOnly } from 'flavours/glitch/actions/mutes';
 
 
 const mapStateToProps = state => {
   return {
     account: state.getIn(['mutes', 'new', 'account']),
     notifications: state.getIn(['mutes', 'new', 'notifications']),
+    timelinesOnly: state.getIn(['mutes', 'new', 'timelines_only']),
   };
 };
 
 const mapDispatchToProps = dispatch => {
   return {
-    onConfirm(account, notifications) {
-      dispatch(muteAccount(account.get('id'), notifications));
+    onConfirm(account, notifications, timelinesOnly) {
+      dispatch(muteAccount(account.get('id'), notifications, timelinesOnly));
     },
 
     onClose() {
@@ -29,6 +31,10 @@ const mapDispatchToProps = dispatch => {
     onToggleNotifications() {
       dispatch(toggleHideNotifications());
     },
+
+    onToggleTimelinesOnly() {
+      dispatch(toggleTimelinesOnly());
+    },
   };
 };
 
@@ -39,9 +45,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,
   };
 
@@ -51,7 +59,7 @@ class MuteModal extends React.PureComponent {
 
   handleClick = () => {
     this.props.onClose();
-    this.props.onConfirm(this.props.account, this.props.notifications);
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly);
   }
 
   handleCancel = () => {
@@ -66,8 +74,12 @@ class MuteModal extends React.PureComponent {
     this.props.onToggleNotifications();
   }
 
+  toggleTimelinesOnly = () => {
+    this.props.onToggleTimelinesOnly();
+  }
+
   render () {
-    const { account, notifications } = this.props;
+    const { account, notifications, timelinesOnly } = this.props;
 
     return (
       <div className='modal-root__modal mute-modal'>
@@ -91,6 +103,13 @@ class MuteModal extends React.PureComponent {
               <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
             </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>
 
         <div className='mute-modal__action-bar'>
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 bf76c0e57..ee1d898bb 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -214,8 +214,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 3d94d665c..9f383abae 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',
@@ -51,7 +51,7 @@ const initialState = ImmutableMap({
     reveal_behind_cw : false,
   }),
   notifications : ImmutableMap({
-    favicon_badge : false,
+    favicon_badge : true,
     tab_badge     : true,
   }),
 });
diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js
index 7111bb710..d170c2594 100644
--- a/app/javascript/flavours/glitch/reducers/mutes.js
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -3,12 +3,14 @@ import Immutable from 'immutable';
 import {
   MUTES_INIT_MODAL,
   MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+  MUTES_TOGGLE_TIMELINES_ONLY,
 } from 'flavours/glitch/actions/mutes';
 
 const initialState = Immutable.Map({
   new: Immutable.Map({
     account: null,
     notifications: true,
+    timelinesOnly: false,
   }),
 });
 
@@ -18,9 +20,12 @@ 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_TOGGLE_TIMELINES_ONLY:
+    return state.updateIn(['new', 'timelines_only'], (old) => !old);
   default:
     return state;
   }
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/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index d0be730ac..f80045505 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -847,7 +847,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 1ed1a5778..9ddabe6f4 100644
--- a/app/javascript/flavours/glitch/styles/variables.scss
+++ b/app/javascript/flavours/glitch/styles/variables.scss
@@ -49,11 +49,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 531425573..da136da03 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -556,7 +556,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/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 b7babd4ad..88fde4ee0 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -250,10 +250,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..ca175119e
--- /dev/null
+++ b/app/javascript/mastodon/locales/en-MP.json
@@ -0,0 +1,176 @@
+{
+  "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": "Fediverse",
+  "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.",
+  "content-type.change": "Content type",
+  "directory.federated": "From Fediverse",
+  "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": "Fediverse",
+  "introduction.federation.federated.text": "Public roars from other servers will appear in the Fediverse 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 Fediverse 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": "Fediverse",
+  "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",
+  "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": "Fediverse",
+  "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 8602c3dde..1b2499aa6 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -51,6 +51,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 94aee7939..337f64d53 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..327def623 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?
@@ -50,7 +50,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
     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/create.rb b/app/lib/activitypub/activity/create.rb
index 3a9f83978..9d03e5247 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -1,6 +1,9 @@
 # frozen_string_literal: true
 
+# rubocop:disable Metrics/ClassLength
 class ActivityPub::Activity::Create < ActivityPub::Activity
+  include ImgProxyHelper
+
   def perform
     dereference_object!
 
@@ -43,7 +46,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 +54,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,17 +75,33 @@ 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)
+      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
 
@@ -108,7 +127,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: @object['sensitive'] || false,
@@ -121,6 +143,55 @@ 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
     (audience_to + audience_cc).uniq.each do |audience|
       next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
@@ -240,7 +311,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 +415,43 @@ 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?
+        params = {
+          uri: uri,
+          root: object_uri,
+          account: @account,
+        }.freeze
+        if conversation.blank?
+          conversation = Conversation.create!(params)
+        elsif conversation.root.blank?
+          conversation.update!(params)
+        end
+      elsif conversation.blank?
+        conversation = Conversation.create!(uri: uri, account_id: nil)
+      end
+
+      conversation
     rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
       retry
     end
@@ -377,7 +483,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 +496,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 +529,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 +573,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 +623,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 +661,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 dc9ff580c..1420c6aff 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 4e406b41d..93fd2d910 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -8,6 +8,18 @@ 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' },
+    require_dereference: { 'mp' => 'https://the.monsterpit.net/ns#', 'requireDereference' => 'mp:requireDereference' },
+    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' },
+    require_auth: { 'mp' => 'https://the.monsterpit.net/ns#', 'requireAuth' => 'mp:requireAuth' },
+    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 +27,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 3f98dad2e..c26301f7e 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -60,8 +60,8 @@ 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
+  def to(status, target_domain: nil)
+    case status.visibility_for_domain(target_domain)
     when 'public'
       [COLLECTIONS[:public]]
     when 'unlisted', 'private'
@@ -92,19 +92,39 @@ 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, target_domain: nil)
     cc = []
 
     cc << uri_for(status.reblog.account) if status.reblog?
 
-    case status.visibility
+    visibility = status.visibility_for_domain(target_domain)
+
+    case visibility
     when 'public'
       cc << account_followers_url(status.account)
     when 'unlisted'
       cc << COLLECTIONS[:public]
+    when 'limited'
+      if status.account.silenced?
+        # Only notify followers if the account is locally silenced
+        account_ids = status.silent_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.silent_mentions.each_with_object([]) do |mention, result|
+          result << uri_for(mention.account)
+          result << account_followers_url(mention.account) if mention.account.group?
+        end)
+      end
     end
 
-    unless status.direct_visibility? || status.limited_visibility?
+    unless %w(direct limited).include?(visibility)
       if status.account.silenced?
         # Only notify followers if the account is locally silenced
         account_ids = status.active_mentions.pluck(:account_id)
diff --git a/app/lib/command_tag/command/account_tools.rb b/app/lib/command_tag/command/account_tools.rb
new file mode 100644
index 000000000..ac38f19a1
--- /dev/null
+++ b/app/lib/command_tag/command/account_tools.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+module CommandTag::Command::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/command/footer_tools.rb b/app/lib/command_tag/command/footer_tools.rb
new file mode 100644
index 000000000..73e2f05bd
--- /dev/null
+++ b/app/lib/command_tag/command/footer_tools.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+module CommandTag::Command::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/command/hello_world.rb b/app/lib/command_tag/command/hello_world.rb
new file mode 100644
index 000000000..ab10b495b
--- /dev/null
+++ b/app/lib/command_tag/command/hello_world.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module CommandTag::Command::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/command/parent_status_tools.rb b/app/lib/command_tag/command/parent_status_tools.rb
new file mode 100644
index 000000000..2fdee2fb8
--- /dev/null
+++ b/app/lib/command_tag/command/parent_status_tools.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+module CommandTag::Command::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/command/status_tools.rb b/app/lib/command_tag/command/status_tools.rb
new file mode 100644
index 000000000..b2ddca422
--- /dev/null
+++ b/app/lib/command_tag/command/status_tools.rb
@@ -0,0 +1,239 @@
+# frozen_string_literal: true
+module CommandTag::Command::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/command/text_tools.rb b/app/lib/command_tag/command/text_tools.rb
new file mode 100644
index 000000000..2c44167b4
--- /dev/null
+++ b/app/lib/command_tag/command/text_tools.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module CommandTag::Command::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
+end
diff --git a/app/lib/command_tag/command/variables.rb b/app/lib/command_tag/command/variables.rb
new file mode 100644
index 000000000..6ba32ea41
--- /dev/null
+++ b/app/lib/command_tag/command/variables.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module CommandTag::Command::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/commands.rb b/app/lib/command_tag/commands.rb
new file mode 100644
index 000000000..f27486427
--- /dev/null
+++ b/app/lib/command_tag/commands.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Dir[File.join(__dir__, 'command', '*.rb')].sort.each { |file| require file }
+
+module CommandTag::Commands
+  def self.included(base)
+    CommandTag::Command.constants.map(&CommandTag::Command.method(:const_get)).grep(Module) do |mod|
+      base.include(mod)
+    end
+  end
+end
diff --git a/app/lib/command_tag/processor.rb b/app/lib/command_tag/processor.rb
new file mode 100644
index 000000000..8461e902f
--- /dev/null
+++ b/app/lib/command_tag/processor.rb
@@ -0,0 +1,335 @@
+# 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
+
+  # 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_start = to_integer(parts[1])
+        index_end   = to_integer(parts[2])
+
+        if ['all', '[]'].include?(parts[1])
+          var(name).join(separator)
+        elsif index_end.zero?
+          var(name)[index_start].presence || ''
+        else
+          var(name)[index_start..index_end].presence || ''
+        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 915f3fa58..4659f6c4d 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -2,14 +2,15 @@
 
 require 'singleton'
 
+# rubocop:disable Metrics/ClassLength
 class FeedManager
   include Singleton
   include Redisable
 
-  MAX_ITEMS = 400
+  MAX_ITEMS = 1000
 
   # Must be <= MAX_ITEMS or the tracking sets will grow forever
-  REBLOG_FALLOFF = 40
+  REBLOG_FALLOFF = 50
 
   def with_active_accounts(&block)
     Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each(&block)
@@ -22,8 +23,8 @@ class FeedManager
   end
 
   def filter?(timeline_type, status, receiver_id)
-    if timeline_type == :home
-      filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]))
+    if [:home, :list].include?(timeline_type)
+      filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]), filter_options_for(receiver_id))
     elsif timeline_type == :mentions
       filter_from_mentions?(status, receiver_id)
     elsif timeline_type == :direct
@@ -49,8 +50,11 @@ class FeedManager
   end
 
   def push_to_list(list, status)
+    return false if status.reblog?
+
     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 &&= status.account_id == list.account_id
       should_filter &&= !list.show_all_replies?
       should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
       return false if should_filter
@@ -72,6 +76,7 @@ class FeedManager
 
   def push_to_direct(account, status)
     return false unless add_to_feed(:direct, account.id, status)
+
     trim(:direct, account.id)
     PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}")
     true
@@ -79,9 +84,29 @@ class FeedManager
 
   def unpush_from_direct(account, status)
     return false unless remove_from_feed(:direct, account.id, status)
+
     redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
   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
+
   def trim(type, account_id)
     timeline_key = key(type, account_id)
     reblog_key   = key(type, account_id, 'reblogs')
@@ -119,9 +144,10 @@ class FeedManager
 
     statuses = query.to_a
     crutches = build_crutches(into_account.id, statuses)
+    filter_options = filter_options_for(into_account.id)
 
     statuses.each do |status|
-      next if filter_from_home?(status, into_account.id, crutches)
+      next if filter_from_home?(status, into_account.id, crutches, filter_options)
 
       add_to_feed(:home, into_account.id, status, aggregate)
     end
@@ -174,11 +200,12 @@ 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.without_replies.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(limit)
       crutches = build_crutches(account.id, statuses)
+      filter_options = filter_options_for(account.id)
 
       statuses.each do |status|
-        next if filter_from_home?(status, account.id, crutches)
+        next if filter_from_home?(status, account.id, crutches, filter_options)
 
         add_to_feed(:home, account.id, status, aggregate)
       end
@@ -199,6 +226,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
 
@@ -219,36 +247,73 @@ class FeedManager
       (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?)
   end
 
-  def filter_from_home?(status, receiver_id, crutches)
+  def filter_from_home?(status, receiver_id, crutches, filter_options)
+    conversation = status.conversation
+    reblog_conversation = status.reblog&.conversation
+
     return false if receiver_id == status.account_id
+    return true  unless status.published?
+    return true  if crutches[:hiding_thread][status.conversation_id] if conversation.present?
     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.account_id, conversation&.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([status.reblog.account_id, reblog_conversation&.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
 
+    check_for_blocks.uniq!
+    check_for_blocks.compact!
     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 author...
+      should_filter   = !crutches[:following][status.in_reply_to_account_id]
+      # (optional) ...or the owner(s) of the thread...
+      should_filter ||= !crutches[:following][conversation.account_id] if filter_options[:to_unknown] && conversation&.account_id.present?
+      # ...and the author isn't replying to a post you wrote...
+      should_filter &&= receiver_id != status.in_reply_to_account_id
+      # ...and the author isn't mentioning you.
+      should_filter &&= !crutches[:active_mentions][receiver_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...
+      should_filter = false
+
+      # ...it's a reply...
+      if status.reblog.reply? && !status.reblog.in_reply_to_account_id.nil?
+        # ...and you don't follow the author if:
+        # - you're filtering replies to parent authors you don't follow
+        # - they're silenced on this server
+        should_filter ||= !crutches[:following][status.reblog.in_reply_to_account_id] if filter_options[:to_unknown] || status.reblog.in_reply_to_account.silenced?
+        # - you're filtering replies to threads whose owners you don't follow
+        should_filter ||= !crutches[:following][reblog_conversation.account_id] if filter_options[:to_unknown] && reblog_conversation&.account_id.present?
+        # ...or you're blocking their domain...
+        should_filter ||= crutches[:domain_blocking][status.reblog.thread.account.domain] if status.reblog.thread.present?
+      end
+
+      # ...or it's a post from a thread's trunk and you don't follow the author if:
+      # - you're filtering boosts of authors you don't follow
+      # - they're silenced on this server
+      should_filter ||= !crutches[:following][status.reblog.account_id] if filter_options[:from_unknown] || status.reblog.account.silenced?
+
+      # ..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]
 
       return !!should_filter
     end
 
-    false
+    crutches[:following][status.account_id]
   end
 
   def filter_from_mentions?(status, receiver_id)
@@ -261,14 +326,21 @@ 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 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
+
   def filter_from_direct?(status, receiver_id)
     return false if receiver_id == status.account_id
+
     filter_from_mentions?(status, receiver_id)
   end
 
@@ -388,6 +460,17 @@ class FeedManager
     redis.zrem(timeline_key, status.id)
   end
 
+  def filter_options_for(receiver_id)
+    Rails.cache.fetch("filter_settings:#{receiver_id}", expires_in: 1.month) do
+      return {} if (settings = User.find_by(account_id: receiver_id)&.settings).blank?
+
+      {
+        to_unknown: settings.filter_to_unknown,
+        from_unknown: settings.filter_from_unknown,
+      }
+    end
+  end
+
   def build_crutches(receiver_id, statuses)
     crutches = {}
 
@@ -411,7 +494,9 @@ class FeedManager
     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[: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, hidden: true).pluck(:conversation_id).each_with_object({}) { |id, mapping| mapping[id] = true }
 
     crutches
   end
 end
+# rubocop:enable Metrics/ClassLength
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index c0f7866bf..159e3ec54 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -24,6 +24,7 @@ class HTMLRenderer < Redcarpet::Render::HTML
   end
 end
 
+# rubocop:disable Metrics/ClassLength
 class Formatter
   include Singleton
   include RoutingHelper
@@ -31,50 +32,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
@@ -89,7 +135,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
@@ -98,8 +148,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
@@ -122,8 +176,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")
 
@@ -154,7 +208,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,
@@ -390,4 +444,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 ccc3f4642..adbbd2168 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -30,28 +30,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|
@@ -83,15 +78,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: {
@@ -107,7 +104,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..eb31dcad6 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
@@ -53,6 +55,8 @@ class StatusFilter
   end
 
   def policy_allows_show?
-    StatusPolicy.new(account, status, @preloaded_relations).show?
+    return false unless StatusPolicy.new(account, status, @preloaded_relations).show?
+
+    status.reblog? ? StatusPolicy.new(account, status.reblog, @preloaded_relations).show? : true
   end
 end
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 2f9cfe3ad..386b1dcf6 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)
@@ -31,18 +35,34 @@ 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['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_to_unknown']   = filter_to_unknown_preference if change?('setting_filter_to_unknown')
+    user.settings['filter_from_unknown'] = filter_from_unknown_preference if change?('setting_filter_from_unknown')
+    user.settings['unpublish_on_delete'] = unpublish_on_delete_preference if change?('setting_unpublish_on_delete')
   end
 
   def merged_notification_emails
@@ -157,6 +177,70 @@ 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 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_to_unknown_preference
+    boolean_cast_setting 'setting_filter_to_unknown'
+  end
+
+  def filter_from_unknown_preference
+    boolean_cast_setting 'setting_filter_from_unknown'
+  end
+
+  def unpublish_on_delete_preference
+    boolean_cast_setting 'setting_unpublish_on_delete'
+  end
+
   def boolean_cast_setting(key)
     ActiveModel::Type::Boolean.new.cast(settings[key])
   end
@@ -172,4 +256,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 0b3c48543..c7bf7bf80 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -50,6 +50,12 @@
 #  avatar_storage_schema_version :integer
 #  header_storage_schema_version :integer
 #  devices_url                   :string
+#  require_dereference           :boolean          default(FALSE), not null
+#  show_replies                  :boolean          default(TRUE), not null
+#  show_unlisted                 :boolean          default(TRUE), not null
+#  private                       :boolean          default(FALSE), not null
+#  require_auth                  :boolean          default(FALSE), not null
+#  last_synced_at                :datetime
 #
 
 class Account < ApplicationRecord
@@ -115,6 +121,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,
@@ -357,6 +364,38 @@ class Account < ApplicationRecord
     shared_inbox_url.presence || inbox_url
   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
 
@@ -525,6 +564,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
@@ -568,4 +609,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 cca3a17fa..a8b024346 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -60,5 +60,23 @@ module AccountAssociations
     # Hashtags
     has_and_belongs_to_many :tags
     has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
+
+    # Threads
+    has_many :threads, class_name: 'Conversation', inverse_of: :account, dependent: :nullify
+
+    # 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
   end
 end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index be7211f2c..538e92f41 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -25,7 +25,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?,
         }
@@ -90,9 +90,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, uri: nil, rate_limit: false)
@@ -125,21 +126,22 @@ module AccountInteractions
                        .find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account, notifications: nil)
+  def mute!(other_account, notifications: nil, timelines_only: nil)
     notifications = true if notifications.nil?
-    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_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_create_by!(target_account: other_account)
     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)
-    end
+    mute.update!(hide_notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? != notifications
 
     mute
   end
 
-  def mute_conversation!(conversation)
-    conversation_mutes.find_or_create_by!(conversation: conversation)
+  def mute_conversation!(conversation, hidden: false)
+    mute = conversation_mutes.find_or_create_by!(conversation: conversation)
+    mute.update(hidden: hidden) if hidden.present? && mute.hidden? != hidden
+    mute
   end
 
   def block_domain!(other_domain)
@@ -171,6 +173,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
@@ -184,13 +195,17 @@ 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)
     conversation_mutes.where(conversation: conversation).exists?
   end
 
+  def hiding_conversation?(conversation)
+    conversation_mutes.where(conversation: conversation, hidden: true).exists?
+  end
+
   def muting_notifications?(other_account)
     mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
   end
@@ -199,6 +214,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..e065c34c8 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -7,12 +7,17 @@
 #  uri        :string
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  account_id :bigint(8)
+#  public     :boolean          default(FALSE), 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
+  belongs_to :account, inverse_of: :threads, optional: true
 
   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/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 3325e264c..cdf0f4bda 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -29,7 +29,10 @@ class FollowRequest < ApplicationRecord
 
   def authorize!
     account.follow!(target_account, reblogs: show_reblogs, 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/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 29d25eae8..4695b4ebb 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/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 639120f7d..11f833d8e 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -9,6 +9,7 @@
 #  hide_notifications :boolean          default(TRUE), not null
 #  account_id         :bigint(8)        not null
 #  target_account_id  :bigint(8)        not null
+#  timelines_only     :boolean          default(FALSE), not null
 #
 
 class Mute < ApplicationRecord
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 594ae98c0..3d524dec5 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
+#  semiprivate            :boolean          default(FALSE), not null
+#  original_text          :text
+#  footer                 :text
+#  expires_at             :datetime
+#  publish_at             :datetime
+#  originally_local_only  :boolean          default(FALSE), not null
 #
 
+# rubocop:disable Metrics/ClassLength
 class Status < ApplicationRecord
   before_destroy :unlink_from_conversations
 
@@ -52,7 +62,7 @@ class Status < ApplicationRecord
   belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
 
   belongs_to :account, inverse_of: :statuses
-  belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true
+  belongs_to :in_reply_to_account, class_name: 'Account', optional: true
   belongs_to :conversation, optional: true
   belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true
 
@@ -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
 
@@ -93,7 +110,8 @@ class Status < ApplicationRecord
   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_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) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
@@ -113,6 +131,22 @@ 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 :without_semiprivate, -> { where(semiprivate: false) }
+  scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') }
+  scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) }
+  scope :conversations_by, ->(account) { joins(:conversation).where(conversations: { account: account }) }
+  scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) }
+  scope :replies, -> { where(reply: true).where('statuses.in_reply_to_account_id != statuses.account_id') }
+  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 :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 != ? AND conversation_mutes.hidden = TRUE))', account.id, account.id)
+  end
+
   cache_associated :application,
                    :media_attachments,
                    :conversation,
@@ -136,8 +170,20 @@ 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
 
   def searchable_by(preloaded = nil)
     ids = []
@@ -204,7 +250,7 @@ class Status < ApplicationRecord
   end
 
   def hidden?
-    !distributable?
+    !published? || !distributable?
   end
 
   def 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,99 @@ class Status < ApplicationRecord
     update_status_stat!(key => [public_send(key) - 1, 0].max)
   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?
+
+    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_be_semiprivate?
+    return @should_be_semiprivate if defined?(@should_be_semiprivate)
+    return @should_be_semiprivate = true if distributable? && (private_domain_permissions? || account.private_domain_permissions?)
+
+    @should_be_semiprivate = !distributable? && (public_domain_permissions? || account.public_domain_permissions?)
+  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_semiprivate, if: :local?
+  after_save :set_conversation_root
+
   class << self
     def selectable_visibilities
       visibilities.keys - %w(direct limited)
@@ -338,7 +459,7 @@ class Status < ApplicationRecord
     end
 
     def as_tag_timeline(tag, account = nil, local_only = false)
-      query = timeline_scope(local_only).tagged_with(tag)
+      query = timeline_scope(local_only, include_unlisted: true).tagged_with(tag)
 
       apply_timeline_filters(query, account, local_only)
     end
@@ -363,6 +484,14 @@ 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_conversations_map(conversation_ids, account_id)
+      ConversationMute.select('conversation_id').where(conversation_id: conversation_ids, hidden: true).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
@@ -379,7 +508,7 @@ class Status < ApplicationRecord
 
       return if account_ids.empty?
 
-      accounts = Account.where(id: account_ids).includes(:account_stat).each_with_object({}) { |a, h| h[a.id] = a }
+      accounts = Account.where(id: account_ids).includes(:account_stat).index_by(&:id)
 
       cached_items.each do |item|
         item.account = accounts[item.account_id]
@@ -387,26 +516,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)
@@ -426,21 +555,75 @@ class Status < ApplicationRecord
 
     private
 
-    def timeline_scope(scope = false)
+    # 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]
+      query = query.without_semiprivate unless options[:include_semiprivate]
+
+      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
+
+        query = if options[:include_replies]
+                  query = query.replies if options[:only_replies]
+                  query.conversations_by(target_account)
+                else
+                  query.without_replies
+                end
+      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
+
+    def timeline_scope(scope = false, include_unlisted: false)
       starting_scope = case scope
                        when :local, true
                          Status.local
                        when :remote
                          Status.remote
+                       when :local_reblogs
+                         Status.locally_reblogged
                        else
                          Status
                        end
-      starting_scope = starting_scope.with_public_visibility
-      if Setting.show_reblogs_in_public_timelines
-        starting_scope
-      else
-        starting_scope.without_reblogs
-      end
+      starting_scope = include_unlisted ? starting_scope.distributable : starting_scope.with_public_visibility
+      scope != :local_reblogs ? starting_scope.without_reblogs : starting_scope
     end
 
     def apply_timeline_filters(query, account, local_only)
@@ -455,6 +638,7 @@ class Status < ApplicationRecord
       query = query.not_excluded_by_account(account)
       query = query.not_domain_blocked_by_account(account) unless local_only
       query = query.in_chosen_languages(account) if account.chosen_languages.present?
+      query = query.not_hidden_by_account(account)
       query.merge(account_silencing_filter(account))
     end
 
@@ -497,9 +681,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
@@ -510,31 +700,38 @@ 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.conversation = reply? ? Conversation.new(account_id: nil) : Conversation.new(account_id: account_id)
+    elsif !reply? && account_id != conversation.account_id
+      conversation.update!(account_id: account_id)
     end
   end
 
+  def set_conversation_root
+    conversation.update!(root: uri, account_id: account_id) if !reply && 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
@@ -547,6 +744,32 @@ 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 set_semiprivate
+    update_column(:semiprivate, should_be_semiprivate?) if semiprivate != should_be_semiprivate?
+  end
+
   def update_statistics
     return unless distributable?
 
@@ -580,3 +803,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 4467362e1..6fa6bb369 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,6 +41,8 @@
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
+#  username                  :string
+#  kobold                    :string
 #
 
 class User < ApplicationRecord
@@ -89,7 +91,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) }
@@ -116,6 +118,12 @@ class User < ApplicationRecord
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
            :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,
+           :publish_in, :unpublish_in, :unpublish_delete, :boost_every, :boost_jitter,
+           :boost_random,
+           :filter_to_unknown, :filter_from_unknown, :unpublish_on_delete,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_reader :invite_code, :sign_in_token_attempt
@@ -149,7 +157,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
@@ -307,6 +315,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?
 
@@ -433,4 +452,17 @@ class User < ApplicationRecord
   def validate_email_dns?
     email_changed? && !(Rails.env.test? || Rails.env.development?)
   end
+
+  def user_might_not_be_a_spam_bot
+    username == account.username && 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].compact.map(&:downcase).join("\u{F0666}")
+    Digest::SHA512.hexdigest(value).upcase
+  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/status_policy.rb b/app/policies/status_policy.rb
index fa5c0dd9c..9f851feb3 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -12,19 +12,20 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def show?
-    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 private?
-      owned? || following_author? || mention_exists?
+      owned? || following_owners? || mention_exists?
     else
-      current_account.nil? || (!author_blocking? && !author_blocking_domain?)
+      current_account.nil? || !blocked_by_owners?
     end
   end
 
   def reblog?
-    !requires_mention? && (!private? || owned?) && show? && !blocking_author?
+    published? && !requires_mention? && (!private? || owned?) && show? && !blocking_author?
   end
 
   def favourite?
@@ -44,7 +45,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?
@@ -52,7 +53,7 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def private?
-    record.private_visibility?
+    visibility_for_remote_domain == 'private'
   end
 
   def mention_exists?
@@ -71,6 +72,12 @@ class StatusPolicy < ApplicationPolicy
     author.domain_blocking?(current_account.domain)
   end
 
+  def conversation_author_blocking_domain?
+    return false if current_account.nil? || current_account.domain.nil? || conversation_owner.nil?
+
+    conversation_owner.domain_blocking?(current_account.domain)
+  end
+
   def blocking_author?
     return false if current_account.nil?
 
@@ -78,22 +85,63 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def author_blocking?
-    return false if current_account.nil?
+    return author.require_auth? if current_account.nil?
 
     @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][author.id] : author.blocking?(current_account)
   end
 
+  def conversation_author_blocking?
+    return false if conversation_owner.nil?
+
+    @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][conversation_owner.id] : conversation_owner.blocking?(current_account)
+  end
+
+  def blocked_by_owners?
+    return author_blocking? || author_blocking_domain? if conversation_owner&.id == author.id
+    return true if conversation_author_blocking? || author_blocking?
+
+    conversation_author_blocking_domain? || author_blocking_domain?
+  end
+
   def following_author?
     return false if current_account.nil?
 
     @preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author)
   end
 
+  def following_conversation_owner?
+    return false if current_account.nil? || conversation_owner.nil?
+
+    @preloaded_relations[:following] ? @preloaded_relations[:following][conversation_owner.id] : current_account.following?(conversation_owner)
+  end
+
+  def following_owners?
+    return following_author? if conversation_owner&.id == author.id
+
+    following_conversation_owner? && following_author?
+  end
+
   def author
-    record.account
+    @author ||= record.account
   end
-  
+
+  def conversation_owner
+    @conversation_owner ||= record.conversation&.account
+  end
+
   def local_only?
     record.local_only?
   end
+
+  def published?
+    record.published?
+  end
+
+  def reply?
+    record.reply? && record.in_reply_to_account_id != author.id
+  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..7a19cc96a 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, update: false, embed: true)
       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)
 
+        unless embed || !status.account.require_dereference
+          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..260ea48fe 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_conversations_map, :hidden_statuses_map
+
   def initialize(statuses, current_account_id = nil, **options)
     if current_account_id.nil?
       @reblogs_map    = {}
@@ -11,6 +13,9 @@ class StatusRelationshipsPresenter
       @bookmarks_map  = {}
       @mutes_map      = {}
       @pins_map       = {}
+
+      @hidden_conversations_map = {}
+      @hidden_statuses_map      = {}
     else
       statuses            = statuses.compact
       status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
@@ -22,6 +27,9 @@ 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_conversations_map = Status.hidden_conversations_map(conversation_ids, current_account_id).merge(options[:hidden_conversations_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 5d2741b17..a56626532 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -24,6 +24,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   attribute :moved_to, if: :moved?
   attribute :also_known_as, if: :also_known_as?
 
+  context_extensions :require_dereference, :show_replies, :private, :require_auth, :metadata, :server_metadata
+  attributes :require_dereference, :show_replies, :show_unlisted, :private, :require_auth
+  attributes :metadata, :server_metadata
+
   class EndpointsSerializer < ActivityPub::Serializer
     include RoutingHelper
 
@@ -137,6 +141,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
     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 a0965790e..b973f69ec 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
 
@@ -53,11 +76,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
@@ -94,6 +117,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
@@ -103,11 +130,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def to
-    ActivityPub::TagManager.instance.to(object)
+    ActivityPub::TagManager.instance.to(object, target_domain: instance_options[:target_domain])
   end
 
   def cc
-    ActivityPub::TagManager.instance.cc(object)
+    ActivityPub::TagManager.instance.cc(object, target_domain: instance_options[:target_domain])
   end
 
   def virtual_tags
@@ -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..2692a1c42 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, embed: false) }
   end
 end
diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb
index a925efc18..a464517ca 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, embed: false)
   end
 end
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 4e497cdbd..e425c34a0 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 :require_dereference, :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/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 58e7bd4e4..c172a37af 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,33 @@ 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 :conversation_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 +59,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
@@ -64,14 +92,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
@@ -96,6 +140,22 @@ class REST::StatusSerializer < ActiveModel::Serializer
     end
   end
 
+  def conversation_hidden
+    if instance_options && instance_options[:relationships]
+      instance_options[:relationships].hidden_conversations_map[object.conversation_id] || false
+    else
+      current_user.account.hiding_conversation?(object.conversation)
+    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
@@ -127,6 +187,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..e113e4937 100644
--- a/app/services/activitypub/fetch_replies_service.rb
+++ b/app/services/activitypub/fetch_replies_service.rb
@@ -1,49 +1,32 @@
 # 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?
+    return if @account.suspended?
 
-    FetchReplyWorker.push_bulk(filtered_replies)
+    fetch_collection_items(collection, **options)
+    return if (collection.is_a?(String) && collection == @account.outbox_url) || @account.local? || @account.silenced? || @account.passive_relationships.exists? || !@account.active_relationships.exists?
 
-    @items
+    fetch_collection_items(@account.outbox_url, **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 85b915ec6..7f17e460c 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -35,12 +35,13 @@ 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?
+    return after_key_change! if key_changed? && !@options[:signed_with_known_key]
 
     unless @options[:only_key]
       check_featured_collection! if @account.featured_collection_url.present?
       check_links! unless @account.fields.empty?
+      process_sync
     end
 
     @account
@@ -86,6 +87,11 @@ 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.require_dereference     = @json['requireDereference'] || false
+    @account.show_replies            = @json['showReplies'] || true
+    @account.show_unlisted           = @json['showUnlisted'] || true
+    @account.private                 = @json['private'] || false
+    @account.require_auth            = @json['require_auth'] || false
   end
 
   def set_fetchable_attributes!
@@ -104,7 +110,8 @@ class ActivityPub::ProcessAccountService < BaseService
   end
 
   def after_key_change!
-    RefollowWorker.perform_async(@account.id)
+    ResetAccountWorker.perform_async(@account.id)
+    nil
   end
 
   def check_featured_collection!
@@ -288,4 +295,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_service.rb b/app/services/after_block_service.rb
index 2a0e10a79..432ba65e6 100644
--- a/app/services/after_block_service.rb
+++ b/app/services/after_block_service.rb
@@ -8,6 +8,8 @@ class AfterBlockService < BaseService
     clear_home_feed!
     clear_notifications!
     clear_conversations!
+    unlink_replies!
+    unlink_mentions!
   end
 
   private
@@ -23,4 +25,16 @@ class AfterBlockService < BaseService
   def clear_notifications!
     Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all
   end
+
+  def unlink_replies!
+    @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.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/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..ba94539c8 100644
--- a/app/services/concerns/payloadable.rb
+++ b/app/services/concerns/payloadable.rb
@@ -15,6 +15,6 @@ module Payloadable
   end
 
   def signing_enabled?
-    ENV['AUTHORIZED_FETCH'] != 'true' && !Rails.configuration.x.whitelist_mode
+    true
   end
 end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 6fa98ce12..800e4aa07 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,22 +15,30 @@ 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.reblog? && !Setting.show_reblogs_in_public_timelines
-
-    render_anonymous_payload(status)
+    return if status.account.silenced?
 
+    render_anonymous_payload(status.proper)
     deliver_to_hashtags(status)
 
-    return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines
+    if status.reblog?
+      if status.local? && status.reblog.public_visibility? && !status.reblog.account.silenced?
+        deliver_to_public(status.reblog)
+        deliver_to_media(status.reblog) if status.reblog.media_attachments.any?
+      end
+      return
+    end
+
+    deliver_to_hashtags(status) if status.distributable?
+    return if !status.public_visibility? || (status.reply? && status.in_reply_to_account_id != status.account_id)
 
-    deliver_to_public(status)
-    deliver_to_media(status) if status.media_attachments.any?
+    deliver_to_media(status, true) if status.media_attachments.any?
+    deliver_to_public(status, true)
   end
 
   private
@@ -84,10 +93,15 @@ class FanOutOnWriteService < BaseService
     end
   end
 
-  def deliver_to_public(status)
+  def deliver_to_public(status, tavern = false)
+    key = "timeline:public:#{status.id}"
+    return if Redis.current.get(key)
+
     Rails.logger.debug "Delivering status #{status.id} to public timeline"
 
-    Redis.current.publish('timeline:public', @payload)
+    Redis.current.set(key, 1, ex: 2.hours)
+
+    Redis.current.publish('timeline:public', @payload) if status.local? || !tavern
     if status.local?
       Redis.current.publish('timeline:public:local', @payload)
     else
@@ -95,10 +109,13 @@ class FanOutOnWriteService < BaseService
     end
   end
 
-  def deliver_to_media(status)
+  def deliver_to_media(status, tavern = false)
+    key = "timeline:public:#{status.id}"
+    return if Redis.current.get(key)
+
     Rails.logger.debug "Delivering status #{status.id} to media timeline"
 
-    Redis.current.publish('timeline:public:media', @payload)
+    Redis.current.publish('timeline:public:media', @payload) if status.local? || !tavern
     if status.local?
       Redis.current.publish('timeline:public:local:media', @payload)
     else
@@ -109,7 +126,7 @@ class FanOutOnWriteService < BaseService
   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..46adb98dc
--- /dev/null
+++ b/app/services/mute_conversation_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class MuteConversationService < BaseService
+  def call(account, conversation, hidden: false)
+    return if account.blank? || conversation.blank?
+
+    account.mute_conversation!(conversation, hidden: hidden)
+    MuteConversationWorker.perform_async(account.id, conversation.id) if hidden
+  end
+end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 676804cb9..1a3f4981f 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 class MuteService < BaseService
-  def call(account, target_account, notifications: nil)
+  def call(account, target_account, notifications: nil, timelines_only: nil)
     return if account.id == target_account.id
 
-    mute = account.mute!(target_account, notifications: notifications)
+    mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only)
 
     if mute.hide_notifications?
       BlockWorker.perform_async(account.id, target_account.id)
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 abd676494..65f6052bf 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -49,6 +49,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
@@ -81,7 +86,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..769b9aba0 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
 
@@ -13,6 +14,8 @@ class PostStatusService < BaseService
   # @option [Boolean] :sensitive
   # @option [String] :visibility
   # @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 +23,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 +56,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 +73,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 +99,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 +126,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 +162,10 @@ class PostStatusService < BaseService
     ProcessHashtagsService.new
   end
 
+  def process_command_tags_service
+    ProcessCommandTagsService.new
+  end
+
   def scheduled?
     @scheduled_at.present?
   end
@@ -156,24 +196,32 @@ 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,
       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 +246,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 029c2f6e5..40cfad572 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -2,6 +2,7 @@
 
 class PrecomputeFeedService < BaseService
   def call(account)
+    Redis.current.del("feed:home:#{account.id}")
     FeedManager.instance.populate_feed(account)
     FeedManager.instance.populate_direct_feed(account)
   ensure
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..5ec5ea0c2 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -1,15 +1,19 @@
 # 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?
+      TrendingTags.record_use!(tag, status.account, status.created_at) if status.distributable?
     end
 
     return unless status.distributable?
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index f45422970..b5134bf9c 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 Goldfinger::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)
+      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name) 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)
+      ActivityPub::DeliveryWorker.perform_async(activitypub_json(mentioned_account.domain), mention.status.account_id, mentioned_account.inbox_url)
     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, embed: false), ActivityPub::ActivitySerializer, signer: @status.account, target_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 6cecb5ac4..ddd22e379 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -28,7 +28,7 @@ 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)
 
     DistributionWorker.perform_async(reblog.id)
     ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
@@ -60,6 +60,6 @@ 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, embed: false), ActivityPub::ActivitySerializer, signer: reblog.account, target_domain: reblog.account.domain))
   end
 end
diff --git a/app/services/remove_hashtags_service.rb b/app/services/remove_hashtags_service.rb
new file mode 100644
index 000000000..6bf77a068
--- /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.distributable?
+      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..57120e38f 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
 
+    return unless status.published? || @options[:unpublished]
+
     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,15 @@ 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
-
-        @status.destroy! if @options[:immediate] || !@status.reported?
+        remove_from_spam_check unless @options[:unpublish]
+        remove_media unless @options[:unpublish]
+
+        if @options[:immediate] || !(@options[:unpublish] || @status.reported?)
+          @status.destroy!
+        else
+          @status.update(published: false, expires_at: nil, local_only: @status.local?)
+          DistributionWorker.perform_async(@status.id) if @status.local?
+        end
       else
         raise Mastodon::RaceConditionError
       end
@@ -48,6 +55,7 @@ class RemoveStatusService < BaseService
 
     remove_from_remote_followers
     remove_from_remote_affected
+    remove_from_remote_shared
   end
 
   private
@@ -107,12 +115,18 @@ class RemoveStatusService < BaseService
 
   def relay!
     ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
+      [signed_activity_json(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url]
+    end
+  end
+
+  def remove_from_remote_shared
+    ActivityPub::DeliveryWorker.push_bulk(Account.remote.activitypub.where.not(shared_inbox_url: '').distinct.select(:shared_inbox_url).pluck(:shared_inbox_url)) do |inbox_url|
       [signed_activity_json, @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))
+    @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? && @status.spoiler_text.blank? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
   end
 
   def remove_reblogs
@@ -130,7 +144,7 @@ class RemoveStatusService < BaseService
       featured_tag.decrement(@status.id)
     end
 
-    return unless @status.public_visibility?
+    return unless @status.distributable?
 
     @tags.each do |hashtag|
       redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
@@ -139,7 +153,7 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_public
-    return unless @status.public_visibility?
+    return unless @status.distributable?
 
     redis.publish('timeline:public', @payload)
     if @status.local?
@@ -150,7 +164,7 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_media
-    return unless @status.public_visibility?
+    return unless @status.distributable?
 
     redis.publish('timeline:public:media', @payload)
     if @status.local?
diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb
new file mode 100644
index 000000000..e51e9f1ef
--- /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 Goldfinger::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 78080d878..bac41f961 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
@@ -42,7 +42,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..95810acd2
--- /dev/null
+++ b/app/services/revoke_status_service.rb
@@ -0,0 +1,104 @@
+# 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.distributable?
+
+    @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.distributable?
+
+    redis.publish('timeline:public', @payload)
+    if @status.local?
+      redis.publish('timeline:public:local', @payload)
+    else
+      redis.publish('timeline:public:remote', @payload)
+    end
+  end
+
+  def remove_from_media
+    return if @status.distributable?
+
+    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
+  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/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..9dc4fbbcd
--- /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[:local_only]    = @status.local_only? if @params[:local_only] == true && (@status.edited.positive? || @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?
+
+    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..f29c5c73c 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 c9688ea88..3a6fca642 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..b9033f553 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/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 3336cf391..34f742c16 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -40,6 +40,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 5fc865814..48031a973 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -30,6 +30,11 @@
     = 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
+
   %h4= t 'appearance.toot_layout'
 
   .fields-group
@@ -59,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..f91010724
--- /dev/null
+++ b/app/views/settings/preferences/filters/show.html.haml
@@ -0,0 +1,22 @@
+- 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_filter_to_unknown, as: :boolean, wrapper: :with_label
+    = f.input :setting_filter_from_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/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 3b5c7016d..a1d3d4357 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -38,10 +38,5 @@
   .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/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..1b7765f32 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')
+
+  .fields-group
+    = f.input :require_auth, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.require_auth')
+
+  %h4= t 'settings.profiles.advanced_privacy'
+
+  %p.hint= t 'settings.profiles.advanced_privacy_html'
+
+  .fields-group
+    = f.input :require_dereference, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.require_dereference_html')
 
   %hr.spacer/
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index b3e9c44fc..8673f860c 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: status.sensitive?, 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: status.sensitive? || parent_status&.sensitive?, 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: status.sensitive?, 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: status.sensitive? || parent_status&.sensitive?, 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: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: status.sensitive? || parent_status&.sensitive?, '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 f2b6866e9..0f2763d27 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: status.sensitive?, 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: status.sensitive? || parent_status&.sensitive?, 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: status.sensitive?, 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: status.sensitive? || parent_status&.sensitive?, 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: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: status.sensitive? || parent_status&.sensitive?, '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 0e3652503..1c8acadf2 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 873df7fbd..c06f6ebce 100644
--- a/app/views/statuses/show.html.haml
+++ b/app/views/statuses/show.html.haml
@@ -16,6 +16,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/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index e4997ba0e..716d751c4 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -9,11 +9,12 @@ class ActivityPub::DistributionWorker
   def perform(status_id)
     @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]
+      [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url]
     end
 
     relay! if relayable?
@@ -24,7 +25,7 @@ class ActivityPub::DistributionWorker
   private
 
   def skip_distribution?
-    @status.direct_visibility? || @status.limited_visibility?
+    !@status.published? || @status.direct_visibility? || @status.limited_visibility?
   end
 
   def relayable?
@@ -35,20 +36,20 @@ class ActivityPub::DistributionWorker
     # 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(domain)
+    @payload[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, update: true, embed: false), ActivityPub::ActivitySerializer, signer: @account, target_domain: domain))
   end
 
   def relay!
     ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
-      [payload, @account.id, inbox_url]
+      [payload(Addressable::URI.parse(inbox_url).host), @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/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
index d4d0148ac..e8648ffcd 100644
--- a/app/workers/activitypub/reply_distribution_worker.rb
+++ b/app/workers/activitypub/reply_distribution_worker.rb
@@ -12,11 +12,12 @@ class ActivityPub::ReplyDistributionWorker
   def perform(status_id)
     @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(Addressable::URI.parse(inbox_url).host), @status.account_id, inbox_url]
     end
   rescue ActiveRecord::RecordNotFound
     true
@@ -28,7 +29,7 @@ class ActivityPub::ReplyDistributionWorker
     @inboxes ||= @account.followers.inboxes
   end
 
-  def payload
-    @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
+  def payload(domain)
+    @payload[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, update: true, embed: false), ActivityPub::ActivitySerializer, signer: @status.account, target_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/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/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..f942a9893
--- /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 > 4')
+  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 > ?', 18.weeks.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 6113edde1..dade63028 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -10,5 +10,10 @@ 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
 end
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 ad6cf82d7..66f14061a 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -45,6 +45,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,
@@ -116,10 +117,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/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..d964e5c03
--- /dev/null
+++ b/config/locales/en-MP.yml
@@ -0,0 +1,164 @@
+---
+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.
+    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)"
+    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:"
+  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.
+  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
+  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>Be aware that other servers you send your roars to have their own profile systems and may not honor these options.  You will need to use <em>followers-only</em> or <em>direct</em> privacy for roars you do not want displayed in other servers' public profiles.</strong>
+      advanced_privacy: Advanced privacy
+      advanced_privacy_html: These options can increase your privacy at the expense of compatability with other servers. <strong>They can potentially cause roars to not be delivered to some of your followers.  Only enable them if you're fully aware of their side effects.</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/simple_form.en-MP.yml b/config/locales/simple_form.en-MP.yml
new file mode 100644
index 000000000..6987e4492
--- /dev/null
+++ b/config/locales/simple_form.en-MP.yml
@@ -0,0 +1,80 @@
+---
+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:
+        irreversible: Filtered roars will disappear irreversibly, even if filter is later removed
+        phrase: Will be matched regardless of casing in text or content warning of a roar
+        private: Only allow authenticated followers to view your local profile.
+        require_auth: Require viewers to log in to access your profile, roars, and threads from Monsterpit.
+        require_dereference_html: "When enabled, Monsterpit will deliver your roars to other servers as pointers and require an authenticated request to access their (non-public) content.  This allows permissions and blocks you've set to be enforced more stringently.  <strong>This feature will make your roars inaccessible from Mastodon servers older than 3.2.0.</strong>"
+        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_to_unknown: Do not show replies to unfollowed accounts on your home timeline.  Takes effect for newly-pushed items.
+        setting_filter_from_unknown: Do not show boosts from unfollowed accounts on your home timeline.  Takes effect for newly-pushed items.
+        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_show_application: The application you use to toot will be displayed in the detailed view of your roars
+        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.
+        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:
+        bot: This is an automated account
+        private: Private mode
+        require_auth: Disallow anonymous access
+        require_dereference: Indirect federation 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_to_unknown: Filter replies to unfollowed accounts
+        setting_filter_from_unknown: Filter boosts from unfollowed accounts
+        setting_manual_publish: Manually publish roars
+        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_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_unpublish_delete: Delete after unpublishing
+        setting_unpublish_in: Then unpublish after
+        setting_unpublish_on_delete: Unpublish on delete
+        setting_use_pending_items: Relax mode
+        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 c82dfbb6d..7f292af3f 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -13,6 +13,8 @@ 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 :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url
+      s.item :filters, safe_join([fa_icon('filter fw'), t('preferences.filters')]), settings_preferences_filters_url
+      s.item :publishing, safe_join([fa_icon('pencil-square-o fw'), t('preferences.publishing')]), settings_preferences_publishing_url
       s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url
     end
 
@@ -45,7 +47,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? }
     end
 
diff --git a/config/routes.rb b/config/routes.rb
index 48d2718c8..a6b9f6981 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
@@ -52,10 +55,10 @@ Rails.application.routes.draw do
 
   devise_for :users, path: 'auth', controllers: {
     omniauth_callbacks: 'auth/omniauth_callbacks',
-    sessions:           'auth/sessions',
-    registrations:      'auth/registrations',
-    passwords:          'auth/passwords',
-    confirmations:      'auth/confirmations',
+    sessions: 'auth/sessions',
+    registrations: 'auth/registrations',
+    passwords: 'auth/passwords',
+    confirmations: 'auth/confirmations',
   }
 
   get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
@@ -88,8 +91,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
@@ -113,6 +119,8 @@ 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
     end
 
     resource :import, only: [:show, :create]
@@ -307,7 +315,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
@@ -320,11 +328,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
@@ -408,6 +421,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
@@ -486,6 +501,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
@@ -516,7 +534,7 @@ Rails.application.routes.draw do
   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 c61454e9e..be9fe093a 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -79,6 +79,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..e1b99ac99 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['AMBASSADOR_DELAY'] || 10 %>m'
+    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/20200720211530_add_hidden_to_conversation_mute.rb b/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb
new file mode 100644
index 000000000..aa7b31d8b
--- /dev/null
+++ b/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb
@@ -0,0 +1,7 @@
+class AddHiddenToConversationMute < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :conversation_mutes, :hidden, :boolean, default: false, null: false
+    end
+  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/20200721212401_backfill_account_id_on_conversations.rb b/db/migrate/20200721212401_backfill_account_id_on_conversations.rb
new file mode 100644
index 000000000..595fd8e52
--- /dev/null
+++ b/db/migrate/20200721212401_backfill_account_id_on_conversations.rb
@@ -0,0 +1,15 @@
+class BackfillAccountIdOnConversations < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    Rails.logger.info('Backfilling owners of conversation threads...')
+    safety_assured do
+      Conversation.left_outer_joins(:statuses).where(statuses: { id: nil }).in_batches.destroy_all
+      execute('UPDATE conversations SET account_id = s.account_id FROM (SELECT account_id, conversation_id 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/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/20200726095058_backfill_semiprivate_on_statuses.rb b/db/migrate/20200726095058_backfill_semiprivate_on_statuses.rb
new file mode 100644
index 000000000..69878ab94
--- /dev/null
+++ b/db/migrate/20200726095058_backfill_semiprivate_on_statuses.rb
@@ -0,0 +1,14 @@
+class BackfillSemiprivateOnStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    Rails.logger.info('Backfilling semiprivate statuses...')
+    safety_assured do
+      Status.where(id: StatusDomainPermission.select(:status_id).distinct(:status_id)).in_batches.update_all(semiprivate: true)
+    end
+  end
+
+  def down
+    true
+  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/schema.rb b/db/schema.rb
index b56aa109a..87cca4ea1 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_06_30_190544) do
+ActiveRecord::Schema.define(version: 2020_09_04_201028) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -44,6 +44,17 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -56,6 +67,12 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -182,6 +199,12 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do
     t.integer "avatar_storage_schema_version"
     t.integer "header_storage_schema_version"
     t.string "devices_url"
+    t.boolean "require_dereference", default: false, null: false
+    t.boolean "show_replies", default: true, null: false
+    t.boolean "show_unlisted", default: true, null: false
+    t.boolean "private", default: false, null: false
+    t.boolean "require_auth", default: false, null: false
+    t.datetime "last_synced_at"
     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"
@@ -272,9 +295,32 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
 
@@ -282,6 +328,10 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do
     t.string "uri"
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
+    t.bigint "account_id"
+    t.boolean "public", default: false, null: false
+    t.string "root"
+    t.index ["account_id"], name: "index_conversations_on_account_id"
     t.index ["uri"], name: "index_conversations_on_uri", unique: true
   end
 
@@ -339,6 +389,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
 
@@ -440,6 +491,14 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -505,6 +564,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -527,6 +587,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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.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"
   end
@@ -667,6 +728,14 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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"
@@ -744,6 +813,24 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -780,17 +867,34 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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.boolean "semiprivate", default: false, null: false
+    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.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_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)))"
+    t.index ["id", "account_id"], name: "index_statuses_local_reblogs", where: "(((local = true) OR (uri IS NULL)) AND (reblog_of_id IS NOT NULL))"
+    t.index ["id", "account_id"], name: "index_statuses_on_id_and_account_id"
+    t.index ["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)))"
     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|
@@ -883,6 +987,8 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
@@ -929,7 +1035,9 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do
   add_foreign_key "account_conversations", "accounts", on_delete: :cascade
   add_foreign_key "account_conversations", "conversations", 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"
@@ -954,8 +1062,11 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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 "conversations", "accounts"
   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
@@ -972,6 +1083,8 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -997,6 +1110,8 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -1006,6 +1121,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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/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..041bdac3c
--- /dev/null
+++ b/lib/tasks/monsterfork.rake
@@ -0,0 +1,22 @@
+# 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
+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 3982f7cde..b850e3976 100644
--- a/package.json
+++ b/package.json
@@ -73,8 +73,8 @@
     "@github/webauthn-json": "^0.4.2",
     "@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.19.2",
     "babel-loader": "^8.1.0",
@@ -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..a82ec09c2
--- /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());
+    }
+  }
+
+  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("load", addSubmitHandler);
+
diff --git a/yarn.lock b/yarn.lock
index b0d8622dc..b9423b781 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2353,7 +2353,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==
@@ -4539,6 +4539,13 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
   resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
 
+fastq@^1.6.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481"
+  integrity sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q==
+  dependencies:
+    reusify "^1.0.4"
+
 favico.js@^0.3.10:
   version "0.3.10"
   resolved "https://registry.yarnpkg.com/favico.js/-/favico.js-0.3.10.tgz#80586e27a117f24a8d51c18a99bdc714d4339301"
@@ -6673,6 +6680,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"
+
 lines-and-columns@^1.1.6:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
@@ -7165,7 +7180,7 @@ 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:
+nan@^2.12.1, nan@^2.14.0:
   version "2.14.1"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
@@ -7862,6 +7877,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"
@@ -7870,7 +7894,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==
@@ -8928,6 +8952,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"
@@ -10068,6 +10102,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"