about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-01-31 12:50:14 -0600
committerStarfall <us@starfall.systems>2022-01-31 12:50:14 -0600
commit17265f47f8f931e70699088dd8bd2a1c7b78112b (patch)
treea1dde2630cd8e481cc4c5d047c4af241a251def0 /app
parent129962006c2ebcd195561ac556887dc87d32081c (diff)
parentd6f3261c6cb810ea4eb6f74b9ee62af0d94cbd52 (diff)
Merge branch 'glitchsoc'
Diffstat (limited to 'app')
-rw-r--r--app/chewy/accounts_index.rb24
-rw-r--r--app/chewy/statuses_index.rb48
-rw-r--r--app/chewy/tags_index.rb16
-rw-r--r--app/controllers/accounts_controller.rb13
-rw-r--r--app/controllers/activitypub/collections_controller.rb1
-rw-r--r--app/controllers/activitypub/followers_synchronizations_controller.rb4
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb10
-rw-r--r--app/controllers/admin/account_moderation_notes_controller.rb2
-rw-r--r--app/controllers/admin/accounts_controller.rb45
-rw-r--r--app/controllers/admin/dashboard_controller.rb37
-rw-r--r--app/controllers/admin/instances_controller.rb9
-rw-r--r--app/controllers/admin/pending_accounts_controller.rb52
-rw-r--r--app/controllers/admin/report_notes_controller.rb23
-rw-r--r--app/controllers/admin/reported_statuses_controller.rb44
-rw-r--r--app/controllers/admin/reports_controller.rb6
-rw-r--r--app/controllers/admin/resets_controller.rb4
-rw-r--r--app/controllers/admin/sign_in_token_authentications_controller.rb27
-rw-r--r--app/controllers/admin/statuses_controller.rb66
-rw-r--r--app/controllers/admin/tags_controller.rb76
-rw-r--r--app/controllers/admin/trends/links/preview_card_providers_controller.rb41
-rw-r--r--app/controllers/admin/trends/links_controller.rb45
-rw-r--r--app/controllers/admin/trends/tags_controller.rb41
-rw-r--r--app/controllers/admin/two_factor_authentications_controller.rb2
-rw-r--r--app/controllers/api/base_controller.rb7
-rw-r--r--app/controllers/api/proofs_controller.rb23
-rw-r--r--app/controllers/api/v1/accounts/identity_proofs_controller.rb3
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts_controller.rb15
-rw-r--r--app/controllers/api/v1/admin/account_actions_controller.rb4
-rw-r--r--app/controllers/api/v1/admin/accounts_controller.rb8
-rw-r--r--app/controllers/api/v1/admin/dimensions_controller.rb25
-rw-r--r--app/controllers/api/v1/admin/measures_controller.rb24
-rw-r--r--app/controllers/api/v1/admin/reports_controller.rb16
-rw-r--r--app/controllers/api/v1/admin/retention_controller.rb23
-rw-r--r--app/controllers/api/v1/admin/trends/tags_controller.rb19
-rw-r--r--app/controllers/api/v1/instances/activity_controller.rb27
-rw-r--r--app/controllers/api/v1/statuses/histories_controller.rb21
-rw-r--r--app/controllers/api/v1/statuses/sources_controller.rb21
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/controllers/api/v1/trends/links_controller.rb21
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb21
-rw-r--r--app/controllers/api/v1/trends_controller.rb15
-rw-r--r--app/controllers/application_controller.rb77
-rw-r--r--app/controllers/auth/confirmations_controller.rb44
-rw-r--r--app/controllers/auth/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/auth/passwords_controller.rb1
-rw-r--r--app/controllers/auth/registrations_controller.rb9
-rw-r--r--app/controllers/auth/sessions_controller.rb44
-rw-r--r--app/controllers/concerns/account_owned_concern.rb5
-rw-r--r--app/controllers/concerns/accountable_concern.rb4
-rw-r--r--app/controllers/concerns/captcha_concern.rb59
-rw-r--r--app/controllers/concerns/sign_in_token_authentication_concern.rb20
-rw-r--r--app/controllers/concerns/theming_concern.rb80
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb26
-rw-r--r--app/controllers/concerns/user_tracking_concern.rb6
-rw-r--r--app/controllers/home_controller.rb25
-rw-r--r--app/controllers/media_controller.rb7
-rw-r--r--app/controllers/settings/deletes_controller.rb2
-rw-r--r--app/controllers/settings/identity_proofs_controller.rb65
-rw-r--r--app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb3
-rw-r--r--app/controllers/statuses_cleanup_controller.rb40
-rw-r--r--app/controllers/well_known/keybase_proof_config_controller.rb17
-rw-r--r--app/controllers/well_known/webfinger_controller.rb3
-rw-r--r--app/helpers/accounts_helper.rb6
-rw-r--r--app/helpers/admin/action_logs_helper.rb6
-rw-r--r--app/helpers/admin/dashboard_helper.rb39
-rw-r--r--app/helpers/admin/filter_helper.rb3
-rw-r--r--app/helpers/admin/settings_helper.rb4
-rw-r--r--app/helpers/application_helper.rb43
-rw-r--r--app/helpers/jsonld_helper.rb8
-rw-r--r--app/helpers/languages_helper.rb95
-rw-r--r--app/helpers/settings_helper.rb89
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js32
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js69
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js9
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js6
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js3
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js4
-rw-r--r--app/javascript/flavours/glitch/components/account.js4
-rw-r--r--app/javascript/flavours/glitch/components/admin/Counter.js116
-rw-r--r--app/javascript/flavours/glitch/components/admin/Dimension.js93
-rw-r--r--app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js159
-rw-r--r--app/javascript/flavours/glitch/components/admin/Retention.js151
-rw-r--r--app/javascript/flavours/glitch/components/admin/Trends.js73
-rw-r--r--app/javascript/flavours/glitch/components/attachment_list.js36
-rw-r--r--app/javascript/flavours/glitch/components/avatar_composite.js2
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js6
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js4
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.js8
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.js61
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js34
-rw-r--r--app/javascript/flavours/glitch/components/poll.js27
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js12
-rw-r--r--app/javascript/flavours/glitch/components/skeleton.js11
-rw-r--r--app/javascript/flavours/glitch/components/status.js72
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js14
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js67
-rw-r--r--app/javascript/flavours/glitch/components/status_header.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js31
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js1
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js2
-rw-r--r--app/javascript/flavours/glitch/containers/admin_component.js26
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js28
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.js2
-rw-r--r--app/javascript/flavours/glitch/containers/scroll_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js6
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js2
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js65
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js3
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js6
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js5
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js4
-rw-r--r--app/javascript/flavours/glitch/features/directory/components/account_card.js2
-rw-r--r--app/javascript/flavours/glitch/features/directory/index.js7
-rw-r--r--app/javascript/flavours/glitch/features/follow_recommendations/components/account.js2
-rw-r--r--app/javascript/flavours/glitch/features/follow_recommendations/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js2
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/following/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.js6
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/trends.js2
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js4
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js19
-rw-r--r--app/javascript/flavours/glitch/features/lists/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js15
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js4
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow_request.js6
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js8
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/header.js2
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js3
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js67
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js17
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/favourite_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js108
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/list_panel.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js31
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/navigation_panel.js8
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/tabs_bar.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/modal_container.js20
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js53
-rw-r--r--app/javascript/flavours/glitch/locales/ja.js79
-rw-r--r--app/javascript/flavours/glitch/locales/ko.js196
-rw-r--r--app/javascript/flavours/glitch/locales/zh-CN.js198
-rw-r--r--app/javascript/flavours/glitch/packs/admin.js48
-rw-r--r--app/javascript/flavours/glitch/packs/public.js8
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts_map.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js38
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/modal.js17
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss42
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss530
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss19
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss76
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss64
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss12
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/contrast/diff.scss13
-rw-r--r--app/javascript/flavours/glitch/styles/dashboard.scss71
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss15
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss21
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss5
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss18
-rw-r--r--app/javascript/flavours/glitch/theme.yml2
-rw-r--r--app/javascript/flavours/glitch/util/api.js26
-rw-r--r--app/javascript/flavours/glitch/util/backend_links.js2
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js2
-rw-r--r--app/javascript/flavours/glitch/util/numbers.js8
-rw-r--r--app/javascript/flavours/vanilla/theme.yml2
-rw-r--r--app/javascript/mastodon/actions/accounts.js32
-rw-r--r--app/javascript/mastodon/actions/compose.js68
-rw-r--r--app/javascript/mastodon/actions/identity_proofs.js31
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js9
-rw-r--r--app/javascript/mastodon/actions/notifications.js6
-rw-r--r--app/javascript/mastodon/actions/statuses.js3
-rw-r--r--app/javascript/mastodon/actions/streaming.js4
-rw-r--r--app/javascript/mastodon/api.js26
-rw-r--r--app/javascript/mastodon/components/account.js2
-rw-r--r--app/javascript/mastodon/components/admin/Counter.js116
-rw-r--r--app/javascript/mastodon/components/admin/Dimension.js93
-rw-r--r--app/javascript/mastodon/components/admin/ReportReasonSelector.js159
-rw-r--r--app/javascript/mastodon/components/admin/Retention.js151
-rw-r--r--app/javascript/mastodon/components/admin/Trends.js73
-rw-r--r--app/javascript/mastodon/components/attachment_list.js36
-rw-r--r--app/javascript/mastodon/components/column_header.js6
-rw-r--r--app/javascript/mastodon/components/hashtag.js61
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js2
-rw-r--r--app/javascript/mastodon/components/modal_root.js40
-rw-r--r--app/javascript/mastodon/components/poll.js27
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js7
-rw-r--r--app/javascript/mastodon/components/skeleton.js11
-rw-r--r--app/javascript/mastodon/components/status.js81
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js7
-rw-r--r--app/javascript/mastodon/components/status_content.js6
-rw-r--r--app/javascript/mastodon/components/status_list.js5
-rw-r--r--app/javascript/mastodon/containers/admin_component.js26
-rw-r--r--app/javascript/mastodon/containers/mastodon.js32
-rw-r--r--app/javascript/mastodon/containers/media_container.js2
-rw-r--r--app/javascript/mastodon/containers/scroll_container.js18
-rw-r--r--app/javascript/mastodon/features/account/components/header.js22
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js73
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js10
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/moved_note.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js62
-rw-r--r--app/javascript/mastodon/features/blocks/index.js4
-rw-r--r--app/javascript/mastodon/features/bookmarked_statuses/index.js4
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js3
-rw-r--r--app/javascript/mastodon/features/compose/components/navigation_bar.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js2
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js1
-rw-r--r--app/javascript/mastodon/features/compose/containers/navigation_container.js1
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_container.js5
-rw-r--r--app/javascript/mastodon/features/compose/index.js7
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversation.js4
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversations_list.js1
-rw-r--r--app/javascript/mastodon/features/direct_timeline/index.js4
-rw-r--r--app/javascript/mastodon/features/directory/components/account_card.js2
-rw-r--r--app/javascript/mastodon/features/directory/index.js7
-rw-r--r--app/javascript/mastodon/features/domain_blocks/index.js4
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js4
-rw-r--r--app/javascript/mastodon/features/favourites/index.js4
-rw-r--r--app/javascript/mastodon/features/follow_recommendations/components/account.js2
-rw-r--r--app/javascript/mastodon/features/follow_recommendations/index.js2
-rw-r--r--app/javascript/mastodon/features/follow_requests/components/account_authorize.js2
-rw-r--r--app/javascript/mastodon/features/follow_requests/index.js4
-rw-r--r--app/javascript/mastodon/features/followers/index.js73
-rw-r--r--app/javascript/mastodon/features/following/index.js73
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js6
-rw-r--r--app/javascript/mastodon/features/getting_started/components/trends.js2
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js10
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js4
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js19
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js4
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js4
-rw-r--r--app/javascript/mastodon/features/list_timeline/index.js4
-rw-r--r--app/javascript/mastodon/features/lists/index.js5
-rw-r--r--app/javascript/mastodon/features/mutes/index.js4
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js15
-rw-r--r--app/javascript/mastodon/features/notifications/components/follow_request.js2
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js6
-rw-r--r--app/javascript/mastodon/features/notifications/index.js4
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/components/footer.js8
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/components/header.js2
-rw-r--r--app/javascript/mastodon/features/pinned_statuses/index.js4
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js4
-rw-r--r--app/javascript/mastodon/features/reblogs/index.js4
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js5
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js20
-rw-r--r--app/javascript/mastodon/features/status/index.js14
-rw-r--r--app/javascript/mastodon/features/ui/components/audio_modal.js27
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/confirmation_modal.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js108
-rw-r--r--app/javascript/mastodon/features/ui/components/link_footer.js1
-rw-r--r--app/javascript/mastodon/features/ui/components/list_panel.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js31
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js23
-rw-r--r--app/javascript/mastodon/features/ui/components/navigation_panel.js8
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js6
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js26
-rw-r--r--app/javascript/mastodon/features/ui/containers/modal_container.js20
-rw-r--r--app/javascript/mastodon/features/ui/index.js83
-rw-r--r--app/javascript/mastodon/locales/af.json9
-rw-r--r--app/javascript/mastodon/locales/ar.json25
-rw-r--r--app/javascript/mastodon/locales/ast.json9
-rw-r--r--app/javascript/mastodon/locales/bg.json9
-rw-r--r--app/javascript/mastodon/locales/bn.json43
-rw-r--r--app/javascript/mastodon/locales/br.json65
-rw-r--r--app/javascript/mastodon/locales/ca.json11
-rw-r--r--app/javascript/mastodon/locales/co.json9
-rw-r--r--app/javascript/mastodon/locales/cs.json15
-rw-r--r--app/javascript/mastodon/locales/cy.json17
-rw-r--r--app/javascript/mastodon/locales/da.json11
-rw-r--r--app/javascript/mastodon/locales/de.json19
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json72
-rw-r--r--app/javascript/mastodon/locales/el.json9
-rw-r--r--app/javascript/mastodon/locales/en.json14
-rw-r--r--app/javascript/mastodon/locales/eo.json39
-rw-r--r--app/javascript/mastodon/locales/es-AR.json9
-rw-r--r--app/javascript/mastodon/locales/es-MX.json11
-rw-r--r--app/javascript/mastodon/locales/es.json25
-rw-r--r--app/javascript/mastodon/locales/et.json9
-rw-r--r--app/javascript/mastodon/locales/eu.json9
-rw-r--r--app/javascript/mastodon/locales/fa.json423
-rw-r--r--app/javascript/mastodon/locales/fi.json225
-rw-r--r--app/javascript/mastodon/locales/fr.json63
-rw-r--r--app/javascript/mastodon/locales/ga.json9
-rw-r--r--app/javascript/mastodon/locales/gd.json19
-rw-r--r--app/javascript/mastodon/locales/gl.json11
-rw-r--r--app/javascript/mastodon/locales/he.json41
-rw-r--r--app/javascript/mastodon/locales/hi.json9
-rw-r--r--app/javascript/mastodon/locales/hr.json9
-rw-r--r--app/javascript/mastodon/locales/hu.json21
-rw-r--r--app/javascript/mastodon/locales/hy.json111
-rw-r--r--app/javascript/mastodon/locales/id.json29
-rw-r--r--app/javascript/mastodon/locales/io.json9
-rw-r--r--app/javascript/mastodon/locales/is.json75
-rw-r--r--app/javascript/mastodon/locales/it.json11
-rw-r--r--app/javascript/mastodon/locales/ja.json9
-rw-r--r--app/javascript/mastodon/locales/ka.json9
-rw-r--r--app/javascript/mastodon/locales/kab.json39
-rw-r--r--app/javascript/mastodon/locales/kk.json9
-rw-r--r--app/javascript/mastodon/locales/kmr.json484
-rw-r--r--app/javascript/mastodon/locales/kn.json9
-rw-r--r--app/javascript/mastodon/locales/ko.json85
-rw-r--r--app/javascript/mastodon/locales/ku.json9
-rw-r--r--app/javascript/mastodon/locales/kw.json9
-rw-r--r--app/javascript/mastodon/locales/lt.json9
-rw-r--r--app/javascript/mastodon/locales/lv.json755
-rw-r--r--app/javascript/mastodon/locales/mk.json9
-rw-r--r--app/javascript/mastodon/locales/ml.json11
-rw-r--r--app/javascript/mastodon/locales/mr.json9
-rw-r--r--app/javascript/mastodon/locales/ms.json487
-rw-r--r--app/javascript/mastodon/locales/nl.json11
-rw-r--r--app/javascript/mastodon/locales/nn.json49
-rw-r--r--app/javascript/mastodon/locales/no.json55
-rw-r--r--app/javascript/mastodon/locales/oc.json9
-rw-r--r--app/javascript/mastodon/locales/pa.json9
-rw-r--r--app/javascript/mastodon/locales/pl.json11
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json397
-rw-r--r--app/javascript/mastodon/locales/pt-PT.json11
-rw-r--r--app/javascript/mastodon/locales/ro.json451
-rw-r--r--app/javascript/mastodon/locales/ru.json55
-rw-r--r--app/javascript/mastodon/locales/sa.json9
-rw-r--r--app/javascript/mastodon/locales/sc.json11
-rw-r--r--app/javascript/mastodon/locales/si.json113
-rw-r--r--app/javascript/mastodon/locales/sk.json107
-rw-r--r--app/javascript/mastodon/locales/sl.json11
-rw-r--r--app/javascript/mastodon/locales/sq.json9
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json9
-rw-r--r--app/javascript/mastodon/locales/sr.json39
-rw-r--r--app/javascript/mastodon/locales/sv.json15
-rw-r--r--app/javascript/mastodon/locales/szl.json9
-rw-r--r--app/javascript/mastodon/locales/ta.json9
-rw-r--r--app/javascript/mastodon/locales/tai.json9
-rw-r--r--app/javascript/mastodon/locales/te.json9
-rw-r--r--app/javascript/mastodon/locales/th.json25
-rw-r--r--app/javascript/mastodon/locales/tr.json103
-rw-r--r--app/javascript/mastodon/locales/tt.json9
-rw-r--r--app/javascript/mastodon/locales/ug.json9
-rw-r--r--app/javascript/mastodon/locales/uk.json17
-rw-r--r--app/javascript/mastodon/locales/ur.json175
-rw-r--r--app/javascript/mastodon/locales/vi.json45
-rw-r--r--app/javascript/mastodon/locales/whitelist_kmr.json2
-rw-r--r--app/javascript/mastodon/locales/zgh.json9
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json15
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json25
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json97
-rw-r--r--app/javascript/mastodon/reducers/accounts_map.js15
-rw-r--r--app/javascript/mastodon/reducers/compose.js38
-rw-r--r--app/javascript/mastodon/reducers/identity_proofs.js25
-rw-r--r--app/javascript/mastodon/reducers/index.js4
-rw-r--r--app/javascript/mastodon/reducers/modal.js17
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js2
-rw-r--r--app/javascript/mastodon/utils/numbers.js8
-rw-r--r--app/javascript/packs/admin.js24
-rw-r--r--app/javascript/packs/public.js8
-rw-r--r--app/javascript/styles/fonts/montserrat.scss2
-rw-r--r--app/javascript/styles/fonts/roboto-mono.scss1
-rw-r--r--app/javascript/styles/fonts/roboto.scss4
-rw-r--r--app/javascript/styles/mailer.scss4
-rw-r--r--app/javascript/styles/mastodon/accounts.scss42
-rw-r--r--app/javascript/styles/mastodon/admin.scss530
-rw-r--r--app/javascript/styles/mastodon/components.scss108
-rw-r--r--app/javascript/styles/mastodon/dashboard.scss71
-rw-r--r--app/javascript/styles/mastodon/emoji_picker.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss62
-rw-r--r--app/javascript/styles/mastodon/polls.scss15
-rw-r--r--app/javascript/styles/mastodon/rtl.scss19
-rw-r--r--app/javascript/styles/mastodon/tables.scss5
-rw-r--r--app/javascript/styles/mastodon/widgets.scss18
-rw-r--r--app/lib/activity_tracker.rb70
-rw-r--r--app/lib/activitypub/activity.rb45
-rw-r--r--app/lib/activitypub/activity/accept.rb13
-rw-r--r--app/lib/activitypub/activity/add.rb3
-rw-r--r--app/lib/activitypub/activity/announce.rb23
-rw-r--r--app/lib/activitypub/activity/create.rb266
-rw-r--r--app/lib/activitypub/activity/update.rb17
-rw-r--r--app/lib/activitypub/adapter.rb1
-rw-r--r--app/lib/activitypub/parser/custom_emoji_parser.rb27
-rw-r--r--app/lib/activitypub/parser/media_attachment_parser.rb58
-rw-r--r--app/lib/activitypub/parser/poll_parser.rb53
-rw-r--r--app/lib/activitypub/parser/status_parser.rb124
-rw-r--r--app/lib/activitypub/tag_manager.rb26
-rw-r--r--app/lib/admin/metrics/dimension.rb20
-rw-r--r--app/lib/admin/metrics/dimension/base_dimension.rb40
-rw-r--r--app/lib/admin/metrics/dimension/languages_dimension.rb25
-rw-r--r--app/lib/admin/metrics/dimension/servers_dimension.rb23
-rw-r--r--app/lib/admin/metrics/dimension/software_versions_dimension.rb69
-rw-r--r--app/lib/admin/metrics/dimension/sources_dimension.rb23
-rw-r--r--app/lib/admin/metrics/dimension/space_usage_dimension.rb70
-rw-r--r--app/lib/admin/metrics/dimension/tag_languages_dimension.rb36
-rw-r--r--app/lib/admin/metrics/dimension/tag_servers_dimension.rb35
-rw-r--r--app/lib/admin/metrics/measure.rb21
-rw-r--r--app/lib/admin/metrics/measure/active_users_measure.rb33
-rw-r--r--app/lib/admin/metrics/measure/base_measure.rb55
-rw-r--r--app/lib/admin/metrics/measure/interactions_measure.rb33
-rw-r--r--app/lib/admin/metrics/measure/new_users_measure.rb35
-rw-r--r--app/lib/admin/metrics/measure/opened_reports_measure.rb35
-rw-r--r--app/lib/admin/metrics/measure/resolved_reports_measure.rb35
-rw-r--r--app/lib/admin/metrics/measure/tag_accounts_measure.rb41
-rw-r--r--app/lib/admin/metrics/measure/tag_servers_measure.rb50
-rw-r--r--app/lib/admin/metrics/measure/tag_uses_measure.rb41
-rw-r--r--app/lib/admin/metrics/retention.rb67
-rw-r--r--app/lib/fast_geometry_parser.rb2
-rw-r--r--app/lib/feed_manager.rb28
-rw-r--r--app/lib/formatter.rb31
-rw-r--r--app/lib/link_details_extractor.rb249
-rw-r--r--app/lib/permalink_redirector.rb63
-rw-r--r--app/lib/proof_provider.rb12
-rw-r--r--app/lib/proof_provider/keybase.rb69
-rw-r--r--app/lib/proof_provider/keybase/badge.rb45
-rw-r--r--app/lib/proof_provider/keybase/config_serializer.rb76
-rw-r--r--app/lib/proof_provider/keybase/serializer.rb25
-rw-r--r--app/lib/proof_provider/keybase/verifier.rb59
-rw-r--r--app/lib/proof_provider/keybase/worker.rb32
-rw-r--r--app/lib/request.rb2
-rw-r--r--app/lib/sidekiq_error_handler.rb26
-rw-r--r--app/lib/status_reach_finder.rb31
-rw-r--r--app/lib/themes.rb43
-rw-r--r--app/lib/webfinger.rb4
-rw-r--r--app/mailers/admin_mailer.rb22
-rw-r--r--app/mailers/user_mailer.rb4
-rw-r--r--app/models/account.rb129
-rw-r--r--app/models/account_filter.rb91
-rw-r--r--app/models/account_identity_proof.rb46
-rw-r--r--app/models/account_note.rb1
-rw-r--r--app/models/account_stat.rb3
-rw-r--r--app/models/account_statuses_cleanup_policy.rb171
-rw-r--r--app/models/account_warning.rb22
-rw-r--r--app/models/admin/account_action.rb28
-rw-r--r--app/models/admin/action_log.rb2
-rw-r--r--app/models/admin/action_log_filter.rb6
-rw-r--r--app/models/admin/status_batch_action.rb92
-rw-r--r--app/models/admin/status_filter.rb41
-rw-r--r--app/models/bookmark.rb10
-rw-r--r--app/models/canonical_email_block.rb6
-rw-r--r--app/models/concerns/account_associations.rb8
-rw-r--r--app/models/concerns/account_interactions.rb16
-rw-r--r--app/models/concerns/account_merging.rb2
-rw-r--r--app/models/concerns/attachmentable.rb61
-rw-r--r--app/models/custom_emoji.rb6
-rw-r--r--app/models/favourite.rb9
-rw-r--r--app/models/form/account_batch.rb63
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/form/preview_card_batch.rb65
-rw-r--r--app/models/form/preview_card_provider_batch.rb33
-rw-r--r--app/models/form/status_batch.rb45
-rw-r--r--app/models/form/tag_batch.rb8
-rw-r--r--app/models/media_attachment.rb26
-rw-r--r--app/models/poll.rb1
-rw-r--r--app/models/preview_card.rb52
-rw-r--r--app/models/preview_card_filter.rb53
-rw-r--r--app/models/preview_card_provider.rb57
-rw-r--r--app/models/preview_card_provider_filter.rb49
-rw-r--r--app/models/report.rb66
-rw-r--r--app/models/report_filter.rb16
-rw-r--r--app/models/status.rb20
-rw-r--r--app/models/status_edit.rb23
-rw-r--r--app/models/status_pin.rb8
-rw-r--r--app/models/tag.rb25
-rw-r--r--app/models/tag_filter.rb56
-rw-r--r--app/models/trending_tags.rb128
-rw-r--r--app/models/trends.rb27
-rw-r--r--app/models/trends/base.rb80
-rw-r--r--app/models/trends/history.rb98
-rw-r--r--app/models/trends/links.rb117
-rw-r--r--app/models/trends/tags.rb111
-rw-r--r--app/models/user.rb89
-rw-r--r--app/models/user_ip.rb19
-rw-r--r--app/policies/account_policy.rb4
-rw-r--r--app/policies/instance_policy.rb4
-rw-r--r--app/policies/preview_card_policy.rb11
-rw-r--r--app/policies/preview_card_provider_policy.rb11
-rw-r--r--app/policies/user_policy.rb8
-rw-r--r--app/presenters/instance_presenter.rb4
-rw-r--r--app/serializers/activitypub/actor_serializer.rb5
-rw-r--r--app/serializers/activitypub/note_serializer.rb7
-rw-r--r--app/serializers/manifest_serializer.rb2
-rw-r--r--app/serializers/rest/admin/account_serializer.rb13
-rw-r--r--app/serializers/rest/admin/cohort_serializer.rb19
-rw-r--r--app/serializers/rest/admin/dimension_serializer.rb5
-rw-r--r--app/serializers/rest/admin/ip_serializer.rb5
-rw-r--r--app/serializers/rest/admin/measure_serializer.rb13
-rw-r--r--app/serializers/rest/admin/report_serializer.rb7
-rw-r--r--app/serializers/rest/admin/tag_serializer.rb13
-rw-r--r--app/serializers/rest/identity_proof_serializer.rb17
-rw-r--r--app/serializers/rest/instance_serializer.rb29
-rw-r--r--app/serializers/rest/media_attachment_serializer.rb2
-rw-r--r--app/serializers/rest/status_edit_serializer.rb6
-rw-r--r--app/serializers/rest/status_serializer.rb4
-rw-r--r--app/serializers/rest/status_source_serializer.rb9
-rw-r--r--app/serializers/rest/trends/link_serializer.rb5
-rw-r--r--app/services/account_statuses_cleanup_service.rb27
-rw-r--r--app/services/activitypub/fetch_featured_collection_service.rb6
-rw-r--r--app/services/activitypub/fetch_remote_poll_service.rb2
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb29
-rw-r--r--app/services/activitypub/process_account_service.rb26
-rw-r--r--app/services/activitypub/process_poll_service.rb64
-rw-r--r--app/services/activitypub/process_status_update_service.rb283
-rw-r--r--app/services/backup_service.rb4
-rw-r--r--app/services/batched_remove_status_service.rb2
-rw-r--r--app/services/delete_account_service.rb8
-rw-r--r--app/services/fan_out_on_write_service.rb162
-rw-r--r--app/services/fetch_link_card_service.rb80
-rw-r--r--app/services/fetch_oembed_service.rb5
-rw-r--r--app/services/follow_service.rb4
-rw-r--r--app/services/import_service.rb4
-rw-r--r--app/services/notify_service.rb45
-rw-r--r--app/services/post_status_service.rb6
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/process_mentions_service.rb65
-rw-r--r--app/services/purge_domain_service.rb11
-rw-r--r--app/services/reblog_service.rb15
-rw-r--r--app/services/remove_from_followers_service.rb25
-rw-r--r--app/services/remove_status_service.rb13
-rw-r--r--app/services/resolve_account_service.rb3
-rw-r--r--app/services/unsuspend_account_service.rb3
-rw-r--r--app/validators/reaction_validator.rb2
-rw-r--r--app/validators/status_length_validator.rb3
-rw-r--r--app/validators/status_pin_validator.rb2
-rw-r--r--app/views/about/_login.html.haml29
-rw-r--r--app/views/about/more.html.haml4
-rw-r--r--app/views/about/show.html.haml4
-rw-r--r--app/views/accounts/_bio.html.haml10
-rw-r--r--app/views/accounts/_header.html.haml10
-rw-r--r--app/views/accounts/show.html.haml2
-rw-r--r--app/views/admin/accounts/_account.html.haml59
-rw-r--r--app/views/admin/accounts/index.html.haml53
-rw-r--r--app/views/admin/accounts/show.html.haml50
-rw-r--r--app/views/admin/action_logs/index.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml184
-rw-r--r--app/views/admin/follow_recommendations/_account.html.haml4
-rw-r--r--app/views/admin/instances/_instance.html.haml2
-rw-r--r--app/views/admin/instances/show.html.haml4
-rw-r--r--app/views/admin/ip_blocks/_ip_block.html.haml6
-rw-r--r--app/views/admin/pending_accounts/_account.html.haml16
-rw-r--r--app/views/admin/pending_accounts/index.html.haml30
-rw-r--r--app/views/admin/report_notes/_report_note.html.haml23
-rw-r--r--app/views/admin/reports/_action_log.html.haml6
-rw-r--r--app/views/admin/reports/_status.html.haml6
-rw-r--r--app/views/admin/reports/index.html.haml6
-rw-r--r--app/views/admin/reports/show.html.haml273
-rw-r--r--app/views/admin/settings/edit.html.haml8
-rw-r--r--app/views/admin/statuses/index.html.haml33
-rw-r--r--app/views/admin/statuses/show.html.haml27
-rw-r--r--app/views/admin/tags/_tag.html.haml19
-rw-r--r--app/views/admin/tags/index.html.haml71
-rw-r--r--app/views/admin/tags/show.html.haml65
-rw-r--r--app/views/admin/trends/links/_preview_card.html.haml30
-rw-r--r--app/views/admin/trends/links/index.html.haml38
-rw-r--r--app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml16
-rw-r--r--app/views/admin/trends/links/preview_card_providers/index.html.haml40
-rw-r--r--app/views/admin/trends/tags/_tag.html.haml24
-rw-r--r--app/views/admin/trends/tags/index.html.haml35
-rw-r--r--app/views/admin_mailer/new_pending_account.text.erb4
-rw-r--r--app/views/admin_mailer/new_trending_links.text.erb16
-rw-r--r--app/views/admin_mailer/new_trending_tag.text.erb5
-rw-r--r--app/views/admin_mailer/new_trending_tags.text.erb16
-rw-r--r--app/views/application/_sidebar.html.haml2
-rw-r--r--app/views/auth/confirmations/captcha.html.haml14
-rw-r--r--app/views/auth/sessions/new.html.haml25
-rw-r--r--app/views/auth/shared/_links.html.haml2
-rw-r--r--app/views/directories/index.html.haml4
-rw-r--r--app/views/layouts/_theme.html.haml7
-rw-r--r--app/views/layouts/public.html.haml2
-rw-r--r--app/views/notification_mailer/_status.html.haml2
-rw-r--r--app/views/notification_mailer/_status.text.erb8
-rw-r--r--app/views/relationships/_account.html.haml4
-rw-r--r--app/views/settings/featured_tags/index.html.haml2
-rw-r--r--app/views/settings/identity_proofs/_proof.html.haml21
-rw-r--r--app/views/settings/identity_proofs/index.html.haml17
-rw-r--r--app/views/settings/identity_proofs/new.html.haml36
-rw-r--r--app/views/settings/profiles/show.html.haml5
-rw-r--r--app/views/statuses/_detailed_status.html.haml11
-rw-r--r--app/views/statuses/_simple_status.html.haml3
-rw-r--r--app/views/statuses/_status.html.haml2
-rw-r--r--app/views/statuses_cleanup/show.html.haml45
-rw-r--r--app/views/user_mailer/warning.html.haml16
-rw-r--r--app/views/user_mailer/warning.text.erb17
-rw-r--r--app/workers/activitypub/delivery_worker.rb6
-rw-r--r--app/workers/activitypub/distribution_worker.rb48
-rw-r--r--app/workers/activitypub/raw_distribution_worker.rb37
-rw-r--r--app/workers/activitypub/reply_distribution_worker.rb34
-rw-r--r--app/workers/activitypub/update_distribution_worker.rb25
-rw-r--r--app/workers/admin/domain_purge_worker.rb9
-rw-r--r--app/workers/distribution_worker.rb4
-rw-r--r--app/workers/feed_insert_worker.rb38
-rw-r--r--app/workers/local_notification_worker.rb2
-rw-r--r--app/workers/move_worker.rb12
-rw-r--r--app/workers/poll_expiration_notify_worker.rb45
-rw-r--r--app/workers/push_update_worker.rb35
-rw-r--r--app/workers/remote_account_refresh_worker.rb24
-rw-r--r--app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb96
-rw-r--r--app/workers/scheduler/follow_recommendations_scheduler.rb4
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/trends/refresh_scheduler.rb (renamed from app/workers/scheduler/trending_tags_scheduler.rb)4
-rw-r--r--app/workers/scheduler/trends/review_notifications_scheduler.rb11
-rw-r--r--app/workers/scheduler/user_cleanup_scheduler.rb9
619 files changed, 14647 insertions, 6544 deletions
diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb
index b814e009e..6f9ea76e9 100644
--- a/app/chewy/accounts_index.rb
+++ b/app/chewy/accounts_index.rb
@@ -23,21 +23,21 @@ class AccountsIndex < Chewy::Index
     },
   }
 
-  define_type ::Account.searchable.includes(:account_stat), delete_if: ->(account) { account.destroyed? || !account.searchable? } do
-    root date_detection: false do
-      field :id, type: 'long'
+  index_scope ::Account.searchable.includes(:account_stat), delete_if: ->(account) { account.destroyed? || !account.searchable? }
 
-      field :display_name, type: 'text', analyzer: 'content' do
-        field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
-      end
+  root date_detection: false do
+    field :id, type: 'long'
 
-      field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do
-        field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
-      end
+    field :display_name, type: 'text', analyzer: 'content' do
+      field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
+    end
 
-      field :following_count, type: 'long', value: ->(account) { account.following.local.count }
-      field :followers_count, type: 'long', value: ->(account) { account.followers.local.count }
-      field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
+    field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do
+      field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
     end
+
+    field :following_count, type: 'long', value: ->(account) { account.following.local.count }
+    field :followers_count, type: 'long', value: ->(account) { account.followers.local.count }
+    field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
   end
 end
diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb
index 47cb856ea..1903c2ea3 100644
--- a/app/chewy/statuses_index.rb
+++ b/app/chewy/statuses_index.rb
@@ -31,36 +31,36 @@ class StatusesIndex < Chewy::Index
     },
   }
 
-  define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll) do
-    crutch :mentions do |collection|
-      data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
-      data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
-    end
+  index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll)
 
-    crutch :favourites do |collection|
-      data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
-      data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
-    end
+  crutch :mentions do |collection|
+    data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
+    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
+  end
 
-    crutch :reblogs do |collection|
-      data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
-      data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
-    end
+  crutch :favourites do |collection|
+    data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
+    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
+  end
 
-    crutch :bookmarks do |collection|
-      data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
-      data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
-    end
+  crutch :reblogs do |collection|
+    data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
+    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
+  end
 
-    root date_detection: false do
-      field :id, type: 'long'
-      field :account_id, type: 'long'
+  crutch :bookmarks do |collection|
+    data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
+    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
+  end
 
-      field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
-        field :stemmed, type: 'text', analyzer: 'content'
-      end
+  root date_detection: false do
+    field :id, type: 'long'
+    field :account_id, type: 'long'
 
-      field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
+    field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
+      field :stemmed, type: 'text', analyzer: 'content'
     end
+
+    field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
   end
 end
diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb
index 300fc128f..f9db2b03a 100644
--- a/app/chewy/tags_index.rb
+++ b/app/chewy/tags_index.rb
@@ -23,15 +23,15 @@ class TagsIndex < Chewy::Index
     },
   }
 
-  define_type ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? } do
-    root date_detection: false do
-      field :name, type: 'text', analyzer: 'content' do
-        field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
-      end
+  index_scope ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? }
 
-      field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
-      field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day[:accounts].to_i } }
-      field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
+  root date_detection: false do
+    field :name, type: 'text', analyzer: 'content' do
+      field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
     end
+
+    field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
+    field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day.accounts } }
+    field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
   end
 end
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index f9bd616e4..03c07c50b 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -29,7 +29,7 @@ class AccountsController < ApplicationController
           return
         end
 
-        @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
+        @pinned_statuses = cached_filtered_status_pins if show_pinned_statuses?
         @statuses        = cached_filtered_status_page
         @rss_url         = rss_url
 
@@ -65,6 +65,10 @@ class AccountsController < ApplicationController
     [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
   end
 
+  def filtered_pinned_statuses
+    @account.pinned_statuses.not_local_only.where(visibility: [:public, :unlisted])
+  end
+
   def filtered_statuses
     default_statuses.tap do |statuses|
       statuses.merge!(hashtag_scope)    if tag_requested?
@@ -143,6 +147,13 @@ class AccountsController < ApplicationController
     request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
   end
 
+  def cached_filtered_status_pins
+    cache_collection(
+      filtered_pinned_statuses,
+      Status
+    )
+  end
+
   def cached_filtered_status_page
     cache_collection_paginated_by_id(
       filtered_statuses,
diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
index 00f3d3cba..ac7ab8a0b 100644
--- a/app/controllers/activitypub/collections_controller.rb
+++ b/app/controllers/activitypub/collections_controller.rb
@@ -21,6 +21,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
     case params[:id]
     when 'featured'
       @items = for_signed_account { cache_collection(@account.pinned_statuses.not_local_only, Status) }
+      @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
     when 'tags'
       @items = for_signed_account { @account.featured_tags }
     when 'devices'
diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb
index 525031105..940b77cf0 100644
--- a/app/controllers/activitypub/followers_synchronizations_controller.rb
+++ b/app/controllers/activitypub/followers_synchronizations_controller.rb
@@ -19,11 +19,11 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro
   private
 
   def uri_prefix
-    signed_request_account.uri[/http(s?):\/\/[^\/]+\//]
+    signed_request_account.uri[Account::URL_PREFIX_RE]
   end
 
   def set_items
-    @items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri)
+    @items = @account.followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(uri_prefix)}/%", false, true)).or(@account.followers.where(uri: uri_prefix)).pluck(:uri)
   end
 
   def collection_presenter
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index 4a52560ac..b2aab56a5 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -11,7 +11,11 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
   before_action :set_cache_headers
 
   def show
-    expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode? && !(signed_request_account.present? && page_requested?))
+    if page_requested?
+      expires_in(1.minute, public: public_fetch_mode? && signed_request_account.nil?)
+    else
+      expires_in(3.minutes, public: public_fetch_mode?)
+    end
     render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
   end
 
@@ -76,4 +80,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
   def set_account
     @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
   end
+
+  def set_cache_headers
+    response.headers['Vary'] = 'Signature' if authorized_fetch_mode? || page_requested?
+  end
 end
diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb
index 44f6e34f8..4f36f33f4 100644
--- a/app/controllers/admin/account_moderation_notes_controller.rb
+++ b/app/controllers/admin/account_moderation_notes_controller.rb
@@ -14,7 +14,7 @@ module Admin
       else
         @account          = @account_moderation_note.target_account
         @moderation_notes = @account.targeted_moderation_notes.latest
-        @warnings         = @account.targeted_account_warnings.latest.custom
+        @warnings         = @account.strikes.custom.latest
 
         render template: 'admin/accounts/show'
       end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 1dd7430e0..e7f56e243 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -2,13 +2,24 @@
 
 module Admin
   class AccountsController < BaseController
-    before_action :set_account, except: [:index]
+    before_action :set_account, except: [:index, :batch]
     before_action :require_remote_account!, only: [:redownload]
     before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
 
     def index
       authorize :account, :index?
+
       @accounts = filtered_accounts.page(params[:page])
+      @form     = Form::AccountBatch.new
+    end
+
+    def batch
+      @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
+      @form.save
+    rescue ActionController::ParameterMissing
+      flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+    ensure
+      redirect_to admin_accounts_path(filter_params)
     end
 
     def show
@@ -17,7 +28,7 @@ module Admin
       @deletion_request        = @account.deletion_request
       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
       @moderation_notes        = @account.targeted_moderation_notes.latest
-      @warnings                = @account.targeted_account_warnings.latest.custom
+      @warnings                = @account.strikes.custom.latest
       @domain_block            = DomainBlock.rule_for(@account.domain)
     end
 
@@ -38,13 +49,13 @@ module Admin
     def approve
       authorize @account.user, :approve?
       @account.user.approve!
-      redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
+      redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
     end
 
     def reject
       authorize @account.user, :reject?
       DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
-      redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
+      redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
     end
 
     def destroy
@@ -106,6 +117,16 @@ module Admin
       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct)
     end
 
+    def unblock_email
+      authorize @account, :unblock_email?
+
+      CanonicalEmailBlock.where(reference_account: @account).delete_all
+
+      log_action :unblock_email, @account
+
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unblocked_email_msg', username: @account.acct)
+    end
+
     private
 
     def set_account
@@ -121,11 +142,25 @@ module Admin
     end
 
     def filtered_accounts
-      AccountFilter.new(filter_params).results
+      AccountFilter.new(filter_params.with_defaults(order: 'recent')).results
     end
 
     def filter_params
       params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
     end
+
+    def form_account_batch_params
+      params.require(:form_account_batch).permit(:action, account_ids: [])
+    end
+
+    def action_from_button
+      if params[:suspend]
+        'suspend'
+      elsif params[:approve]
+        'approve'
+      elsif params[:reject]
+        'reject'
+      end
+    end
   end
 end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index a00d7ed96..f0a935411 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -1,50 +1,17 @@
 # frozen_string_literal: true
-require 'sidekiq/api'
 
 module Admin
   class DashboardController < BaseController
     def index
       @system_checks         = Admin::SystemCheck.perform
-      @users_count           = User.count
+      @time_period           = (29.days.ago.to_date...Time.now.utc.to_date)
       @pending_users_count   = User.pending.count
-      @registrations_week    = Redis.current.get("activity:accounts:local:#{current_week}") || 0
-      @logins_week           = Redis.current.pfcount("activity:logins:#{current_week}")
-      @interactions_week     = Redis.current.get("activity:interactions:#{current_week}") || 0
-      @relay_enabled         = Relay.enabled.exists?
-      @single_user_mode      = Rails.configuration.x.single_user_mode
-      @registrations_enabled = Setting.registrations_mode != 'none'
-      @deletions_enabled     = Setting.open_deletion
-      @invites_enabled       = Setting.min_invite_role == 'user'
-      @search_enabled        = Chewy.enabled?
-      @version               = Mastodon::Version.to_s
-      @database_version      = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
-      @redis_version         = redis_info['redis_version']
-      @reports_count         = Report.unresolved.count
-      @queue_backlog         = Sidekiq::Stats.new.enqueued
-      @recent_users          = User.confirmed.recent.includes(:account).limit(8)
-      @database_size         = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
-      @redis_size            = redis_info['used_memory']
-      @ldap_enabled          = ENV['LDAP_ENABLED'] == 'true'
-      @cas_enabled           = ENV['CAS_ENABLED'] == 'true'
-      @saml_enabled          = ENV['SAML_ENABLED'] == 'true'
-      @pam_enabled           = ENV['PAM_ENABLED'] == 'true'
-      @hidden_service        = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
-      @trending_hashtags     = TrendingTags.get(10, filtered: false)
+      @pending_reports_count = Report.unresolved.count
       @pending_tags_count    = Tag.pending_review.count
-      @authorized_fetch      = authorized_fetch_mode?
-      @whitelist_enabled     = whitelist_mode?
-      @profile_directory     = Setting.profile_directory
-      @timeline_preview      = Setting.timeline_preview
-      @keybase_integration   = Setting.enable_keybase
-      @trends_enabled        = Setting.trends
     end
 
     private
 
-    def current_week
-      @current_week ||= Time.now.utc.to_date.cweek
-    end
-
     def redis_info
       @redis_info ||= begin
         if Redis.current.is_a?(Redis::Namespace)
diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb
index 748c5de5a..306ec1f53 100644
--- a/app/controllers/admin/instances_controller.rb
+++ b/app/controllers/admin/instances_controller.rb
@@ -14,6 +14,15 @@ module Admin
       authorize :instance, :show?
     end
 
+    def destroy
+      authorize :instance, :destroy?
+
+      Admin::DomainPurgeWorker.perform_async(@instance.domain)
+
+      log_action :destroy, @instance
+      redirect_to admin_instances_path, notice: I18n.t('admin.instances.destroyed_msg', domain: @instance.domain)
+    end
+
     def clear_delivery_errors
       authorize :delivery, :clear_delivery_errors?
 
diff --git a/app/controllers/admin/pending_accounts_controller.rb b/app/controllers/admin/pending_accounts_controller.rb
deleted file mode 100644
index b62a9bc84..000000000
--- a/app/controllers/admin/pending_accounts_controller.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class PendingAccountsController < BaseController
-    before_action :set_accounts, only: :index
-
-    def index
-      @form = Form::AccountBatch.new
-    end
-
-    def batch
-      @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
-      @form.save
-    rescue ActionController::ParameterMissing
-      flash[:alert] = I18n.t('admin.accounts.no_account_selected')
-    ensure
-      redirect_to admin_pending_accounts_path(current_params)
-    end
-
-    def approve_all
-      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.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
-      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])
-    end
-
-    def form_account_batch_params
-      params.require(:form_account_batch).permit(:action, account_ids: [])
-    end
-
-    def action_from_button
-      if params[:approve]
-        'approve'
-      elsif params[:reject]
-        'reject'
-      end
-    end
-
-    def current_params
-      params.slice(:page).permit(:page)
-    end
-  end
-end
diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb
index b816c5b5d..3fd815b60 100644
--- a/app/controllers/admin/report_notes_controller.rb
+++ b/app/controllers/admin/report_notes_controller.rb
@@ -14,20 +14,17 @@ module Admin
         if params[:create_and_resolve]
           @report.resolve!(current_account)
           log_action :resolve, @report
-
-          redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
-          return
-        end
-
-        if params[:create_and_unresolve]
+        elsif params[:create_and_unresolve]
           @report.unresolve!
           log_action :reopen, @report
         end
 
-        redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg')
+        redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg')
       else
-        @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
-        @form         = Form::StatusBatch.new
+        @report_notes = @report.notes.includes(:account).order(id: :desc)
+        @action_logs  = @report.history.includes(:target)
+        @form         = Admin::StatusBatchAction.new
+        @statuses     = @report.statuses.with_includes
 
         render template: 'admin/reports/show'
       end
@@ -41,6 +38,14 @@ module Admin
 
     private
 
+    def after_create_redirect_path
+      if params[:create_and_resolve]
+        admin_reports_path
+      else
+        admin_report_path(@report)
+      end
+    end
+
     def resource_params
       params.require(:report_note).permit(
         :content,
diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb
deleted file mode 100644
index 3ba9f5df2..000000000
--- a/app/controllers/admin/reported_statuses_controller.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class ReportedStatusesController < BaseController
-    before_action :set_report
-
-    def create
-      authorize :status, :update?
-
-      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
-      flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
-
-      redirect_to admin_report_path(@report)
-    rescue ActionController::ParameterMissing
-      flash[:alert] = I18n.t('admin.statuses.no_status_selected')
-
-      redirect_to admin_report_path(@report)
-    end
-
-    private
-
-    def status_params
-      params.require(:status).permit(:sensitive)
-    end
-
-    def form_status_batch_params
-      params.require(:form_status_batch).permit(status_ids: [])
-    end
-
-    def action_from_button
-      if params[:nsfw_on]
-        'nsfw_on'
-      elsif params[:nsfw_off]
-        'nsfw_off'
-      elsif params[:delete]
-        'delete'
-      end
-    end
-
-    def set_report
-      @report = Report.find(params[:report_id])
-    end
-  end
-end
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index 7c831b3d4..00d200d7c 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -13,8 +13,10 @@ module Admin
       authorize @report, :show?
 
       @report_note  = @report.notes.new
-      @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
-      @form         = Form::StatusBatch.new
+      @report_notes = @report.notes.includes(:account).order(id: :desc)
+      @action_logs  = @report.history.includes(:target)
+      @form         = Admin::StatusBatchAction.new
+      @statuses     = @report.statuses.with_includes
     end
 
     def assign_to_self
diff --git a/app/controllers/admin/resets_controller.rb b/app/controllers/admin/resets_controller.rb
index db8f61d64..7962b7a58 100644
--- a/app/controllers/admin/resets_controller.rb
+++ b/app/controllers/admin/resets_controller.rb
@@ -6,9 +6,9 @@ module Admin
 
     def create
       authorize @user, :reset_password?
-      @user.send_reset_password_instructions
+      @user.reset_password!
       log_action :reset_password, @user
-      redirect_to admin_accounts_path
+      redirect_to admin_account_path(@user.account_id)
     end
   end
 end
diff --git a/app/controllers/admin/sign_in_token_authentications_controller.rb b/app/controllers/admin/sign_in_token_authentications_controller.rb
new file mode 100644
index 000000000..e620ab292
--- /dev/null
+++ b/app/controllers/admin/sign_in_token_authentications_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Admin
+  class SignInTokenAuthenticationsController < BaseController
+    before_action :set_target_user
+
+    def create
+      authorize @user, :enable_sign_in_token_auth?
+      @user.update(skip_sign_in_token: false)
+      log_action :enable_sign_in_token_auth, @user
+      redirect_to admin_account_path(@user.account_id)
+    end
+
+    def destroy
+      authorize @user, :disable_sign_in_token_auth?
+      @user.update(skip_sign_in_token: true)
+      log_action :disable_sign_in_token_auth, @user
+      redirect_to admin_account_path(@user.account_id)
+    end
+
+    private
+
+    def set_target_user
+      @user = User.find(params[:user_id])
+    end
+  end
+end
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index ef279509d..8d039b281 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -2,71 +2,57 @@
 
 module Admin
   class StatusesController < BaseController
-    helper_method :current_params
-
     before_action :set_account
+    before_action :set_statuses
 
     PER_PAGE = 20
 
     def index
       authorize :status, :index?
 
-      @statuses = @account.statuses.where(visibility: [:public, :unlisted])
-
-      if params[:media]
-        @statuses.merge!(Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id))
-      end
-
-      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
-      @form     = Form::StatusBatch.new
-    end
-
-    def show
-      authorize :status, :index?
-
-      @statuses = @account.statuses.where(id: params[:id])
-      authorize @statuses.first, :show?
-
-      @form = Form::StatusBatch.new
+      @status_batch_action = Admin::StatusBatchAction.new
     end
 
-    def create
-      authorize :status, :update?
-
-      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
-      flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
-
-      redirect_to admin_account_statuses_path(@account.id, current_params)
+    def batch
+      @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
+      @status_batch_action.save!
     rescue ActionController::ParameterMissing
       flash[:alert] = I18n.t('admin.statuses.no_status_selected')
-
-      redirect_to admin_account_statuses_path(@account.id, current_params)
+    ensure
+      redirect_to after_create_redirect_path
     end
 
     private
 
-    def form_status_batch_params
-      params.require(:form_status_batch).permit(:action, status_ids: [])
+    def admin_status_batch_action_params
+      params.require(:admin_status_batch_action).permit(status_ids: [])
+    end
+
+    def after_create_redirect_path
+      if @status_batch_action.report_id.present?
+        admin_report_path(@status_batch_action.report_id)
+      else
+        admin_account_statuses_path(params[:account_id], current_params)
+      end
     end
 
     def set_account
       @account = Account.find(params[:account_id])
     end
 
-    def current_params
-      page = (params[:page] || 1).to_i
+    def set_statuses
+      @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE)
+    end
 
-      {
-        media: params[:media],
-        page: page > 1 && page,
-      }.select { |_, value| value.present? }
+    def filter_params
+      params.slice(*Admin::StatusFilter::KEYS).permit(*Admin::StatusFilter::KEYS)
     end
 
     def action_from_button
-      if params[:nsfw_on]
-        'nsfw_on'
-      elsif params[:nsfw_off]
-        'nsfw_off'
+      if params[:report]
+        'report'
+      elsif params[:remove_from_report]
+        'remove_from_report'
       elsif params[:delete]
         'delete'
       end
diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
index eed4feea2..749e2f144 100644
--- a/app/controllers/admin/tags_controller.rb
+++ b/app/controllers/admin/tags_controller.rb
@@ -2,38 +2,12 @@
 
 module Admin
   class TagsController < BaseController
-    before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all]
-    before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all]
-    before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all]
-
-    def index
-      authorize :tag, :index?
-
-      @tags = filtered_tags.page(params[:page])
-      @form = Form::TagBatch.new
-    end
-
-    def batch
-      @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
-      @form.save
-    rescue ActionController::ParameterMissing
-      flash[:alert] = I18n.t('admin.accounts.no_account_selected')
-    ensure
-      redirect_to admin_tags_path(filter_params)
-    end
-
-    def approve_all
-      Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'approve').save
-      redirect_to admin_tags_path(filter_params)
-    end
-
-    def reject_all
-      Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'reject').save
-      redirect_to admin_tags_path(filter_params)
-    end
+    before_action :set_tag
 
     def show
       authorize @tag, :show?
+
+      @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
     end
 
     def update
@@ -52,52 +26,8 @@ module Admin
       @tag = Tag.find(params[:id])
     end
 
-    def set_usage_by_domain
-      @usage_by_domain = @tag.statuses
-                             .with_public_visibility
-                             .excluding_silenced_accounts
-                             .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
-                             .joins(:account)
-                             .group('accounts.domain')
-                             .reorder(statuses_count: :desc)
-                             .pluck(Arel.sql('accounts.domain, count(*) AS statuses_count'))
-    end
-
-    def set_counters
-      @accounts_today = @tag.history.first[:accounts]
-      @accounts_week  = Redis.current.pfcount(*current_week_days.map { |day| "activity:tags:#{@tag.id}:#{day}:accounts" })
-    end
-
-    def filtered_tags
-      TagFilter.new(filter_params).results
-    end
-
-    def filter_params
-      params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
-    end
-
     def tag_params
       params.require(:tag).permit(:name, :trendable, :usable, :listable)
     end
-
-    def current_week_days
-      now = Time.now.utc.beginning_of_day.to_date
-
-      (Date.commercial(now.cwyear, now.cweek)..now).map do |date|
-        date.to_time(:utc).beginning_of_day.to_i
-      end
-    end
-
-    def form_tag_batch_params
-      params.require(:form_tag_batch).permit(:action, tag_ids: [])
-    end
-
-    def action_from_button
-      if params[:approve]
-        'approve'
-      elsif params[:reject]
-        'reject'
-      end
-    end
   end
 end
diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
new file mode 100644
index 000000000..2c26e03f3
--- /dev/null
+++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseController
+  def index
+    authorize :preview_card_provider, :index?
+
+    @preview_card_providers = filtered_preview_card_providers.page(params[:page])
+    @form = Form::PreviewCardProviderBatch.new
+  end
+
+  def batch
+    @form = Form::PreviewCardProviderBatch.new(form_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form.save
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+  ensure
+    redirect_to admin_trends_links_preview_card_providers_path(filter_params)
+  end
+
+  private
+
+  def filtered_preview_card_providers
+    PreviewCardProviderFilter.new(filter_params).results
+  end
+
+  def filter_params
+    params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS)
+  end
+
+  def form_preview_card_provider_batch_params
+    params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
+  end
+
+  def action_from_button
+    if params[:approve]
+      'approve'
+    elsif params[:reject]
+      'reject'
+    end
+  end
+end
diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb
new file mode 100644
index 000000000..619b37deb
--- /dev/null
+++ b/app/controllers/admin/trends/links_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Admin::Trends::LinksController < Admin::BaseController
+  def index
+    authorize :preview_card, :index?
+
+    @preview_cards = filtered_preview_cards.page(params[:page])
+    @form          = Form::PreviewCardBatch.new
+  end
+
+  def batch
+    @form = Form::PreviewCardBatch.new(form_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form.save
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+  ensure
+    redirect_to admin_trends_links_path(filter_params)
+  end
+
+  private
+
+  def filtered_preview_cards
+    PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
+  end
+
+  def filter_params
+    params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS)
+  end
+
+  def form_preview_card_batch_params
+    params.require(:form_preview_card_batch).permit(:action, preview_card_ids: [])
+  end
+
+  def action_from_button
+    if params[:approve]
+      'approve'
+    elsif params[:approve_all]
+      'approve_all'
+    elsif params[:reject]
+      'reject'
+    elsif params[:reject_all]
+      'reject_all'
+    end
+  end
+end
diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb
new file mode 100644
index 000000000..91ff33d40
--- /dev/null
+++ b/app/controllers/admin/trends/tags_controller.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Trends::TagsController < Admin::BaseController
+  def index
+    authorize :tag, :index?
+
+    @tags = filtered_tags.page(params[:page])
+    @form = Form::TagBatch.new
+  end
+
+  def batch
+    @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form.save
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+  ensure
+    redirect_to admin_trends_tags_path(filter_params)
+  end
+
+  private
+
+  def filtered_tags
+    TagFilter.new(filter_params).results
+  end
+
+  def filter_params
+    params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
+  end
+
+  def form_tag_batch_params
+    params.require(:form_tag_batch).permit(:action, tag_ids: [])
+  end
+
+  def action_from_button
+    if params[:approve]
+      'approve'
+    elsif params[:reject]
+      'reject'
+    end
+  end
+end
diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb
index 0652c3a7a..f7fb7eb8f 100644
--- a/app/controllers/admin/two_factor_authentications_controller.rb
+++ b/app/controllers/admin/two_factor_authentications_controller.rb
@@ -9,7 +9,7 @@ module Admin
       @user.disable_two_factor!
       log_action :disable_2fa, @user
       UserMailer.two_factor_disabled(@user).deliver_later!
-      redirect_to admin_accounts_path
+      redirect_to admin_account_path(@user.account_id)
     end
 
     private
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 85f4cc768..b863d8643 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -40,7 +40,12 @@ class Api::BaseController < ApplicationController
     render json: { error: 'This action is not allowed' }, status: 403
   end
 
-  rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight do
+  rescue_from Seahorse::Client::NetworkingError do |e|
+    Rails.logger.warn "Storage server error: #{e}"
+    render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
+  end
+
+  rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do
     render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
   end
 
diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb
deleted file mode 100644
index dd32cd577..000000000
--- a/app/controllers/api/proofs_controller.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-class Api::ProofsController < Api::BaseController
-  include AccountOwnedConcern
-
-  skip_before_action :require_authenticated_user!
-
-  before_action :set_provider
-
-  def index
-    render json: @account, serializer: @provider.serializer_class
-  end
-
-  private
-
-  def set_provider
-    @provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound)
-  end
-
-  def username_param
-    params[:username]
-  end
-end
diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
index 4b5f6902c..48f293f47 100644
--- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb
+++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
@@ -5,8 +5,7 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController
   before_action :set_account
 
   def index
-    @proofs = @account.suspended? ? [] : @account.identity_proofs.active
-    render json: @proofs, each_serializer: REST::IdentityProofSerializer
+    render json: []
   end
 
   private
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 6e44f5c01..5e5d2b19b 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -48,9 +48,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def pinned_scope
-    return Status.none if @account.blocking?(current_account)
-
-    @account.pinned_statuses
+    @account.pinned_statuses.permitted_for(@account, current_account)
   end
 
   def no_replies_scope
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 95869f554..5c47158e0 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 class Api::V1::AccountsController < Api::BaseController
-  before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :block, :unblock, :mute, :unmute]
-  before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow]
+  before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :remove_from_followers, :block, :unblock, :mute, :unmute]
+  before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow, :remove_from_followers]
   before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute]
   before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock]
   before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create]
@@ -53,6 +53,11 @@ class Api::V1::AccountsController < Api::BaseController
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
+  def remove_from_followers
+    RemoveFromFollowersService.new.call(current_user.account, @account)
+    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
+  end
+
   def unblock
     UnblockService.new.call(current_user.account, @account)
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
@@ -78,10 +83,14 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def check_enabled_registrations
-    forbidden if single_user_mode? || !allowed_registrations?
+    forbidden if single_user_mode? || omniauth_only? || !allowed_registrations?
   end
 
   def allowed_registrations?
     Setting.registrations_mode != 'none'
   end
+
+  def omniauth_only?
+    ENV['OMNIAUTH_ONLY'] == 'true'
+  end
 end
diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb
index 29c9b7107..15af50822 100644
--- a/app/controllers/api/v1/admin/account_actions_controller.rb
+++ b/app/controllers/api/v1/admin/account_actions_controller.rb
@@ -1,7 +1,9 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::AccountActionsController < Api::BaseController
-  before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }
   before_action :require_staff!
   before_action :set_account
 
diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb
index 63cc521ed..65330b8c8 100644
--- a/app/controllers/api/v1/admin/accounts_controller.rb
+++ b/app/controllers/api/v1/admin/accounts_controller.rb
@@ -1,13 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::AccountsController < Api::BaseController
+  protect_from_forgery with: :exception
+
   include Authorization
   include AccountableConcern
 
   LIMIT = 100
 
-  before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
-  before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
   before_action :require_staff!
   before_action :set_accounts, only: :index
   before_action :set_account, except: :index
@@ -94,7 +96,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
   private
 
   def set_accounts
-    @accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite]).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
+    @accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite, :ips]).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
   end
 
   def set_account
diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb
new file mode 100644
index 000000000..b1f738990
--- /dev/null
+++ b/app/controllers/api/v1/admin/dimensions_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::DimensionsController < Api::BaseController
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:read' }
+  before_action :require_staff!
+  before_action :set_dimensions
+
+  def create
+    render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer
+  end
+
+  private
+
+  def set_dimensions
+    @dimensions = Admin::Metrics::Dimension.retrieve(
+      params[:keys],
+      params[:start_at],
+      params[:end_at],
+      params[:limit],
+      params
+    )
+  end
+end
diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb
new file mode 100644
index 000000000..d64c3cdf7
--- /dev/null
+++ b/app/controllers/api/v1/admin/measures_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::MeasuresController < Api::BaseController
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:read' }
+  before_action :require_staff!
+  before_action :set_measures
+
+  def create
+    render json: @measures, each_serializer: REST::Admin::MeasureSerializer
+  end
+
+  private
+
+  def set_measures
+    @measures = Admin::Metrics::Measure.retrieve(
+      params[:keys],
+      params[:start_at],
+      params[:end_at],
+      params
+    )
+  end
+end
diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb
index c8f4cd8d8..fbfd0ee12 100644
--- a/app/controllers/api/v1/admin/reports_controller.rb
+++ b/app/controllers/api/v1/admin/reports_controller.rb
@@ -1,13 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::ReportsController < Api::BaseController
+  protect_from_forgery with: :exception
+
   include Authorization
   include AccountableConcern
 
   LIMIT = 100
 
-  before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
-  before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
   before_action :require_staff!
   before_action :set_reports, only: :index
   before_action :set_report, except: :index
@@ -32,6 +34,12 @@ class Api::V1::Admin::ReportsController < Api::BaseController
     render json: @report, serializer: REST::Admin::ReportSerializer
   end
 
+  def update
+    authorize @report, :update?
+    @report.update!(report_params)
+    render json: @report, serializer: REST::Admin::ReportSerializer
+  end
+
   def assign_to_self
     authorize @report, :update?
     @report.update!(assigned_account_id: current_account.id)
@@ -74,6 +82,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController
     ReportFilter.new(filter_params).results
   end
 
+  def report_params
+    params.permit(:category, rule_ids: [])
+  end
+
   def filter_params
     params.permit(*FILTER_PARAMS)
   end
diff --git a/app/controllers/api/v1/admin/retention_controller.rb b/app/controllers/api/v1/admin/retention_controller.rb
new file mode 100644
index 000000000..4af5a5c4d
--- /dev/null
+++ b/app/controllers/api/v1/admin/retention_controller.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::RetentionController < Api::BaseController
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:read' }
+  before_action :require_staff!
+  before_action :set_cohorts
+
+  def create
+    render json: @cohorts, each_serializer: REST::Admin::CohortSerializer
+  end
+
+  private
+
+  def set_cohorts
+    @cohorts = Admin::Metrics::Retention.new(
+      params[:start_at],
+      params[:end_at],
+      params[:frequency]
+    ).cohorts
+  end
+end
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
new file mode 100644
index 000000000..4815af31e
--- /dev/null
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::Trends::TagsController < Api::BaseController
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:read' }
+  before_action :require_staff!
+  before_action :set_tags
+
+  def index
+    render json: @tags, each_serializer: REST::Admin::TagSerializer
+  end
+
+  private
+
+  def set_tags
+    @tags = Trends.tags.get(false, limit_param(10))
+  end
+end
diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb
index 4f6b4bcbf..bad61425a 100644
--- a/app/controllers/api/v1/instances/activity_controller.rb
+++ b/app/controllers/api/v1/instances/activity_controller.rb
@@ -14,22 +14,21 @@ class Api::V1::Instances::ActivityController < Api::BaseController
   private
 
   def activity
-    weeks = []
-
-    12.times do |i|
-      day     = i.weeks.ago.to_date
-      week_id = day.cweek
-      week    = Date.commercial(day.cwyear, week_id)
-
-      weeks << {
-        week: week.to_time.to_i.to_s,
-        statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
-        logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
-        registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
+    statuses_tracker      = ActivityTracker.new('activity:statuses:local', :basic)
+    logins_tracker        = ActivityTracker.new('activity:logins', :unique)
+    registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic)
+
+    (0...12).map do |i|
+      start_of_week = i.weeks.ago
+      end_of_week   = start_of_week + 6.days
+
+      {
+        week: start_of_week.to_i.to_s,
+        statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
+        logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
+        registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
       }
     end
-
-    weeks
   end
 
   def require_enabled_api!
diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb
new file mode 100644
index 000000000..c2c1fac5d
--- /dev/null
+++ b/app/controllers/api/v1/statuses/histories_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::HistoriesController < Api::BaseController
+  include Authorization
+
+  before_action -> { authorize_if_got_token! :read, :'read:statuses' }
+  before_action :set_status
+
+  def show
+    render json: @status.edits, each_serializer: REST::StatusEditSerializer
+  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/sources_controller.rb b/app/controllers/api/v1/statuses/sources_controller.rb
new file mode 100644
index 000000000..434086451
--- /dev/null
+++ b/app/controllers/api/v1/statuses/sources_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::SourcesController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
+  before_action :set_status
+
+  def show
+    render json: @status, serializer: REST::StatusSourceSerializer
+  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_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index c8529318f..b1390ae48 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -57,7 +57,7 @@ class Api::V1::StatusesController < Api::BaseController
     authorize @status, :destroy?
 
     @status.discard
-    RemovalWorker.perform_async(@status.id, redraft: true)
+    RemovalWorker.perform_async(@status.id, { 'redraft' => true })
     @status.account.statuses_count = @status.account.statuses_count - 1
 
     render json: @status, serializer: REST::StatusSerializer, source_requested: true
diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb
new file mode 100644
index 000000000..1c3ab1e1c
--- /dev/null
+++ b/app/controllers/api/v1/trends/links_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::Trends::LinksController < Api::BaseController
+  before_action :set_links
+
+  def index
+    render json: @links, each_serializer: REST::Trends::LinkSerializer
+  end
+
+  private
+
+  def set_links
+    @links = begin
+      if Setting.trends
+        Trends.links.get(true, limit_param(10))
+      else
+        []
+      end
+    end
+  end
+end
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
new file mode 100644
index 000000000..947b53de2
--- /dev/null
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::Trends::TagsController < Api::BaseController
+  before_action :set_tags
+
+  def index
+    render json: @tags, each_serializer: REST::TagSerializer
+  end
+
+  private
+
+  def set_tags
+    @tags = begin
+      if Setting.trends
+        Trends.tags.get(true, limit_param(10))
+      else
+        []
+      end
+    end
+  end
+end
diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb
deleted file mode 100644
index c875e9041..000000000
--- a/app/controllers/api/v1/trends_controller.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-class Api::V1::TrendsController < Api::BaseController
-  before_action :set_tags
-
-  def index
-    render json: @tags, each_serializer: REST::TagSerializer
-  end
-
-  private
-
-  def set_tags
-    @tags = TrendingTags.get(limit_param(10))
-  end
-end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 9eb73d576..08cca0734 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -10,6 +10,7 @@ class ApplicationController < ActionController::Base
   include SessionTrackingConcern
   include CacheConcern
   include DomainControlHelper
+  include ThemingConcern
 
   helper_method :current_account
   helper_method :current_session
@@ -27,7 +28,12 @@ class ApplicationController < ActionController::Base
   rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
 
   rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
-  rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable
+  rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable
+
+  rescue_from Seahorse::Client::NetworkingError do |e|
+    Rails.logger.warn "Storage server error: #{e}"
+    service_unavailable
+  end
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :require_functional!, if: :user_signed_in?
@@ -68,75 +74,6 @@ class ApplicationController < ActionController::Base
     new_user_session_path
   end
 
-  def pack(data, pack_name, skin = 'default')
-    return nil unless pack?(data, pack_name)
-    pack_data = {
-      common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
-      flavour: data['name'],
-      pack: pack_name,
-      preload: nil,
-      skin: nil,
-      supported_locales: data['locales'],
-    }
-    if data['pack'][pack_name].is_a?(Hash)
-      pack_data[:common] = nil if data['pack'][pack_name]['use_common'] == false
-      pack_data[:pack] = nil unless data['pack'][pack_name]['filename']
-      if data['pack'][pack_name]['preload']
-        pack_data[:preload] = [data['pack'][pack_name]['preload']] if data['pack'][pack_name]['preload'].is_a?(String)
-        pack_data[:preload] = data['pack'][pack_name]['preload'] if data['pack'][pack_name]['preload'].is_a?(Array)
-      end
-      if skin != 'default' && data['skin'][skin]
-        pack_data[:skin] = skin if data['skin'][skin].include?(pack_name)
-      else  #  default skin
-        pack_data[:skin] = 'default' if data['pack'][pack_name]['stylesheet']
-      end
-    end
-    pack_data
-  end
-
-  def pack?(data, pack_name)
-    if data['pack'].is_a?(Hash) && data['pack'].key?(pack_name)
-      return true if data['pack'][pack_name].is_a?(String) || data['pack'][pack_name].is_a?(Hash)
-    end
-    false
-  end
-
-  def nil_pack(data, pack_name, skin = 'default')
-    {
-      common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
-      flavour: data['name'],
-      pack: nil,
-      preload: nil,
-      skin: nil,
-      supported_locales: data['locales'],
-    }
-  end
-
-  def resolve_pack(data, pack_name, skin = 'default')
-    result = pack(data, pack_name, skin)
-    unless result
-      if data['name'] && data.key?('fallback')
-        if data['fallback'].nil?
-          return nil_pack(data, pack_name, skin)
-        elsif data['fallback'].is_a?(String) && Themes.instance.flavour(data['fallback'])
-          return resolve_pack(Themes.instance.flavour(data['fallback']), pack_name)
-        elsif data['fallback'].is_a?(Array)
-          data['fallback'].each do |fallback|
-            return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback)
-          end
-        end
-        return nil_pack(data, pack_name, skin)
-      end
-      return data.key?('name') && data['name'] != Setting.default_settings['flavour'] ? resolve_pack(Themes.instance.flavour(Setting.default_settings['flavour']), pack_name) : nil_pack(data, pack_name, skin)
-    end
-    result
-  end
-
-  def use_pack(pack_name)
-    @core = resolve_pack(Themes.instance.core, pack_name)
-    @theme = resolve_pack(Themes.instance.flavour(current_flavour), pack_name, current_skin)
-  end
-
   protected
 
   def truthy_param?(key)
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 0b5a2f3c9..17ad56fa8 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -1,12 +1,18 @@
 # frozen_string_literal: true
 
 class Auth::ConfirmationsController < Devise::ConfirmationsController
+  include CaptchaConcern
+
   layout 'auth'
 
   before_action :set_body_classes
   before_action :set_pack
+  before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
   before_action :require_unconfirmed!
 
+  before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
+  before_action :require_captcha_if_needed!, only: [:show]
+
   skip_before_action :require_functional!
 
   def new
@@ -15,8 +21,46 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
     resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
   end
 
+  def show
+    old_session_values = session.to_hash
+    reset_session
+    session.update old_session_values.except('session_id')
+
+    super
+  end
+
+  def confirm_captcha
+    check_captcha! do |message|
+      flash.now[:alert] = message
+      render :captcha
+      return
+    end
+
+    show
+  end
+
   private
 
+  def require_captcha_if_needed!
+    render :captcha if captcha_required?
+  end
+
+  def set_confirmation_user!
+    # We need to reimplement looking up the user because
+    # Devise::ConfirmationsController#show looks up and confirms in one
+    # step.
+    confirmation_token = params[:confirmation_token]
+    return if confirmation_token.nil?
+    @confirmation_user = User.find_first_by_auth_conditions(confirmation_token: confirmation_token)
+  end
+
+  def captcha_user_bypass?
+    return true if @confirmation_user.nil? || @confirmation_user.confirmed?
+
+    invite = Invite.find(@confirmation_user.invite_id) if @confirmation_user.invite_id.present?
+    invite.present? && !invite.max_uses.nil?
+  end
+
   def set_pack
     use_pack 'auth'
   end
diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb
index 7925e23cb..991a50b03 100644
--- a/app/controllers/auth/omniauth_callbacks_controller.rb
+++ b/app/controllers/auth/omniauth_callbacks_controller.rb
@@ -11,7 +11,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
 
       if @user.persisted?
         LoginActivity.create(
-          user: user,
+          user: @user,
           success: true,
           authentication_method: :omniauth,
           provider: provider,
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index 42534f8ce..609220eb1 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -11,7 +11,6 @@ class Auth::PasswordsController < Devise::PasswordsController
     super do |resource|
       if resource.errors.empty?
         resource.session_activations.destroy_all
-        resource.forget_me!
       end
     end
   end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 6429bd969..6b1f3fa82 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -1,7 +1,6 @@
 # frozen_string_literal: true
 
 class Auth::RegistrationsController < Devise::RegistrationsController
-  include Devise::Controllers::Rememberable
   include RegistrationSpamConcern
 
   layout :determine_layout
@@ -31,8 +30,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     super do |resource|
       if resource.saved_change_to_encrypted_password?
         resource.clear_other_sessions(current_session.session_id)
-        resource.forget_me!
-        remember_me(resource)
       end
     end
   end
@@ -85,13 +82,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def check_enabled_registrations
-    redirect_to root_path if single_user_mode? || !allowed_registrations?
+    redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations?
   end
 
   def allowed_registrations?
     Setting.registrations_mode != 'none' || @invite&.valid_for_use?
   end
 
+  def omniauth_only?
+    ENV['OMNIAUTH_ONLY'] == 'true'
+  end
+
   def invite_code
     if params[:user]
       params[:user][:invite_code]
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index f07f38075..8607077f7 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Auth::SessionsController < Devise::SessionsController
-  include Devise::Controllers::Rememberable
-
   layout 'auth'
 
   skip_before_action :require_no_authentication, only: [:create]
@@ -17,14 +15,6 @@ class Auth::SessionsController < Devise::SessionsController
   before_action :set_instance_presenter, only: [:new]
   before_action :set_body_classes
 
-  def new
-    Devise.omniauth_configs.each do |provider, config|
-      return redirect_to(omniauth_authorize_path(resource_name, provider)) if config.strategy.redirect_at_sign_in
-    end
-
-    super
-  end
-
   def create
     super do |resource|
       # We only need to call this if this hasn't already been
@@ -44,10 +34,13 @@ class Auth::SessionsController < Devise::SessionsController
   end
 
   def webauthn_options
-    user = find_user
+    user = User.find_by(id: session[:attempt_user_id])
 
     if user&.webauthn_enabled?
-      options_for_get = WebAuthn::Credential.options_for_get(allow: user.webauthn_credentials.pluck(:external_id))
+      options_for_get = WebAuthn::Credential.options_for_get(
+        allow: user.webauthn_credentials.pluck(:external_id),
+        user_verification: 'discouraged'
+      )
 
       session[:webauthn_challenge] = options_for_get.challenge
 
@@ -60,16 +53,20 @@ class Auth::SessionsController < Devise::SessionsController
   protected
 
   def find_user
-    if session[:attempt_user_id]
+    if user_params[:email].present?
+      find_user_from_params
+    elsif session[:attempt_user_id]
       User.find_by(id: session[:attempt_user_id])
-    else
-      user   = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
-      user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
-      user ||= User.find_for_authentication(email: user_params[:email])
-      user
     end
   end
 
+  def find_user_from_params
+    user   = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
+    user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
+    user ||= User.find_for_authentication(email: user_params[:email])
+    user
+  end
+
   def user_params
     params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
   end
@@ -84,14 +81,6 @@ class Auth::SessionsController < Devise::SessionsController
     end
   end
 
-  def after_sign_out_path_for(_resource_or_scope)
-    Devise.omniauth_configs.each_value do |config|
-      return root_path if config.strategy.redirect_at_sign_in
-    end
-
-    super
-  end
-
   def require_no_authentication
     super
 
@@ -148,8 +137,7 @@ class Auth::SessionsController < Devise::SessionsController
 
     clear_attempt_from_session
 
-    user.update_sign_in!(request, new_sign_in: true)
-    remember_me(user)
+    user.update_sign_in!(new_sign_in: true)
     sign_in(user)
     flash.delete(:notice)
 
diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb
index 62e379846..25149d03f 100644
--- a/app/controllers/concerns/account_owned_concern.rb
+++ b/app/controllers/concerns/account_owned_concern.rb
@@ -8,6 +8,7 @@ module AccountOwnedConcern
     before_action :set_account, if: :account_required?
     before_action :check_account_approval, if: :account_required?
     before_action :check_account_suspension, if: :account_required?
+    before_action :check_account_confirmation, if: :account_required?
   end
 
   private
@@ -28,6 +29,10 @@ module AccountOwnedConcern
     not_found if @account.local? && @account.user_pending?
   end
 
+  def check_account_confirmation
+    not_found if @account.local? && !@account.user_confirmed?
+  end
+
   def check_account_suspension
     if @account.suspended_permanently?
       permanent_suspension_response
diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb
index 3cdcffc51..87d62478d 100644
--- a/app/controllers/concerns/accountable_concern.rb
+++ b/app/controllers/concerns/accountable_concern.rb
@@ -3,7 +3,7 @@
 module AccountableConcern
   extend ActiveSupport::Concern
 
-  def log_action(action, target)
-    Admin::ActionLog.create(account: current_account, action: action, target: target)
+  def log_action(action, target, options = {})
+    Admin::ActionLog.create(account: current_account, action: action, target: target, recorded_changes: options.stringify_keys)
   end
 end
diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/captcha_concern.rb
new file mode 100644
index 000000000..538c1ffb1
--- /dev/null
+++ b/app/controllers/concerns/captcha_concern.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module CaptchaConcern
+  extend ActiveSupport::Concern
+  include Hcaptcha::Adapters::ViewMethods
+
+  included do
+    helper_method :render_captcha
+  end
+
+  def captcha_available?
+    ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
+  end
+
+  def captcha_enabled?
+    captcha_available? && Setting.captcha_enabled
+  end
+
+  def captcha_user_bypass?
+    false
+  end
+
+  def captcha_required?
+    captcha_enabled? && !captcha_user_bypass?
+  end
+
+  def check_captcha!
+    return true unless captcha_required?
+
+    if verify_hcaptcha
+      true
+    else
+      if block_given?
+        message = flash[:hcaptcha_error]
+        flash.delete(:hcaptcha_error)
+        yield message
+      end
+      false
+    end
+  end
+
+  def extend_csp_for_captcha!
+    policy = request.content_security_policy
+    return unless captcha_required? && policy.present?
+
+    %w(script_src frame_src style_src connect_src).each do |directive|
+      values = policy.send(directive)
+      values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
+      values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
+      policy.send(directive, *values)
+    end
+  end
+
+  def render_captcha
+    return unless captcha_required?
+
+    hcaptcha_tags
+  end
+end
diff --git a/app/controllers/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb
index 016ab8f52..4eb3d7181 100644
--- a/app/controllers/concerns/sign_in_token_authentication_concern.rb
+++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb
@@ -16,14 +16,18 @@ module SignInTokenAuthenticationConcern
   end
 
   def authenticate_with_sign_in_token
-    user = self.resource = find_user
-
-    if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s
-      restart_session
-    elsif user_params.key?(:sign_in_token_attempt) && session[:attempt_user_id]
-      authenticate_with_sign_in_token_attempt(user)
-    elsif user.present? && user.external_or_valid_password?(user_params[:password])
-      prompt_for_sign_in_token(user)
+    if user_params[:email].present?
+      user = self.resource = find_user_from_params
+      prompt_for_sign_in_token(user) if user&.external_or_valid_password?(user_params[:password])
+    elsif session[:attempt_user_id]
+      user = self.resource = User.find_by(id: session[:attempt_user_id])
+      return if user.nil?
+
+      if session[:attempt_user_updated_at] != user.updated_at.to_s
+        restart_session
+      elsif user_params.key?(:sign_in_token_attempt)
+        authenticate_with_sign_in_token_attempt(user)
+      end
     end
   end
 
diff --git a/app/controllers/concerns/theming_concern.rb b/app/controllers/concerns/theming_concern.rb
new file mode 100644
index 000000000..1ee3256c0
--- /dev/null
+++ b/app/controllers/concerns/theming_concern.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module ThemingConcern
+  extend ActiveSupport::Concern
+
+  def use_pack(pack_name)
+    @core = resolve_pack_with_common(Themes.instance.core, pack_name)
+    @theme = resolve_pack_with_common(Themes.instance.flavour(current_flavour), pack_name, current_skin)
+  end
+
+  private
+
+  def valid_pack_data?(data, pack_name)
+    data['pack'].is_a?(Hash) && [String, Hash].any? { |c| data['pack'][pack_name].is_a?(c) }
+  end
+
+  def nil_pack(data)
+    {
+      use_common: true,
+      flavour: data['name'],
+      pack: nil,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+  end
+
+  def pack(data, pack_name, skin)
+    pack_data = {
+      use_common: true,
+      flavour: data['name'],
+      pack: pack_name,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+
+    return pack_data unless data['pack'][pack_name].is_a?(Hash)
+
+    pack_data[:use_common] = false if data['pack'][pack_name]['use_common'] == false
+    pack_data[:pack] = nil unless data['pack'][pack_name]['filename']
+
+    preloads = data['pack'][pack_name]['preload']
+    pack_data[:preload] = [preloads] if preloads.is_a?(String)
+    pack_data[:preload] = preloads if preloads.is_a?(Array)
+
+    if skin != 'default' && data['skin'][skin]
+      pack_data[:skin] = skin if data['skin'][skin].include?(pack_name)
+    elsif data['pack'][pack_name]['stylesheet']
+      pack_data[:skin] = 'default'
+    end
+
+    pack_data
+  end
+
+  def resolve_pack(data, pack_name, skin)
+    return pack(data, pack_name, skin) if valid_pack_data?(data, pack_name)
+    return if data['name'].blank?
+
+    fallbacks = []
+    if data.key?('fallback')
+      fallbacks = data['fallback'] if data['fallback'].is_a?(Array)
+      fallbacks = [data['fallback']] if data['fallback'].is_a?(String)
+    elsif data['name'] != Setting.default_settings['flavour']
+      fallbacks = [Setting.default_settings['flavour']]
+    end
+
+    fallbacks.each do |fallback|
+      return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback)
+    end
+
+    nil
+  end
+
+  def resolve_pack_with_common(data, pack_name, skin = 'default')
+    result = resolve_pack(data, pack_name, skin) || nil_pack(data)
+    result[:common] = resolve_pack(data, 'common', skin) if result.delete(:use_common)
+    result
+  end
+end
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index d3f00a4b4..c9477a1d4 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -35,16 +35,20 @@ module TwoFactorAuthenticationConcern
   end
 
   def authenticate_with_two_factor
-    user = self.resource = find_user
-
-    if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s
-      restart_session
-    elsif user.webauthn_enabled? && user_params.key?(:credential) && session[:attempt_user_id]
-      authenticate_with_two_factor_via_webauthn(user)
-    elsif user_params.key?(:otp_attempt) && session[:attempt_user_id]
-      authenticate_with_two_factor_via_otp(user)
-    elsif user.present? && user.external_or_valid_password?(user_params[:password])
-      prompt_for_two_factor(user)
+    if user_params[:email].present?
+      user = self.resource = find_user_from_params
+      prompt_for_two_factor(user) if user&.external_or_valid_password?(user_params[:password])
+    elsif session[:attempt_user_id]
+      user = self.resource = User.find_by(id: session[:attempt_user_id])
+      return if user.nil?
+
+      if session[:attempt_user_updated_at] != user.updated_at.to_s
+        restart_session
+      elsif user.webauthn_enabled? && user_params.key?(:credential)
+        authenticate_with_two_factor_via_webauthn(user)
+      elsif user_params.key?(:otp_attempt)
+        authenticate_with_two_factor_via_otp(user)
+      end
     end
   end
 
@@ -53,7 +57,7 @@ module TwoFactorAuthenticationConcern
 
     if valid_webauthn_credential?(user, webauthn_credential)
       on_authentication_success(user, :webauthn)
-      render json: { redirect_path: root_path }, status: :ok
+      render json: { redirect_path: after_sign_in_path_for(user) }, status: :ok
     else
       on_authentication_failure(user, :webauthn, :invalid_credential)
       render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb
index efda37fae..45f3aab0d 100644
--- a/app/controllers/concerns/user_tracking_concern.rb
+++ b/app/controllers/concerns/user_tracking_concern.rb
@@ -3,7 +3,7 @@
 module UserTrackingConcern
   extend ActiveSupport::Concern
 
-  UPDATE_SIGN_IN_HOURS = 24
+  UPDATE_SIGN_IN_FREQUENCY = 24.hours.freeze
 
   included do
     before_action :update_user_sign_in
@@ -12,10 +12,10 @@ module UserTrackingConcern
   private
 
   def update_user_sign_in
-    current_user.update_sign_in!(request) if user_needs_sign_in_update?
+    current_user.update_sign_in! if user_needs_sign_in_update?
   end
 
   def user_needs_sign_in_update?
-    user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago)
+    user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_FREQUENCY.ago)
   end
 end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index c9b840881..450f92bd4 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -16,30 +16,7 @@ class HomeController < ApplicationController
   def redirect_unauthenticated_to_permalinks!
     return if user_signed_in?
 
-    matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
-
-    if matches
-      case matches[1]
-      when 'statuses'
-        status = Status.find_by(id: matches[2])
-
-        if status&.distributable?
-          redirect_to(ActivityPub::TagManager.instance.url_for(status))
-          return
-        end
-      when 'accounts'
-        account = Account.find_by(id: matches[2])
-
-        if account
-          redirect_to(ActivityPub::TagManager.instance.url_for(account))
-          return
-        end
-      end
-    end
-
-    matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z})
-
-    redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path)
+    redirect_to(PermalinkRedirector.new(request.path).redirect_path || default_redirect_path)
   end
 
   def set_pack
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 772fc42cb..d2de432ba 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -28,7 +28,12 @@ class MediaController < ApplicationController
   private
 
   def set_media_attachment
-    @media_attachment = MediaAttachment.attached.find_by!(shortcode: params[:id] || params[:medium_id])
+    id = params[:id] || params[:medium_id]
+    return if id.nil?
+
+    scope = MediaAttachment.local.attached
+    # If id is 19 characters long, it's a shortcode, otherwise it's an identifier
+    @media_attachment = id.size == 19 ? scope.find_by!(shortcode: id) : scope.find_by!(id: id)
   end
 
   def verify_permitted_status!
diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb
index 7b8f8d207..e0dd5edcb 100644
--- a/app/controllers/settings/deletes_controller.rb
+++ b/app/controllers/settings/deletes_controller.rb
@@ -42,7 +42,7 @@ class Settings::DeletesController < Settings::BaseController
   end
 
   def destroy_account!
-    current_account.suspend!(origin: :local)
+    current_account.suspend!(origin: :local, block_email: false)
     AccountDeletionWorker.perform_async(current_user.account_id)
     sign_out
   end
diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb
deleted file mode 100644
index 4618c7883..000000000
--- a/app/controllers/settings/identity_proofs_controller.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-class Settings::IdentityProofsController < Settings::BaseController
-  before_action :check_required_params, only: :new
-  before_action :check_enabled, only: :new
-
-  def index
-    @proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc)
-    @proofs.each(&:refresh!)
-  end
-
-  def new
-    @proof = current_account.identity_proofs.new(
-      token: params[:token],
-      provider: params[:provider],
-      provider_username: params[:provider_username]
-    )
-
-    if current_account.username.casecmp(params[:username]).zero?
-      render layout: 'auth'
-    else
-      redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username)
-    end
-  end
-
-  def create
-    @proof = current_account.identity_proofs.where(provider: resource_params[:provider], provider_username: resource_params[:provider_username]).first_or_initialize(resource_params)
-    @proof.token = resource_params[:token]
-
-    if @proof.save
-      PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof?
-      redirect_to @proof.on_success_path(params[:user_agent])
-    else
-      redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
-    end
-  end
-
-  def destroy
-    @proof = current_account.identity_proofs.find(params[:id])
-    @proof.destroy!
-    redirect_to settings_identity_proofs_path, success: I18n.t('identity_proofs.removed')
-  end
-
-  private
-
-  def check_enabled
-    not_found unless Setting.enable_keybase
-  end
-
-  def check_required_params
-    redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? }
-  end
-
-  def resource_params
-    params.require(:account_identity_proof).permit(:provider, :provider_username, :token)
-  end
-
-  def publish_proof?
-    ActiveModel::Type::Boolean.new.cast(post_params[:post_status])
-  end
-
-  def post_params
-    params.require(:account_identity_proof).permit(:post_status, :status_text)
-  end
-end
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
index bd6f83134..7e2d43dcd 100644
--- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -21,7 +21,8 @@ module Settings
             display_name: current_user.account.username,
             id: current_user.webauthn_id,
           },
-          exclude: current_user.webauthn_credentials.pluck(:external_id)
+          exclude: current_user.webauthn_credentials.pluck(:external_id),
+          authenticator_selection: { user_verification: 'discouraged' }
         )
 
         session[:webauthn_challenge] = options_for_create.challenge
diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb
new file mode 100644
index 000000000..3d4f4af02
--- /dev/null
+++ b/app/controllers/statuses_cleanup_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class StatusesCleanupController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_policy
+  before_action :set_body_classes
+  before_action :set_pack
+
+  def show; end
+
+  def update
+    if @policy.update(resource_params)
+      redirect_to statuses_cleanup_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render action: :show
+    end
+  rescue ActionController::ParameterMissing
+    # Do nothing
+  end
+
+  private
+
+  def set_pack
+    use_pack 'settings'
+  end
+
+  def set_policy
+    @policy = current_account.statuses_cleanup_policy || current_account.build_statuses_cleanup_policy(enabled: false)
+  end
+
+  def resource_params
+    params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs)
+  end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
+end
diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb
deleted file mode 100644
index 03232df2d..000000000
--- a/app/controllers/well_known/keybase_proof_config_controller.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module WellKnown
-  class KeybaseProofConfigController < ActionController::Base
-    before_action :check_enabled
-
-    def show
-      render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer, root: 'keybase_config'
-    end
-
-    private
-
-    def check_enabled
-      head 404 unless Setting.enable_keybase
-    end
-  end
-end
diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb
index 0227f722a..2b296ea3b 100644
--- a/app/controllers/well_known/webfinger_controller.rb
+++ b/app/controllers/well_known/webfinger_controller.rb
@@ -4,7 +4,6 @@ module WellKnown
   class WebfingerController < ActionController::Base
     include RoutingHelper
 
-    before_action { response.headers['Vary'] = 'Accept' }
     before_action :set_account
     before_action :check_account_suspension
 
@@ -39,10 +38,12 @@ module WellKnown
     end
 
     def bad_request
+      expires_in(3.minutes, public: true)
       head 400
     end
 
     def not_found
+      expires_in(3.minutes, public: true)
       head 404
     end
 
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index e977db2c6..bb2374c0e 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -84,19 +84,19 @@ module AccountsHelper
   def account_description(account)
     prepend_stats = [
       [
-        number_to_human(account.statuses_count, strip_insignificant_zeros: true),
+        number_to_human(account.statuses_count, precision: 3, strip_insignificant_zeros: true),
         I18n.t('accounts.posts', count: account.statuses_count),
       ].join(' '),
 
       [
-        number_to_human(account.following_count, strip_insignificant_zeros: true),
+        number_to_human(account.following_count, precision: 3, strip_insignificant_zeros: true),
         I18n.t('accounts.following', count: account.following_count),
       ].join(' '),
     ]
 
     unless hide_followers_count?(account)
       prepend_stats << [
-        number_to_human(account.followers_count, strip_insignificant_zeros: true),
+        number_to_human(account.followers_count, precision: 3, strip_insignificant_zeros: true),
         I18n.t('accounts.followers', count: account.followers_count),
       ].join(' ')
     end
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index e9a298a24..f3aa4be4f 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -31,11 +31,15 @@ module Admin::ActionLogsHelper
       link_to truncate(record.text), edit_admin_announcement_path(record.id)
     when 'IpBlock'
       "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
+    when 'Instance'
+      record.domain
     end
   end
 
   def log_target_from_history(type, attributes)
     case type
+    when 'User'
+      attributes['username']
     when 'CustomEmoji'
       attributes['shortcode']
     when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
@@ -52,6 +56,8 @@ module Admin::ActionLogsHelper
       truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
     when 'IpBlock'
       "#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
+    when 'Instance'
+      attributes['domain']
     end
   end
 end
diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb
index 4ee2cdef4..c21d41341 100644
--- a/app/helpers/admin/dashboard_helper.rb
+++ b/app/helpers/admin/dashboard_helper.rb
@@ -1,10 +1,41 @@
 # frozen_string_literal: true
 
 module Admin::DashboardHelper
-  def feature_hint(feature, enabled)
-    indicator   = safe_join([enabled ? t('simple_form.yes') : t('simple_form.no'), fa_icon('power-off fw')], ' ')
-    class_names = enabled ? 'pull-right positive-hint' : 'pull-right neutral-hint'
+  def relevant_account_ip(account, ip_query)
+    ips = account.user.present? ? account.user.ips.to_a : []
 
-    safe_join([feature, content_tag(:span, indicator, class: class_names)])
+    matched_ip = begin
+      ip_query_addr = IPAddr.new(ip_query)
+      ips.find { |ip| ip_query_addr.include?(ip.ip) } || ips.first
+    rescue IPAddr::Error
+      ips.first
+    end
+
+    if matched_ip
+      link_to matched_ip.ip, admin_accounts_path(ip: matched_ip.ip)
+    else
+      '-'
+    end
+  end
+
+  def relevant_account_timestamp(account)
+    timestamp, exact = begin
+      if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago
+        [account.user_current_sign_in_at, true]
+      elsif account.user_current_sign_in_at
+        [account.user_current_sign_in_at, false]
+      elsif account.user_pending?
+        [account.user_created_at, true]
+      elsif account.last_status_at.present?
+        [account.last_status_at, true]
+      else
+        [nil, false]
+      end
+    end
+
+    return '-' if timestamp.nil?
+    return t('generic.today') unless exact
+
+    content_tag(:time, l(timestamp), class: 'time-ago', datetime: timestamp.iso8601, title: l(timestamp))
   end
 end
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index ba0ca9638..907529b37 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -6,11 +6,14 @@ module Admin::FilterHelper
     CustomEmojiFilter::KEYS,
     ReportFilter::KEYS,
     TagFilter::KEYS,
+    PreviewCardProviderFilter::KEYS,
+    PreviewCardFilter::KEYS,
     InstanceFilter::KEYS,
     InviteFilter::KEYS,
     RelationshipFilter::KEYS,
     AnnouncementFilter::KEYS,
     Admin::ActionLogFilter::KEYS,
+    Admin::StatusFilter::KEYS,
   ].flatten.freeze
 
   def filter_link_to(text, link_to_params, link_class_params = link_to_params)
diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb
index baf14ab25..f99a2b8c8 100644
--- a/app/helpers/admin/settings_helper.rb
+++ b/app/helpers/admin/settings_helper.rb
@@ -8,4 +8,8 @@ module Admin::SettingsHelper
     link = link_to t('admin.site_uploads.delete'), admin_site_upload_path(upload), data: { method: :delete }
     safe_join([hint, link], '<br/>'.html_safe)
   end
+
+  def captcha_available?
+    ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
+  end
 end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 5a9496bd4..8b41033a5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -14,6 +14,17 @@ module ApplicationHelper
     ku
   ).freeze
 
+  def friendly_number_to_human(number, **options)
+    # By default, the number of precision digits used by number_to_human
+    # is looked up from the locales definition, and rails-i18n comes with
+    # values that don't seem to make much sense for many languages, so
+    # override these values with a default of 3 digits of precision.
+    options[:precision] = 3
+    options[:strip_insignificant_zeros] = true
+
+    number_to_human(number, **options)
+  end
+
   def active_nav_class(*paths)
     paths.any? { |path| current_page?(path) } ? 'active' : ''
   end
@@ -39,13 +50,39 @@ module ApplicationHelper
   end
 
   def available_sign_up_path
-    if closed_registrations?
+    if closed_registrations? || omniauth_only?
       'https://joinmastodon.org/#getting-started'
     else
       new_user_registration_path
     end
   end
 
+  def omniauth_only?
+    ENV['OMNIAUTH_ONLY'] == 'true'
+  end
+
+  def link_to_login(name = nil, html_options = nil, &block)
+    target = new_user_session_path
+
+    html_options = name if block_given?
+
+    if omniauth_only? && Devise.mappings[:user].omniauthable? && User.omniauth_providers.size == 1
+      target = omniauth_authorize_path(:user, User.omniauth_providers[0])
+      html_options ||= {}
+      html_options[:method] = :post
+    end
+
+    if block_given?
+      link_to(target, html_options, &block)
+    else
+      link_to(name, target, html_options)
+    end
+  end
+
+  def provider_sign_in_link(provider)
+    link_to I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize), omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
+  end
+
   def open_deletion?
     Setting.open_deletion
   end
@@ -126,6 +163,10 @@ module ApplicationHelper
     end
   end
 
+  def react_admin_component(name, props = {})
+    content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) })
+  end
+
   def body_classes
     output = (@body_classes || '').split(' ')
     output << "flavour-#{current_flavour.parameterize}"
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index 62eb50f78..c24d2ddf1 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -34,7 +34,13 @@ module JsonLdHelper
   end
 
   def as_array(value)
-    value.is_a?(Array) ? value : [value]
+    if value.nil?
+      []
+    elsif value.is_a?(Array)
+      value
+    else
+      [value]
+    end
   end
 
   def value_or_id(value)
diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb
new file mode 100644
index 000000000..2eba433a3
--- /dev/null
+++ b/app/helpers/languages_helper.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module LanguagesHelper
+  HUMAN_LOCALES = {
+    af: 'Afrikaans',
+    ar: 'العربية',
+    ast: 'Asturianu',
+    bg: 'Български',
+    bn: 'বাংলা',
+    br: 'Breton',
+    ca: 'Català',
+    co: 'Corsu',
+    cs: 'Čeština',
+    cy: 'Cymraeg',
+    da: 'Dansk',
+    de: 'Deutsch',
+    el: 'Ελληνικά',
+    en: 'English',
+    'en-cafe': 'English (Plural Cafe)',
+    eo: 'Esperanto',
+    'es-AR': 'Español (Argentina)',
+    'es-MX': 'Español (México)',
+    es: 'Español',
+    et: 'Eesti',
+    eu: 'Euskara',
+    fa: 'فارسی',
+    fi: 'Suomi',
+    fr: 'Français',
+    ga: 'Gaeilge',
+    gd: 'Gàidhlig',
+    gl: 'Galego',
+    he: 'עברית',
+    hi: 'हिन्दी',
+    hr: 'Hrvatski',
+    hu: 'Magyar',
+    hy: 'Հայերեն',
+    id: 'Bahasa Indonesia',
+    io: 'Ido',
+    is: 'Íslenska',
+    it: 'Italiano',
+    ja: '日本語',
+    ka: 'ქართული',
+    kab: 'Taqbaylit',
+    kk: 'Қазақша',
+    kmr: 'Kurmancî',
+    kn: 'ಕನ್ನಡ',
+    ko: '한국어',
+    ku: 'سۆرانی',
+    lt: 'Lietuvių',
+    lv: 'Latviešu',
+    mk: 'Македонски',
+    ml: 'മലയാളം',
+    mr: 'मराठी',
+    ms: 'Bahasa Melayu',
+    nl: 'Nederlands',
+    nn: 'Nynorsk',
+    no: 'Norsk',
+    oc: 'Occitan',
+    pl: 'Polski',
+    'pt-BR': 'Português (Brasil)',
+    'pt-PT': 'Português (Portugal)',
+    pt: 'Português',
+    ro: 'Română',
+    ru: 'Русский',
+    sa: 'संस्कृतम्',
+    sc: 'Sardu',
+    si: 'සිංහල',
+    sk: 'Slovenčina',
+    sl: 'Slovenščina',
+    sq: 'Shqip',
+    'sr-Latn': 'Srpski (latinica)',
+    sr: 'Српски',
+    sv: 'Svenska',
+    ta: 'தமிழ்',
+    te: 'తెలుగు',
+    th: 'ไทย',
+    tr: 'Türkçe',
+    uk: 'Українська',
+    ur: 'اُردُو',
+    vi: 'Tiếng Việt',
+    zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ',
+    'zh-CN': '简体中文',
+    'zh-HK': '繁體中文(香港)',
+    'zh-TW': '繁體中文(臺灣)',
+    zh: '中文',
+  }.freeze
+
+  def human_locale(locale)
+    if locale == 'und'
+      I18n.t('generic.none')
+    else
+      HUMAN_LOCALES[locale.to_sym] || locale
+    end
+  end
+end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 2bc65e497..23739d1cd 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -1,95 +1,8 @@
 # frozen_string_literal: true
 
 module SettingsHelper
-  HUMAN_LOCALES = {
-    af: 'Afrikaans',
-    ar: 'العربية',
-    ast: 'Asturianu',
-    bg: 'Български',
-    bn: 'বাংলা',
-    br: 'Breton',
-    ca: 'Català',
-    co: 'Corsu',
-    cs: 'Čeština',
-    cy: 'Cymraeg',
-    da: 'Dansk',
-    de: 'Deutsch',
-    el: 'Ελληνικά',
-    en: 'English',
-    'en-cafe': 'English (Plural Café)',
-    eo: 'Esperanto',
-    'es-AR': 'Español (Argentina)',
-    'es-MX': 'Español (México)',
-    es: 'Español',
-    et: 'Eesti',
-    eu: 'Euskara',
-    fa: 'فارسی',
-    fi: 'Suomi',
-    fr: 'Français',
-    ga: 'Gaeilge',
-    gd: 'Gàidhlig',
-    gl: 'Galego',
-    he: 'עברית',
-    hi: 'हिन्दी',
-    hr: 'Hrvatski',
-    hu: 'Magyar',
-    hy: 'Հայերեն',
-    id: 'Bahasa Indonesia',
-    io: 'Ido',
-    is: 'Íslenska',
-    it: 'Italiano',
-    ja: '日本語',
-    ka: 'ქართული',
-    kab: 'Taqbaylit',
-    kk: 'Қазақша',
-    kn: 'ಕನ್ನಡ',
-    ko: '한국어',
-    ku: 'سۆرانی',
-    lt: 'Lietuvių',
-    lv: 'Latviešu',
-    mk: 'Македонски',
-    ml: 'മലയാളം',
-    mr: 'मराठी',
-    ms: 'Bahasa Melayu',
-    nl: 'Nederlands',
-    nn: 'Nynorsk',
-    no: 'Norsk',
-    oc: 'Occitan',
-    pl: 'Polski',
-    'pt-BR': 'Português (Brasil)',
-    'pt-PT': 'Português (Portugal)',
-    pt: 'Português',
-    ro: 'Română',
-    ru: 'Русский',
-    sa: 'संस्कृतम्',
-    sc: 'Sardu',
-    si: 'සිංහල',
-    sk: 'Slovenčina',
-    sl: 'Slovenščina',
-    sq: 'Shqip',
-    'sr-Latn': 'Srpski (latinica)',
-    sr: 'Српски',
-    sv: 'Svenska',
-    ta: 'தமிழ்',
-    te: 'తెలుగు',
-    th: 'ไทย',
-    tr: 'Türkçe',
-    uk: 'Українська',
-    ur: 'اُردُو',
-    vi: 'Tiếng Việt',
-    zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ',
-    'zh-CN': '简体中文',
-    'zh-HK': '繁體中文(香港)',
-    'zh-TW': '繁體中文(臺灣)',
-    zh: '中文',
-  }.freeze
-
-  def human_locale(locale)
-    HUMAN_LOCALES[locale]
-  end
-
   def filterable_languages
-    LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?))
+    LanguageDetector.instance.language_names.select(&LanguagesHelper::HUMAN_LOCALES.method(:key?))
   end
 
   def hash_to_object(hash)
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
index 912a3d179..0cf64e076 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -5,6 +5,10 @@ export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
 export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
 export const ACCOUNT_FETCH_FAIL    = 'ACCOUNT_FETCH_FAIL';
 
+export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST';
+export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS';
+export const ACCOUNT_LOOKUP_FAIL    = 'ACCOUNT_LOOKUP_FAIL';
+
 export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
 export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
 export const ACCOUNT_FOLLOW_FAIL    = 'ACCOUNT_FOLLOW_FAIL';
@@ -104,6 +108,34 @@ export function fetchAccount(id) {
   };
 };
 
+export const lookupAccount = acct => (dispatch, getState) => {
+  dispatch(lookupAccountRequest(acct));
+
+  api(getState).get('/api/v1/accounts/lookup', { params: { acct } }).then(response => {
+    dispatch(fetchRelationships([response.data.id]));
+    dispatch(importFetchedAccount(response.data));
+    dispatch(lookupAccountSuccess());
+  }).catch(error => {
+    dispatch(lookupAccountFail(acct, error));
+  });
+};
+
+export const lookupAccountRequest = (acct) => ({
+  type: ACCOUNT_LOOKUP_REQUEST,
+  acct,
+});
+
+export const lookupAccountSuccess = () => ({
+  type: ACCOUNT_LOOKUP_SUCCESS,
+});
+
+export const lookupAccountFail = (acct, error) => ({
+  type: ACCOUNT_LOOKUP_FAIL,
+  acct,
+  error,
+  skipAlert: true,
+});
+
 export function fetchAccountRequest(id) {
   return {
     type: ACCOUNT_FETCH_REQUEST,
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index f83738093..261c72b2a 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -10,6 +10,7 @@ import { importFetchedAccounts } from './importer';
 import { updateTimeline } from './timelines';
 import { showAlertForError } from './alerts';
 import { showAlert } from './alerts';
+import { openModal } from './modal';
 import { defineMessages } from 'react-intl';
 
 let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
@@ -38,6 +39,7 @@ export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
 export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
 export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
 export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+export const COMPOSE_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE';
 export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
 
 export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
@@ -68,6 +70,11 @@ export const COMPOSE_POLL_OPTION_CHANGE   = 'COMPOSE_POLL_OPTION_CHANGE';
 export const COMPOSE_POLL_OPTION_REMOVE   = 'COMPOSE_POLL_OPTION_REMOVE';
 export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
 
+export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
+
+export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
+export const COMPOSE_CHANGE_MEDIA_FOCUS       = 'COMPOSE_CHANGE_MEDIA_FOCUS';
+
 const messages = defineMessages({
   uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
   uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@@ -77,7 +84,7 @@ const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
 
 export const ensureComposeIsVisible = (getState, routerHistory) => {
   if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
-    routerHistory.push('/statuses/new');
+    routerHistory.push('/publish');
   }
 };
 
@@ -170,7 +177,8 @@ export function submitCompose(routerHistory) {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
       },
     }).then(function (response) {
-      if (routerHistory && routerHistory.location.pathname === '/statuses/new'
+      if (routerHistory
+          && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new')
           && window.history.state
           && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) {
         routerHistory.goBack();
@@ -278,12 +286,15 @@ export function uploadCompose(files) {
           if (status === 200) {
             dispatch(uploadComposeSuccess(data, f));
           } else if (status === 202) {
+            let tryCount = 1;
             const poll = () => {
               api(getState).get(`/api/v1/media/${data.id}`).then(response => {
                 if (response.status === 200) {
                   dispatch(uploadComposeSuccess(response.data, f));
                 } else if (response.status === 206) {
-                  setTimeout(() => poll(), 1000);
+                  let retryAfter = (Math.log2(tryCount) || 1) * 1000;
+                  tryCount += 1;
+                  setTimeout(() => poll(), retryAfter);
                 }
               }).catch(error => dispatch(uploadComposeFail(error)));
             };
@@ -339,6 +350,32 @@ export const uploadThumbnailFail = error => ({
   skipLoading: true,
 });
 
+export function initMediaEditModal(id) {
+  return dispatch => {
+    dispatch({
+      type: INIT_MEDIA_EDIT_MODAL,
+      id,
+    });
+
+    dispatch(openModal('FOCAL_POINT', { id }));
+  };
+};
+
+export function onChangeMediaDescription(description) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+    description,
+  };
+};
+
+export function onChangeMediaFocus(focusX, focusY) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_FOCUS,
+    focusX,
+    focusY,
+  };
+};
+
 export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
@@ -529,13 +566,25 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
       completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
     }
 
-    dispatch({
-      type: COMPOSE_SUGGESTION_SELECT,
-      position,
-      token,
-      completion,
-      path,
-    });
+    // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
+    // the suggestions are dismissed and the cursor moves forward.
+    if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
+      dispatch({
+        type: COMPOSE_SUGGESTION_SELECT,
+        position,
+        token,
+        completion,
+        path,
+      });
+    } else {
+      dispatch({
+        type: COMPOSE_SUGGESTION_IGNORE,
+        position,
+        token,
+        completion,
+        path,
+      });
+    }
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 9b3bd0d56..bda15a9b0 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -54,15 +54,16 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
-  // Only calculate these values when status first encountered
-  // Otherwise keep the ones already in the reducer
-  if (normalOldStatus) {
+  // Only calculate these values when status first encountered and
+  // when the underlying values change. Otherwise keep the ones
+  // already in the reducer
+  if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
-    const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).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)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
     const emojiMap      = makeEmojiMap(normalStatus);
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index bd3a34e5d..4b00ea632 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -1,6 +1,6 @@
 import api, { getLinks } from 'flavours/glitch/util/api';
 import IntlMessageFormat from 'intl-messageformat';
-import { fetchRelationships } from './accounts';
+import { fetchFollowRequests, fetchRelationships } from './accounts';
 import {
   importFetchedAccount,
   importFetchedAccounts,
@@ -90,6 +90,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
       filtered = regex && regex.test(searchIndex);
     }
 
+    if (['follow_request'].includes(notification.type)) {
+      dispatch(fetchFollowRequests());
+    }
+
     dispatch(submitMarkers());
 
     if (showInColumn) {
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 4d2bda78b..7db357df1 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -128,6 +128,9 @@ export function deleteStatusFail(id, error) {
   };
 };
 
+export const updateStatus = status => dispatch =>
+  dispatch(importFetchedStatus(status));
+
 export function fetchContext(id) {
   return (dispatch, getState) => {
     dispatch(fetchContextRequest(id));
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index 35db5dcc9..223924534 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -10,6 +10,7 @@ import {
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
 import { updateConversations } from './conversations';
+import { updateStatus } from './statuses';
 import {
   fetchAnnouncements,
   updateAnnouncements,
@@ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'update':
           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
           break;
+        case 'status.update':
+          dispatch(updateStatus(JSON.parse(data.payload)));
+          break;
         case 'delete':
           dispatch(deleteFromTimelines(data.payload));
           break;
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index 20313535b..396a36ea0 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -128,7 +128,7 @@ class Account extends ImmutablePureComponent {
       <Permalink
         className='account small'
         href={account.get('url')}
-        to={`/accounts/${account.get('id')}`}
+        to={`/@${account.get('acct')}`}
       >
         <div className='account__avatar-wrapper'>
           <Avatar
@@ -144,7 +144,7 @@ class Account extends ImmutablePureComponent {
     ) : (
       <div className='account'>
         <div className='account__wrapper'>
-          <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+          <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             {mute_expires_at}
             <DisplayName account={account} />
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.js b/app/javascript/flavours/glitch/components/admin/Counter.js
new file mode 100644
index 000000000..2bc9ce482
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Counter.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedNumber } from 'react-intl';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import classNames from 'classnames';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+const percIncrease = (a, b) => {
+  let percent;
+
+  if (b !== 0) {
+    if (a !== 0) {
+      percent = (b - a) / a;
+    } else {
+      percent = 1;
+    }
+  } else if (b === 0 && a === 0) {
+    percent = 0;
+  } else {
+    percent = - 1;
+  }
+
+  return percent;
+};
+
+export default class Counter extends React.PureComponent {
+
+  static propTypes = {
+    measure: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    label: PropTypes.string.isRequired,
+    href: PropTypes.string,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { measure, start_at, end_at, params } = this.props;
+
+    api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, href } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><Skeleton width={43} /></span>
+          <span className='sparkline__value__change'><Skeleton width={43} /></span>
+        </React.Fragment>
+      );
+    } else {
+      const measure = data[0];
+      const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
+
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
+          <span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
+        </React.Fragment>
+      );
+    }
+
+    const inner = (
+      <React.Fragment>
+        <div className='sparkline__value'>
+          {content}
+        </div>
+
+        <div className='sparkline__label'>
+          {label}
+        </div>
+
+        <div className='sparkline__graph'>
+          {!loading && (
+            <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
+              <SparklinesCurve />
+            </Sparklines>
+          )}
+        </div>
+      </React.Fragment>
+    );
+
+    if (href) {
+      return (
+        <a href={href} className='sparkline'>
+          {inner}
+        </a>
+      );
+    } else {
+      return (
+        <div className='sparkline'>
+          {inner}
+        </div>
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.js b/app/javascript/flavours/glitch/components/admin/Dimension.js
new file mode 100644
index 000000000..a924d093c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Dimension.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedNumber } from 'react-intl';
+import { roundTo10 } from 'flavours/glitch/util/numbers';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+export default class Dimension extends React.PureComponent {
+
+  static propTypes = {
+    dimension: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    limit: PropTypes.number.isRequired,
+    label: PropTypes.string.isRequired,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, dimension, limit, params } = this.props;
+
+    api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <table>
+          <tbody>
+            {Array.from(Array(limit)).map((_, i) => (
+              <tr className='dimension__item' key={i}>
+                <td className='dimension__item__key'>
+                  <Skeleton width={100} />
+                </td>
+
+                <td className='dimension__item__value'>
+                  <Skeleton width={60} />
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    } else {
+      const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
+
+      content = (
+        <table>
+          <tbody>
+            {data[0].data.map(item => (
+              <tr className='dimension__item' key={item.key}>
+                <td className='dimension__item__key'>
+                  <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
+                  <span title={item.key}>{item.human_key}</span>
+                </td>
+
+                <td className='dimension__item__value'>
+                  {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    return (
+      <div className='dimension'>
+        <h4>{label}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
new file mode 100644
index 000000000..0f2a4fe36
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  other: { id: 'report.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
+});
+
+class Category extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onSelect: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onSelect } = this.props;
+
+    if (!disabled) {
+      onSelect(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected, children } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
+        {selected && <input type='hidden' name='report[category]' value={id} />}
+
+        <div className='report-reason-selector__category__label'>
+          <span className={classNames('poll__input', { active: selected, disabled })} />
+          {text}
+        </div>
+
+        {(selected && children) && (
+          <div className='report-reason-selector__category__rules'>
+            {children}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+class Rule extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onToggle: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onToggle } = this.props;
+
+    if (!disabled) {
+      onToggle(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
+        <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
+        {selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
+        {text}
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class ReportReasonSelector extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    category: PropTypes.string.isRequired,
+    rule_ids: PropTypes.arrayOf(PropTypes.string),
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    category: this.props.category,
+    rule_ids: this.props.rule_ids || [],
+    rules: [],
+  };
+
+  componentDidMount() {
+    api().get('/api/v1/instance').then(res => {
+      this.setState({
+        rules: res.data.rules,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  _save = () => {
+    const { id, disabled } = this.props;
+    const { category, rule_ids } = this.state;
+
+    if (disabled) {
+      return;
+    }
+
+    api().put(`/api/v1/admin/reports/${id}`, {
+      category,
+      rule_ids,
+    }).catch(err => {
+      console.error(err);
+    });
+  };
+
+  handleSelect = id => {
+    this.setState({ category: id }, () => this._save());
+  };
+
+  handleToggle = id => {
+    const { rule_ids } = this.state;
+
+    if (rule_ids.includes(id)) {
+      this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
+    } else {
+      this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
+    }
+  };
+
+  render () {
+    const { disabled, intl } = this.props;
+    const { rules, category, rule_ids } = this.state;
+
+    return (
+      <div className='report-reason-selector'>
+        <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
+          {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
+        </Category>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Retention.js b/app/javascript/flavours/glitch/components/admin/Retention.js
new file mode 100644
index 000000000..6d7e4b279
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Retention.js
@@ -0,0 +1,151 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
+import classNames from 'classnames';
+import { roundTo10 } from 'flavours/glitch/util/numbers';
+
+const dateForCohort = cohort => {
+  switch(cohort.frequency) {
+  case 'day':
+    return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
+  default:
+    return <FormattedDate value={cohort.period} month='long' year='numeric' />;
+  }
+};
+
+export default class Retention extends React.PureComponent {
+
+  static propTypes = {
+    start_at: PropTypes.string,
+    end_at: PropTypes.string,
+    frequency: PropTypes.string,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, frequency } = this.props;
+
+    api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { loading, data } = this.state;
+    const { frequency } = this.props;
+
+    let content;
+
+    if (loading) {
+      content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
+    } else {
+      content = (
+        <table className='retention__table'>
+          <thead>
+            <tr>
+              <th>
+                <div className='retention__table__date retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
+                </div>
+              </th>
+
+              <th>
+                <div className='retention__table__number retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
+                </div>
+              </th>
+
+              {data[0].data.slice(1).map((retention, i) => (
+                <th key={retention.date}>
+                  <div className='retention__table__number retention__table__label'>
+                    {i + 1}
+                  </div>
+                </th>
+              ))}
+            </tr>
+
+            <tr>
+              <td>
+                <div className='retention__table__date retention__table__average'>
+                  <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
+                </div>
+              </td>
+
+              <td>
+                <div className='retention__table__size'>
+                  <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
+                </div>
+              </td>
+
+              {data[0].data.slice(1).map((retention, i) => {
+                const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
+
+                return (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
+                      <FormattedNumber value={average} style='percent' />
+                    </div>
+                  </td>
+                );
+              })}
+            </tr>
+          </thead>
+
+          <tbody>
+            {data.slice(0, -1).map(cohort => (
+              <tr key={cohort.period}>
+                <td>
+                  <div className='retention__table__date'>
+                    {dateForCohort(cohort)}
+                  </div>
+                </td>
+
+                <td>
+                  <div className='retention__table__size'>
+                    <FormattedNumber value={cohort.data[0].value} />
+                  </div>
+                </td>
+
+                {cohort.data.slice(1).map(retention => (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}>
+                      <FormattedNumber value={retention.rate} style='percent' />
+                    </div>
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    let title = null;
+    switch(frequency) {
+    case 'day':
+      title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
+      break;
+    default:
+      title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
+    };
+
+    return (
+      <div className='retention'>
+        <h4>{title}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js
new file mode 100644
index 000000000..60e367f00
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Trends.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Hashtag from 'flavours/glitch/components/hashtag';
+
+export default class Trends extends React.PureComponent {
+
+  static propTypes = {
+    limit: PropTypes.number.isRequired,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { limit } = this.props;
+
+    api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <div>
+          {Array.from(Array(limit)).map((_, i) => (
+            <Hashtag key={i} />
+          ))}
+        </div>
+      );
+    } else {
+      content = (
+        <div>
+          {data.map(hashtag => (
+            <Hashtag
+              key={hashtag.name}
+              name={hashtag.name}
+              href={`/admin/tags/${hashtag.id}`}
+              people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
+              uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
+              history={hashtag.history.reverse().map(day => day.uses)}
+              className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
+            />
+          ))}
+        </div>
+      );
+    }
+
+    return (
+      <div className='trends trends--compact'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/attachment_list.js b/app/javascript/flavours/glitch/components/attachment_list.js
index 68d8d29c7..68b80b19f 100644
--- a/app/javascript/flavours/glitch/components/attachment_list.js
+++ b/app/javascript/flavours/glitch/components/attachment_list.js
@@ -2,6 +2,8 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 
 const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@@ -16,29 +18,13 @@ export default class AttachmentList extends ImmutablePureComponent {
   render () {
     const { media, compact } = this.props;
 
-    if (compact) {
-      return (
-        <div className='attachment-list compact'>
-          <ul className='attachment-list__list'>
-            {media.map(attachment => {
-              const displayUrl = attachment.get('remote_url') || attachment.get('url');
-
-              return (
-                <li key={attachment.get('id')}>
-                  <a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a>
-                </li>
-              );
-            })}
-          </ul>
-        </div>
-      );
-    }
-
     return (
-      <div className='attachment-list'>
-        <div className='attachment-list__icon'>
-          <Icon id='link' />
-        </div>
+      <div className={classNames('attachment-list', { compact })}>
+        {!compact && (
+          <div className='attachment-list__icon'>
+            <Icon id='link' />
+          </div>
+        )}
 
         <ul className='attachment-list__list'>
           {media.map(attachment => {
@@ -46,7 +32,11 @@ export default class AttachmentList extends ImmutablePureComponent {
 
             return (
               <li key={attachment.get('id')}>
-                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a>
+                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>
+                  {compact && <Icon id='link' />}
+                  {compact && ' ' }
+                  {displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
+                </a>
               </li>
             );
           })}
diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js
index 125b51c44..e30dfe68a 100644
--- a/app/javascript/flavours/glitch/components/avatar_composite.js
+++ b/app/javascript/flavours/glitch/components/avatar_composite.js
@@ -82,7 +82,7 @@ export default class AvatarComposite extends React.PureComponent {
       <a
         href={account.get('url')}
         target='_blank'
-        onClick={(e) => this.props.onAccountClick(account.get('id'), e)}
+        onClick={(e) => this.props.onAccountClick(account.get('acct'), e)}
         title={`@${account.get('acct')}`}
         key={account.get('id')}
       >
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index ccd0714f1..500612093 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -124,8 +124,8 @@ class ColumnHeader extends React.PureComponent {
 
       moveButtons = (
         <div key='move-buttons' className='column-header__setting-arrows'>
-          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
-          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
+          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
+          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
         </div>
       );
     } else if (multiColumn && this.props.onPin) {
@@ -146,8 +146,8 @@ class ColumnHeader extends React.PureComponent {
     ];
 
     if (multiColumn) {
-      collapsedContent.push(moveButtons);
       collapsedContent.push(pinButton);
+      collapsedContent.push(moveButtons);
     }
 
     if (children || (multiColumn && this.props.onPin)) {
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index ad978a2c6..9c7da744e 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -61,7 +61,7 @@ export default class DisplayName extends React.PureComponent {
         <a
           href={a.get('url')}
           target='_blank'
-          onClick={(e) => onAccountClick(a.get('id'), e)}
+          onClick={(e) => onAccountClick(a.get('acct'), e)}
           title={`@${a.get('acct')}`}
           rel='noopener noreferrer'
         >
@@ -76,7 +76,7 @@ export default class DisplayName extends React.PureComponent {
       }
 
       suffix = (
-        <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)} rel='noopener noreferrer'>
+        <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('acct'), e)} rel='noopener noreferrer'>
           <span className='display-name__account'>@{acct}</span>
         </a>
       );
diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js
index 8e6cd1461..4537bde1d 100644
--- a/app/javascript/flavours/glitch/components/error_boundary.js
+++ b/app/javascript/flavours/glitch/components/error_boundary.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
+import { source_url } from 'flavours/glitch/util/initial_state';
 import { preferencesLink } from 'flavours/glitch/util/backend_links';
 import StackTrace from 'stacktrace-js';
 
@@ -64,6 +65,11 @@ export default class ErrorBoundary extends React.PureComponent {
       debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```';
     }
 
+    let issueTracker = source_url;
+    if (source_url.match(/^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/)) {
+      issueTracker = source_url + '/issues';
+    }
+
     return (
       <div tabIndex='-1'>
         <div className='error-boundary'>
@@ -84,7 +90,7 @@ export default class ErrorBoundary extends React.PureComponent {
               <FormattedMessage
                 id='web_app_crash.report_issue'
                 defaultMessage='Report a bug in the {issuetracker}'
-                values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
+                values={{ issuetracker: <a href={issueTracker} rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
               />
               { debugInfo !== '' && (
                 <details>
diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js
index 24c595ed7..769185a2b 100644
--- a/app/javascript/flavours/glitch/components/hashtag.js
+++ b/app/javascript/flavours/glitch/components/hashtag.js
@@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
 import ShortNumber from 'flavours/glitch/components/short_number';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import classNames from 'classnames';
 
 class SilentErrorBoundary extends React.Component {
 
@@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
   />
 );
 
-const Hashtag = ({ hashtag }) => (
-  <div className='trends__item'>
+export const ImmutableHashtag = ({ hashtag }) => (
+  <Hashtag
+    name={hashtag.get('name')}
+    href={hashtag.get('url')}
+    to={`/tags/${hashtag.get('name')}`}
+    people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+    uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
+    history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+  />
+);
+
+ImmutableHashtag.propTypes = {
+  hashtag: ImmutablePropTypes.map.isRequired,
+};
+
+const Hashtag = ({ name, href, to, people, uses, history, className }) => (
+  <div className={classNames('trends__item', className)}>
     <div className='trends__item__name'>
-      <Permalink
-        href={hashtag.get('url')}
-        to={`/timelines/tag/${hashtag.get('name')}`}
-      >
-        #<span>{hashtag.get('name')}</span>
+      <Permalink href={href} to={to}>
+        {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
       </Permalink>
 
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'accounts']) * 1 +
-          hashtag.getIn(['history', 1, 'accounts']) * 1
-        }
-        renderer={accountsCountRenderer}
-      />
+      {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
     </div>
 
     <div className='trends__item__current'>
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'uses']) * 1 +
-          hashtag.getIn(['history', 1, 'uses']) * 1
-        }
-      />
+      {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
     </div>
 
     <div className='trends__item__sparkline'>
       <SilentErrorBoundary>
-        <Sparklines
-          width={50}
-          height={28}
-          data={hashtag
-            .get('history')
-            .reverse()
-            .map((day) => day.get('uses'))
-            .toArray()}
-        >
+        <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
           <SparklinesCurve style={{ fill: 'none' }} />
         </Sparklines>
       </SilentErrorBoundary>
@@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
 );
 
 Hashtag.propTypes = {
-  hashtag: ImmutablePropTypes.map.isRequired,
+  name: PropTypes.string,
+  href: PropTypes.string,
+  to: PropTypes.string,
+  people: PropTypes.number,
+  uses: PropTypes.number,
+  history: PropTypes.arrayOf(PropTypes.number),
+  className: PropTypes.string,
 };
 
 export default Hashtag;
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index 913234d32..7b5a630e5 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -76,10 +76,13 @@ export default class ModalRoot extends React.PureComponent {
         this.activeElement = null;
       }).catch(console.error);
 
-      this.handleModalClose();
+      this._handleModalClose();
     }
     if (this.props.children && !prevProps.children) {
-      this.handleModalOpen();
+      this._handleModalOpen();
+    }
+    if (this.props.children) {
+      this._ensureHistoryBuffer();
     }
   }
 
@@ -88,22 +91,29 @@ export default class ModalRoot extends React.PureComponent {
     window.removeEventListener('keydown', this.handleKeyDown);
   }
 
-  handleModalClose () {
+  _handleModalOpen () {
+    this._modalHistoryKey = Date.now();
+    this.unlistenHistory = this.history.listen((_, action) => {
+      if (action === 'POP') {
+        this.props.onClose();
+      }
+    });
+  }
+
+  _handleModalClose () {
     this.unlistenHistory();
 
-    const state = this.history.location.state;
-    if (state && state.mastodonModalOpen) {
+    const { state } = this.history.location;
+    if (state && state.mastodonModalKey === this._modalHistoryKey) {
       this.history.goBack();
     }
   }
 
-  handleModalOpen () {
-    const history = this.history;
-    const state   = {...history.location.state, mastodonModalOpen: true};
-    history.push(history.location.pathname, state);
-    this.unlistenHistory = history.listen(() => {
-      this.props.onClose();
-    });
+  _ensureHistoryBuffer () {
+    const { pathname, state } = this.history.location;
+    if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
+      this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
+    }
   }
 
   getSiblings = () => {
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index f230823cc..970b00705 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -12,8 +12,18 @@ import RelativeTimestamp from './relative_timestamp';
 import Icon from 'flavours/glitch/components/icon';
 
 const messages = defineMessages({
-  closed: { id: 'poll.closed', defaultMessage: 'Closed' },
-  voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
+  closed: {
+    id: 'poll.closed',
+    defaultMessage: 'Closed',
+  },
+  voted: {
+    id: 'poll.voted',
+    defaultMessage: 'You voted for this answer',
+  },
+  votes: {
+    id: 'poll.votes',
+    defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
+  },
 });
 
 const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
@@ -148,9 +158,16 @@ class Poll extends ImmutablePureComponent {
               data-index={optionIndex}
             />
           )}
-          {showResults && <span className='poll__number'>
-            {Math.round(percent)}%
-          </span>}
+          {showResults && (
+            <span
+              className='poll__number'
+              title={intl.formatMessage(messages.votes, {
+                votes: option.get('votes_count'),
+              })}
+            >
+              {Math.round(percent)}%
+            </span>
+          )}
 
           <span
             className='poll__option__text translate'
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index cc8d9f1f3..16f13afa4 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -1,5 +1,5 @@
 import React, { PureComponent } from 'react';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 import PropTypes from 'prop-types';
 import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
 import LoadMore from './load_more';
@@ -34,7 +34,6 @@ class ScrollableList extends PureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     showLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
@@ -264,11 +263,6 @@ class ScrollableList extends PureComponent {
     this.props.onLoadMore();
   }
 
-  defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
-    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
-    return !(location.state && location.state.mastodonModalOpen);
-  }
-
   handleLoadPending = e => {
     e.preventDefault();
     this.props.onLoadPending();
@@ -282,7 +276,7 @@ class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -348,7 +342,7 @@ class ScrollableList extends PureComponent {
 
     if (trackScroll) {
       return (
-        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll || this.defaultShouldUpdateScroll}>
+        <ScrollContainer scrollKey={scrollKey}>
           {scrollableArea}
         </ScrollContainer>
       );
diff --git a/app/javascript/flavours/glitch/components/skeleton.js b/app/javascript/flavours/glitch/components/skeleton.js
new file mode 100644
index 000000000..09093e99c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/skeleton.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
+
+Skeleton.propTypes = {
+  width: PropTypes.number,
+  height: PropTypes.number,
+};
+
+export default Skeleton;
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 782fd918e..02ff9ab28 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -346,7 +346,9 @@ class Status extends ImmutablePureComponent {
         return;
       } else {
         if (destination === undefined) {
-          destination = `/statuses/${
+          destination = `/@${
+            status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
+          }/${
             status.getIn(['reblog', 'id'], status.get('id'))
           }`;
         }
@@ -362,16 +364,6 @@ class Status extends ImmutablePureComponent {
     this.setState({ showMedia: !this.state.showMedia });
   }
 
-  handleAccountClick = (e) => {
-    if (this.context.router && e.button === 0) {
-      const id = e.currentTarget.getAttribute('data-id');
-      e.preventDefault();
-      let state = {...this.context.router.history.location.state};
-      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${id}`, state);
-    }
-  }
-
   handleExpandedToggle = () => {
     if (this.props.status.get('spoiler_text')) {
       this.setExpansion(!this.state.isExpanded);
@@ -433,13 +425,14 @@ class Status extends ImmutablePureComponent {
   handleHotkeyOpen = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
+    const status = this.props.status;
+    this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`, state);
   }
 
   handleHotkeyOpenProfile = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
   }
 
   handleHotkeyMoveUp = e => {
@@ -516,8 +509,8 @@ class Status extends ImmutablePureComponent {
     const { isExpanded, isCollapsed, forceFilter } = this.state;
     let background = null;
     let attachments = null;
-    let media = null;
-    let mediaIcon = null;
+    let media = [];
+    let mediaIcons = [];
 
     if (status === null) {
       return null;
@@ -543,9 +536,8 @@ class Status extends ImmutablePureComponent {
       return (
         <HotKeys handlers={handlers}>
           <div ref={this.handleRef} className='status focusable' tabIndex='0'>
-            {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
-            {' '}
-            {status.get('content')}
+            <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
+            <span>{status.get('content')}</span>
           </div>
         </HotKeys>
       );
@@ -587,25 +579,27 @@ class Status extends ImmutablePureComponent {
     //  After we have generated our appropriate media element and stored it in
     //  `media`, we snatch the thumbnail to use as our `background` if media
     //  backgrounds for collapsed statuses are enabled.
+
     attachments = status.get('media_attachments');
     if (status.get('poll')) {
-      media = <PollContainer pollId={status.get('poll')} />;
-      mediaIcon = 'tasks';
-    } else if (usingPiP) {
-      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
-      mediaIcon = 'video-camera';
+      media.push(<PollContainer pollId={status.get('poll')} />);
+      mediaIcons.push('tasks');
+    }
+    if (usingPiP) {
+      media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
+      mediaIcons.push('video-camera');
     } else if (attachments.size > 0) {
       if (muted || attachments.some(item => item.get('type') === 'unknown')) {
-        media = (
+        media.push(
           <AttachmentList
             compact
             media={status.get('media_attachments')}
-          />
+          />,
         );
       } else if (attachments.getIn([0, 'type']) === 'audio') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
             {Component => (
               <Component
@@ -622,13 +616,13 @@ class Status extends ImmutablePureComponent {
                 deployPictureInPicture={this.handleDeployPictureInPicture}
               />
             )}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'music';
+        mediaIcons.push('music');
       } else if (attachments.getIn([0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
             {Component => (<Component
               preview={attachment.get('preview_url')}
@@ -648,11 +642,11 @@ class Status extends ImmutablePureComponent {
               visible={this.state.showMedia}
               onToggleVisibility={this.handleToggleMediaVisibility}
             />)}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'video-camera';
+        mediaIcons.push('video-camera');
       } else {  //  Media type is 'image' or 'gifv'
-        media = (
+        media.push(
           <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
             {Component => (
               <Component
@@ -668,16 +662,16 @@ class Status extends ImmutablePureComponent {
                 onToggleVisibility={this.handleToggleMediaVisibility}
               />
             )}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'picture-o';
+        mediaIcons.push('picture-o');
       }
 
       if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
         background = attachments.getIn([0, 'preview_url']);
       }
     } else if (status.get('card') && settings.get('inline_preview_cards')) {
-      media = (
+      media.push(
         <Card
           onOpenMedia={this.handleOpenMedia}
           card={status.get('card')}
@@ -685,9 +679,9 @@ class Status extends ImmutablePureComponent {
           cacheWidth={this.props.cacheMediaWidth}
           defaultWidth={this.props.cachedMediaWidth}
           sensitive={status.get('sensitive')}
-        />
+        />,
       );
-      mediaIcon = 'link';
+      mediaIcons.push('link');
     }
 
     //  Here we prepare extra data-* attributes for CSS selectors.
@@ -754,7 +748,7 @@ class Status extends ImmutablePureComponent {
             </span>
             <StatusIcons
               status={status}
-              mediaIcon={mediaIcon}
+              mediaIcons={mediaIcons}
               collapsible={settings.getIn(['collapsed', 'enabled'])}
               collapsed={isCollapsed}
               setCollapsed={setCollapsed}
@@ -764,7 +758,7 @@ class Status extends ImmutablePureComponent {
           <StatusContent
             status={status}
             media={media}
-            mediaIcon={mediaIcon}
+            mediaIcons={mediaIcons}
             expanded={isExpanded}
             onExpandedToggle={this.handleExpandedToggle}
             parseClick={parseClick}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 74bfd948e..ae67c6116 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -38,6 +38,7 @@ const messages = defineMessages({
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
   hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
+  edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
 });
 
 export default @injectIntl
@@ -147,11 +148,11 @@ class StatusActionBar extends ImmutablePureComponent {
 
   handleOpen = () => {
     let state = {...this.context.router.history.location.state};
-    if (state.mastodonModalOpen) {
-      this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
+    if (state.mastodonModalKey) {
+      this.context.router.history.replace(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
     } else {
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, state);
     }
   }
 
@@ -196,6 +197,7 @@ class StatusActionBar extends ImmutablePureComponent {
     const anonymousAccess    = !me;
     const mutingConversation = status.get('muted');
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
     const writtenByMe        = status.getIn(['account', 'id']) === me;
 
     let menu = [];
@@ -212,7 +214,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
     menu.push(null);
 
-    if (writtenByMe && publicStatus) {
+    if (writtenByMe && pinnableStatus) {
       menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
       menu.push(null);
     }
@@ -323,7 +325,9 @@ class StatusActionBar extends ImmutablePureComponent {
           </div>,
         ]}
 
-        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
+          <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
+        </a>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 34ff97305..1d32b35e5 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -69,8 +69,8 @@ export default class StatusContent extends React.PureComponent {
     expanded: PropTypes.bool,
     collapsed: PropTypes.bool,
     onExpandedToggle: PropTypes.func,
-    media: PropTypes.element,
-    mediaIcon: PropTypes.string,
+    media: PropTypes.node,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
     parseClick: PropTypes.func,
     disabled: PropTypes.bool,
     onUpdate: PropTypes.func,
@@ -197,7 +197,7 @@ export default class StatusContent extends React.PureComponent {
 
   onMentionClick = (mention, e) => {
     if (this.props.parseClick) {
-      this.props.parseClick(e, `/accounts/${mention.get('id')}`);
+      this.props.parseClick(e, `/@${mention.get('acct')}`);
     }
   }
 
@@ -205,7 +205,7 @@ export default class StatusContent extends React.PureComponent {
     hashtag = hashtag.replace(/^#/, '');
 
     if (this.props.parseClick) {
-      this.props.parseClick(e, `/timelines/tag/${hashtag}`);
+      this.props.parseClick(e, `/tags/${hashtag}`);
     }
   }
 
@@ -224,8 +224,8 @@ export default class StatusContent extends React.PureComponent {
     const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
 
     let element = e.target;
-    while (element) {
-      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName)) {
+    while (element !== e.currentTarget) {
+      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName) || element.getAttribute('role') === 'button') {
         return;
       }
       element = element.parentNode;
@@ -256,7 +256,7 @@ export default class StatusContent extends React.PureComponent {
     const {
       status,
       media,
-      mediaIcon,
+      mediaIcons,
       parseClick,
       disabled,
       tagLinks,
@@ -277,7 +277,7 @@ export default class StatusContent extends React.PureComponent {
 
       const mentionLinks = status.get('mentions').map(item => (
         <Permalink
-          to={`/accounts/${item.get('id')}`}
+          to={`/@${item.get('acct')}`}
           href={item.get('url')}
           key={item.get('id')}
           className='mention'
@@ -286,28 +286,37 @@ export default class StatusContent extends React.PureComponent {
         </Permalink>
       )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
 
-      const toggleText = hidden ? [
-        <FormattedMessage
-          id='status.show_more'
-          defaultMessage='Show more'
-          key='0'
-        />,
-        mediaIcon ? (
-          <Icon
-            fixedWidth
-            className='status__content__spoiler-icon'
-            id={mediaIcon}
-            aria-hidden='true'
-            key='1'
+      let toggleText = null;
+      if (hidden) {
+        toggleText = [
+          <FormattedMessage
+            id='status.show_more'
+            defaultMessage='Show more'
+            key='0'
+          />,
+        ];
+        if (mediaIcons) {
+          mediaIcons.forEach((mediaIcon, idx) => {
+            toggleText.push(
+              <Icon
+                fixedWidth
+                className='status__content__spoiler-icon'
+                id={mediaIcon}
+                aria-hidden='true'
+                key={`icon-${idx}`}
+              />,
+            );
+          });
+        }
+      } else {
+        toggleText = (
+          <FormattedMessage
+            id='status.show_less'
+            defaultMessage='Show less'
+            key='0'
           />
-        ) : null,
-      ] : [
-        <FormattedMessage
-          id='status.show_less'
-          defaultMessage='Show less'
-          key='0'
-        />,
-      ];
+        );
+      }
 
       if (hidden) {
         mentionsPlaceholder = <div>{mentionLinks}</div>;
diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js
index 06296e124..cc476139b 100644
--- a/app/javascript/flavours/glitch/components/status_header.js
+++ b/app/javascript/flavours/glitch/components/status_header.js
@@ -19,14 +19,14 @@ export default class StatusHeader extends React.PureComponent {
   };
 
   //  Handles clicks on account name/image
-  handleClick = (id, e) => {
+  handleClick = (acct, e) => {
     const { parseClick } = this.props;
-    parseClick(e, `/accounts/${id}`);
+    parseClick(e, `/@${acct}`);
   }
 
   handleAccountClick = (e) => {
     const { status } = this.props;
-    this.handleClick(status.getIn(['account', 'id']), e);
+    this.handleClick(status.getIn(['account', 'acct']), e);
   }
 
   //  Rendering.
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index f4d0a7405..e66947f4a 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -27,7 +27,7 @@ class StatusIcons extends React.PureComponent {
 
   static propTypes = {
     status: ImmutablePropTypes.map.isRequired,
-    mediaIcon: PropTypes.string,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
     collapsible: PropTypes.bool,
     collapsed: PropTypes.bool,
     directMessage: PropTypes.bool,
@@ -44,8 +44,8 @@ class StatusIcons extends React.PureComponent {
     }
   }
 
-  mediaIconTitleText () {
-    const { intl, mediaIcon } = this.props;
+  mediaIconTitleText (mediaIcon) {
+    const { intl } = this.props;
 
     switch (mediaIcon) {
       case 'link':
@@ -61,11 +61,24 @@ class StatusIcons extends React.PureComponent {
     }
   }
 
+  renderIcon (mediaIcon) {
+    return (
+      <Icon
+        fixedWidth
+        className='status__media-icon'
+        key={`media-icon--${mediaIcon}`}
+        id={mediaIcon}
+        aria-hidden='true'
+        title={this.mediaIconTitleText(mediaIcon)}
+      />
+    );
+  }
+
   //  Rendering.
   render () {
     const {
       status,
-      mediaIcon,
+      mediaIcons,
       collapsible,
       collapsed,
       directMessage,
@@ -90,15 +103,7 @@ class StatusIcons extends React.PureComponent {
             aria-hidden='true'
             title={intl.formatMessage(messages.localOnly)}
           />}
-        {mediaIcon ? (
-          <Icon
-            fixedWidth
-            className='status__media-icon'
-            id={mediaIcon}
-            aria-hidden='true'
-            title={this.mediaIconTitleText()}
-          />
-        ) : null}
+        { !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon)) }
         {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
         {collapsible ? (
           <IconButton
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index 60cc23f4b..9095e087e 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -18,7 +18,6 @@ export default class StatusList extends ImmutablePureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     isPartial: PropTypes.bool,
     hasMore: PropTypes.bool,
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index af6acdef9..5a00f232e 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -17,7 +17,7 @@ export default class StatusPrepend extends React.PureComponent {
 
   handleClick = (e) => {
     const { account, parseClick } = this.props;
-    parseClick(e, `/accounts/${account.get('id')}`);
+    parseClick(e, `/@${account.get('acct')}`);
   }
 
   Message = () => {
diff --git a/app/javascript/flavours/glitch/containers/admin_component.js b/app/javascript/flavours/glitch/containers/admin_component.js
new file mode 100644
index 000000000..64dabac8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/admin_component.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class AdminComponent extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    children: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { locale, children } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        {children}
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index bcdd9b54e..de8ea8ee2 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -23,14 +23,38 @@ store.dispatch(hydrateAction);
 // load custom emojis
 store.dispatch(fetchCustomEmojis());
 
+const createIdentityContext = state => ({
+  signedIn: !!state.meta.me,
+  accountId: state.meta.me,
+  accessToken: state.meta.access_token,
+});
+
 export default class Mastodon extends React.PureComponent {
 
   static propTypes = {
     locale: PropTypes.string.isRequired,
   };
 
+  static childContextTypes = {
+    identity: PropTypes.shape({
+      signedIn: PropTypes.bool.isRequired,
+      accountId: PropTypes.string,
+      accessToken: PropTypes.string,
+    }).isRequired,
+  };
+
+  identity = createIdentityContext(initialState);
+
+  getChildContext() {
+    return {
+      identity: this.identity,
+    };
+  }
+
   componentDidMount() {
-    this.disconnect = store.dispatch(connectUserStream());
+    if (this.identity.signedIn) {
+      this.disconnect = store.dispatch(connectUserStream());
+    }
   }
 
   componentWillUnmount () {
@@ -41,7 +65,7 @@ export default class Mastodon extends React.PureComponent {
   }
 
   shouldUpdateScroll (_, { location }) {
-    return !(location.state && location.state.mastodonModalOpen);
+    return !(location.state?.mastodonModalKey);
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js
index 8657b8064..1ddbc706b 100644
--- a/app/javascript/flavours/glitch/containers/media_container.js
+++ b/app/javascript/flavours/glitch/containers/media_container.js
@@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
 import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
 import MediaGallery from 'flavours/glitch/components/media_gallery';
 import Poll from 'flavours/glitch/components/poll';
-import Hashtag from 'flavours/glitch/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
 import ModalRoot from 'flavours/glitch/components/modal_root';
 import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
 import Video from 'flavours/glitch/features/video';
diff --git a/app/javascript/flavours/glitch/containers/scroll_container.js b/app/javascript/flavours/glitch/containers/scroll_container.js
new file mode 100644
index 000000000..d21ff6368
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/scroll_container.js
@@ -0,0 +1,18 @@
+import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
+
+// ScrollContainer is used to automatically scroll to the top when pushing a
+// new history state and remembering the scroll position when going back.
+// There are a few things we need to do differently, though.
+const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
+  // If the change is caused by opening a modal, do not scroll to top
+  return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
+};
+
+export default
+class ScrollContainer extends OriginalScrollContainer {
+
+  static defaultProps = {
+    shouldUpdateScroll: defaultShouldUpdateScroll,
+  };
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js
index 2d4cc7f49..23120d57e 100644
--- a/app/javascript/flavours/glitch/features/account/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -62,17 +62,17 @@ class ActionBar extends React.PureComponent {
 
         <div className='account__action-bar'>
           <div className='account__action-bar-links'>
-            <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
+            <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}`}>
               <FormattedMessage id='account.posts' defaultMessage='Posts' />
               <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
             </NavLink>
 
-            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/following`}>
               <FormattedMessage id='account.follows' defaultMessage='Follows' />
               <strong><FormattedNumber value={account.get('following_count')} /></strong>
             </NavLink>
 
-            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/followers`}>
               <FormattedMessage id='account.followers' defaultMessage='Followers' />
               <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong>
             </NavLink>
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index 2a43d1ed2..8df1bf4ca 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
 import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import Column from 'flavours/glitch/features/ui/components/column';
@@ -11,18 +11,29 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { getAccountGallery } from 'flavours/glitch/selectors';
 import MediaItem from './components/media_item';
 import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 import LoadMore from 'flavours/glitch/components/load_more';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import { openModal } from 'flavours/glitch/actions/modal';
 
-const mapStateToProps = (state, props) => ({
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  attachments: getAccountGallery(state, props.params.accountId),
-  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
-  hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
-  suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    isAccount: !!state.getIn(['accounts', accountId]),
+    attachments: getAccountGallery(state, accountId),
+    isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
+    hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+  };
+};
 
 class LoadMoreMedia extends ImmutablePureComponent {
 
@@ -50,7 +61,11 @@ export default @connect(mapStateToProps)
 class AccountGallery extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     attachments: ImmutablePropTypes.list.isRequired,
     isLoading: PropTypes.bool,
@@ -64,15 +79,30 @@ class AccountGallery extends ImmutablePureComponent {
     width: 323,
   };
 
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(expandAccountMediaTimeline(accountId));
+  }
+
   componentDidMount () {
-    this.props.dispatch(fetchAccount(this.props.params.accountId));
-    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
   }
 
@@ -96,7 +126,7 @@ class AccountGallery extends ImmutablePureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
+    this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
   };
 
   handleLoadOlder = e => {
@@ -104,11 +134,6 @@ class AccountGallery extends ImmutablePureComponent {
     this.handleScrollToBottom();
   }
 
-  shouldUpdateScroll = (prevRouterProps, { location }) => {
-    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
-    return !(location.state && location.state.mastodonModalOpen);
-  }
-
   setColumnRef = c => {
     this.column = c;
   }
@@ -165,9 +190,9 @@ class AccountGallery extends ImmutablePureComponent {
       <Column ref={this.setColumnRef}>
         <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
 
-        <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
+        <ScrollContainer scrollKey='account_gallery'>
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
-            <HeaderContainer accountId={this.props.params.accountId} />
+            <HeaderContainer accountId={this.props.accountId} />
 
             {suspended ? (
               <div className='empty-column-indicator'>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
index a6b57d331..e70f011b7 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -128,9 +128,9 @@ 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')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
           </div>
         )}
       </div>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
index fcaa7b494..308407e94 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
@@ -23,7 +23,7 @@ export default class MovedNote extends ImmutablePureComponent {
       e.preventDefault();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.to.get('id')}`, state);
+      this.context.router.history.push(`/@${this.props.to.get('acct')}`, state);
     }
 
     e.stopPropagation();
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index 0d24980a9..776687486 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
 import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
@@ -19,10 +19,19 @@ import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
 const emptyList = ImmutableList();
 
-const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
+const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
   const path = withReplies ? `${accountId}:with_replies` : accountId;
 
   return {
+    accountId,
     remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
     remoteUrl: state.getIn(['accounts', accountId, 'url']),
     isAccount: !!state.getIn(['accounts', accountId]),
@@ -46,7 +55,11 @@ export default @connect(mapStateToProps)
 class AccountTimeline extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     statusIds: ImmutablePropTypes.list,
     featuredStatusIds: ImmutablePropTypes.list,
@@ -60,25 +73,47 @@ class AccountTimeline extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    const { params: { accountId }, withReplies } = this.props;
+  _load () {
+    const { accountId, withReplies, dispatch } = this.props;
 
-    this.props.dispatch(fetchAccount(accountId));
-    this.props.dispatch(fetchAccountIdentityProofs(accountId));
+    dispatch(fetchAccount(accountId));
+    dispatch(fetchAccountIdentityProofs(accountId));
     if (!withReplies) {
-      this.props.dispatch(expandAccountFeaturedTimeline(accountId));
+      dispatch(expandAccountFeaturedTimeline(accountId));
+    }
+    dispatch(expandAccountTimeline(accountId, { withReplies }));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
-    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
   }
 
   componentWillReceiveProps (nextProps) {
+    const { dispatch } = this.props;
+
     if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
+      dispatch(fetchAccount(nextProps.params.accountId));
+      dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
       if (!nextProps.withReplies) {
-        this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
+        dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
       }
-      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
+      dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
     }
   }
 
@@ -87,7 +122,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.accountId, { maxId, withReplies: this.props.withReplies }));
   }
 
   setRef = c => {
@@ -131,7 +166,7 @@ class AccountTimeline extends ImmutablePureComponent {
         <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
 
         <StatusList
-          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          prepend={<HeaderContainer accountId={this.props.accountId} />}
           alwaysPrepend
           append={remoteMessage}
           scrollKey='account_timeline'
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 d4804a3c2..5dfc119c1 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -58,6 +58,7 @@ class ComposeForm extends ImmutablePureComponent {
     onPickEmoji: PropTypes.func,
     showSearch: PropTypes.bool,
     anyMedia: PropTypes.bool,
+    isInReply: PropTypes.bool,
     singleColumn: PropTypes.bool,
 
     advancedOptions: ImmutablePropTypes.map,
@@ -233,7 +234,7 @@ class ComposeForm extends ImmutablePureComponent {
     //  Caret/selection handling.
     if (focusDate !== prevProps.focusDate) {
       switch (true) {
-      case preselectDate !== prevProps.preselectDate && preselectOnReply:
+      case preselectDate !== prevProps.preselectDate && this.props.isInReply && preselectOnReply:
         selectionStart = text.search(/\s/) + 1;
         selectionEnd = text.length;
         break;
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
index 5b456b717..c6e4cc36b 100644
--- a/app/javascript/flavours/glitch/features/compose/components/header.js
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -87,7 +87,7 @@ class Header extends ImmutablePureComponent {
           <Link
             aria-label={intl.formatMessage(messages.home_timeline)}
             title={intl.formatMessage(messages.home_timeline)}
-            to='/timelines/home'
+            to='/home'
           ><Icon id='home' /></Link>
         ))}
         {renderForColumn('NOTIFICATIONS', (
@@ -106,14 +106,14 @@ class Header extends ImmutablePureComponent {
           <Link
             aria-label={intl.formatMessage(messages.community)}
             title={intl.formatMessage(messages.community)}
-            to='/timelines/public/local'
+            to='/public/local'
           ><Icon id='users' /></Link>
         ))}
         {renderForColumn('PUBLIC', (
           <Link
             aria-label={intl.formatMessage(messages.public)}
             title={intl.formatMessage(messages.public)}
-            to='/timelines/public'
+            to='/public'
           ><Icon id='globe' /></Link>
         ))}
         <a
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
index f6bfbdd1e..595ca5512 100644
--- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -15,13 +15,13 @@ export default class NavigationBar extends ImmutablePureComponent {
   render () {
     return (
       <div className='drawer--account'>
-        <Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+        <Permalink className='avatar' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
           <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
           <Avatar account={this.props.account} size={48} />
         </Permalink>
 
         <div className='navigation-bar__profile'>
-          <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <Permalink className='acct' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
             <strong>@{this.props.account.get('acct')}</strong>
           </Permalink>
 
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
index a0f86a06a..cbc1f35e5 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search_results.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import AccountContainer from 'flavours/glitch/containers/account_container';
 import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import Hashtag from 'flavours/glitch/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
 import Icon from 'flavours/glitch/components/icon';
 import { searchEnabled } from 'flavours/glitch/util/initial_state';
 import LoadMore from 'flavours/glitch/components/load_more';
@@ -71,7 +71,7 @@ class SearchResults extends ImmutablePureComponent {
       );
     } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
       statuses = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           <div className='search-results__info'>
@@ -87,7 +87,7 @@ class SearchResults extends ImmutablePureComponent {
     if (results.get('accounts') && results.get('accounts').size > 0) {
       count   += results.get('accounts').size;
       accounts = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
 
           {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
@@ -100,7 +100,7 @@ class SearchResults extends ImmutablePureComponent {
     if (results.get('statuses') && results.get('statuses').size > 0) {
       count   += results.get('statuses').size;
       statuses = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
@@ -113,7 +113,7 @@ class SearchResults extends ImmutablePureComponent {
     if (results.get('hashtags') && results.get('hashtags').size > 0) {
       count += results.get('hashtags').size;
       hashtags = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
 
           {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
@@ -131,11 +131,9 @@ class SearchResults extends ImmutablePureComponent {
           <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
         </header>
 
-        <div className='search-results__contents'>
-          {accounts}
-          {statuses}
-          {hashtags}
-        </div>
+        {accounts}
+        {statuses}
+        {hashtags}
       </div>
     );
   };
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..8eff8a36b 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
@@ -68,6 +68,7 @@ function mapStateToProps (state) {
     spoilersAlwaysOn: spoilersAlwaysOn,
     mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
     preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
+    isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
   };
 };
 
diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
index b4dcb4d56..2f0da48c8 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -27,6 +27,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.logoutMessage),
       confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
       onConfirm: () => logOut(),
     }));
   },
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
index f687fae99..f3ca4ce7b 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -1,7 +1,6 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
-import { undoUploadCompose } from 'flavours/glitch/actions/compose';
-import { openModal } from 'flavours/glitch/actions/modal';
+import { undoUploadCompose, initMediaEditModal } from 'flavours/glitch/actions/compose';
 import { submitCompose } from 'flavours/glitch/actions/compose';
 
 const mapStateToProps = (state, { id }) => ({
@@ -15,7 +14,7 @@ const mapDispatchToProps = dispatch => ({
   },
 
   onOpenFocalPoint: id => {
-    dispatch(openModal('FOCAL_POINT', { id }));
+    dispatch(initMediaEditModal(id));
   },
 
   onSubmit (router) {
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
index 98b48cd90..202d96676 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
@@ -104,7 +104,7 @@ class Conversation extends ImmutablePureComponent {
       markRead();
     }
 
-    this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
+    this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
   }
 
   handleMarkAsRead = () => {
@@ -163,7 +163,7 @@ class Conversation extends ImmutablePureComponent {
 
     menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
 
-    const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
+    const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
 
     const handlers = {
       reply: this.handleReply,
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js
index 9fe84c10b..2a3fd1ecf 100644
--- a/app/javascript/flavours/glitch/features/directory/components/account_card.js
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js
@@ -213,7 +213,7 @@ class AccountCard extends ImmutablePureComponent {
           <Permalink
             className='directory__card__bar__name'
             href={account.get('url')}
-            to={`/accounts/${account.get('id')}`}
+            to={`/@${account.get('acct')}`}
           >
             <Avatar account={account} size={48} />
             <DisplayName account={account} />
diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js
index 858a8fa55..cde5926e0 100644
--- a/app/javascript/flavours/glitch/features/directory/index.js
+++ b/app/javascript/flavours/glitch/features/directory/index.js
@@ -12,7 +12,7 @@ import AccountCard from './components/account_card';
 import RadioButton from 'flavours/glitch/components/radio_button';
 import classNames from 'classnames';
 import LoadMore from 'flavours/glitch/components/load_more';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 
 const messages = defineMessages({
   title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@@ -40,7 +40,6 @@ class Directory extends React.PureComponent {
     isLoading: PropTypes.bool,
     accountIds: ImmutablePropTypes.list.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
     multiColumn: PropTypes.bool,
@@ -125,7 +124,7 @@ class Directory extends React.PureComponent {
   }
 
   render () {
-    const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
+    const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
     const { order, local }  = this.getParams(this.props, this.state);
     const pinned = !!columnId;
 
@@ -163,7 +162,7 @@ class Directory extends React.PureComponent {
           multiColumn={multiColumn}
         />
 
-        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
+        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
       </Column>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js
index 046d03a9b..2c668da3e 100644
--- a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js
+++ b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js
@@ -66,7 +66,7 @@ class Account extends ImmutablePureComponent {
     return (
       <div className='account follow-recommendations-account'>
         <div className='account__wrapper'>
-          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
 
             <DisplayName account={account} />
diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/index.js b/app/javascript/flavours/glitch/features/follow_recommendations/index.js
index fee4d8757..d050e3cc7 100644
--- a/app/javascript/flavours/glitch/features/follow_recommendations/index.js
+++ b/app/javascript/flavours/glitch/features/follow_recommendations/index.js
@@ -68,7 +68,7 @@ class FollowRecommendations extends ImmutablePureComponent {
       }
     }));
 
-    router.history.push('/timelines/home');
+    router.history.push('/home');
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js
index eb9f3db7e..cbe7a1032 100644
--- a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js
+++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js
@@ -30,7 +30,7 @@ class AccountAuthorize extends ImmutablePureComponent {
     return (
       <div className='account-authorize__wrapper'>
         <div className='account-authorize'>
-          <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
+          <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
             <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
             <DisplayName account={account} />
           </Permalink>
diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js
index 25f7f05b4..978436dcc 100644
--- a/app/javascript/flavours/glitch/features/followers/index.js
+++ b/app/javascript/flavours/glitch/features/followers/index.js
@@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { debounce } from 'lodash';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import {
+  lookupAccount,
   fetchAccount,
   fetchFollowers,
   expandFollowers,
@@ -19,14 +20,25 @@ import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
-const mapStateToProps = (state, props) => ({
-  remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
-  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
-  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
-  isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
+  };
+};
 
 const RemoteHint = ({ url }) => (
   <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
@@ -40,7 +52,11 @@ export default @connect(mapStateToProps)
 class Followers extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     accountIds: ImmutablePropTypes.list,
     hasMore: PropTypes.bool,
@@ -51,32 +67,45 @@ class Followers extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    if (!this.props.accountIds) {
-      this.props.dispatch(fetchAccount(this.props.params.accountId));
-      this.props.dispatch(fetchFollowers(this.props.params.accountId));
-    }
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowers(accountId));
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
     }
   }
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    }
   }
 
   handleLoadMore = debounce(() => {
-    this.props.dispatch(expandFollowers(this.props.params.accountId));
+    this.props.dispatch(expandFollowers(this.props.accountId));
   }, 300, { leading: true });
 
   setRef = c => {
     this.column = c;
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   render () {
     const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
@@ -115,7 +144,7 @@ class Followers extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js
index 968829fd5..446a19894 100644
--- a/app/javascript/flavours/glitch/features/following/index.js
+++ b/app/javascript/flavours/glitch/features/following/index.js
@@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { debounce } from 'lodash';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import {
+  lookupAccount,
   fetchAccount,
   fetchFollowing,
   expandFollowing,
@@ -19,14 +20,25 @@ import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
-const mapStateToProps = (state, props) => ({
-  remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
-  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
-  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
-  isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
+  };
+};
 
 const RemoteHint = ({ url }) => (
   <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
@@ -40,7 +52,11 @@ export default @connect(mapStateToProps)
 class Following extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     accountIds: ImmutablePropTypes.list,
     hasMore: PropTypes.bool,
@@ -51,32 +67,45 @@ class Following extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    if (!this.props.accountIds) {
-      this.props.dispatch(fetchAccount(this.props.params.accountId));
-      this.props.dispatch(fetchFollowing(this.props.params.accountId));
-    }
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowing(accountId));
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
     }
   }
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    }
   }
 
   handleLoadMore = debounce(() => {
-    this.props.dispatch(expandFollowing(this.props.params.accountId));
+    this.props.dispatch(expandFollowing(this.props.accountId));
   }, 300, { leading: true });
 
   setRef = c => {
     this.column = c;
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   render () {
     const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
@@ -115,7 +144,7 @@ class Following extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
index 4eebe83ef..2f6d2de5c 100644
--- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
@@ -87,7 +87,7 @@ class Content extends ImmutablePureComponent {
   onMentionClick = (mention, e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+      this.context.router.history.push(`/@${mention.get('acct')}`);
     }
   }
 
@@ -96,14 +96,14 @@ class Content extends ImmutablePureComponent {
 
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+      this.context.router.history.push(`/tags/${hashtag}`);
     }
   }
 
   onStatusClick = (status, e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/statuses/${status.get('id')}`);
+      this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.js b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
index c60f78f7e..5158f6689 100644
--- a/app/javascript/flavours/glitch/features/getting_started/components/trends.js
+++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Hashtag from 'flavours/glitch/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
 import { FormattedMessage } from 'react-intl';
 
 export default class Trends extends ImmutablePureComponent {
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index b4549fdf8..56750fd88 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -107,7 +107,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
     const { fetchFollowRequests, multiColumn } = this.props;
 
     if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
-      this.context.router.history.replace('/timelines/home');
+      this.context.router.history.replace('/home');
       return;
     }
 
@@ -122,7 +122,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
 
     if (multiColumn) {
       if (!columns.find(item => item.get('id') === 'HOME')) {
-        navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />);
+        navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/home' />);
       }
 
       if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
@@ -130,16 +130,16 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
       }
 
       if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
-        navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />);
+        navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/public/local' />);
       }
 
       if (!columns.find(item => item.get('id') === 'PUBLIC')) {
-        navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />);
+        navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/public' />);
       }
     }
 
     if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
-      navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
+      navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/conversations' />);
     }
 
     if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) {
@@ -160,7 +160,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
       <div key='9'>
         <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
         {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list =>
-          <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+          <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
         )}
       </div>,
     ]);
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
index de1127b0d..142118cef 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
@@ -33,8 +33,8 @@ class ColumnSettings extends React.PureComponent {
   tags (mode) {
     let tags = this.props.settings.getIn(['tags', mode]) || [];
 
-    if (tags.toJSON) {
-      return tags.toJSON();
+    if (tags.toJS) {
+      return tags.toJS();
     } else {
       return tags;
     }
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
index de1db692d..1cf527573 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
@@ -11,21 +11,22 @@ const mapStateToProps = (state, { columnId }) => {
     return {};
   }
 
-  return { settings: columns.get(index).get('params') };
+  return {
+    settings: columns.get(index).get('params'),
+    onLoad (value) {
+      return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
+        return (response.data.hashtags || []).map((tag) => {
+          return { value: tag.name, label: `#${tag.name}` };
+        });
+      });
+    },
+  };
 };
 
 const mapDispatchToProps = (dispatch, { columnId }) => ({
   onChange (key, value) {
     dispatch(changeColumnParams(columnId, key, value));
   },
-
-  onLoad (value) {
-    return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
-      return (response.data.hashtags || []).map((tag) => {
-        return { value: tag.name, label: `#${tag.name}` };
-      });
-    });
-  },
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/lists/index.js b/app/javascript/flavours/glitch/features/lists/index.js
index e384f301b..b92389d82 100644
--- a/app/javascript/flavours/glitch/features/lists/index.js
+++ b/app/javascript/flavours/glitch/features/lists/index.js
@@ -73,7 +73,7 @@ class Lists extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {lists.map(list =>
-            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
+            <ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index c01a21e3b..95250c6ed 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -27,11 +27,12 @@ export default class ColumnSettings extends React.PureComponent {
   render () {
     const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
 
-    const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
+    const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
+    const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
-    const alertStr  = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
-    const showStr   = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
-    const soundStr  = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
+    const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
+    const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
 
     const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
     const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
@@ -58,11 +59,11 @@ export default class ColumnSettings extends React.PureComponent {
 
         <div role='group' aria-labelledby='notifications-unread-markers'>
           <span id='notifications-unread-markers' className='column-settings__section'>
-            <FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' />
+            <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
           </span>
 
           <div className='column-settings__row'>
-            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} />
+            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} />
           </div>
         </div>
 
@@ -72,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent {
           </span>
 
           <div className='column-settings__row'>
-            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
+            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
           </div>
         </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js
index 0d3162fc9..b8fad19d0 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -39,7 +39,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
 
   handleOpenProfile = () => {
     const { notification } = this.props;
-    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+    this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
   }
 
   handleMention = e => {
@@ -70,7 +70,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
         className='notification__display-name'
         href={account.get('url')}
         title={account.get('acct')}
-        to={`/accounts/${account.get('id')}`}
+        to={`/@${account.get('acct')}`}
         dangerouslySetInnerHTML={{ __html: displayName }}
       /></bdi>
     );
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
index f351c1035..69b92a06f 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
@@ -45,7 +45,7 @@ class FollowRequest extends ImmutablePureComponent {
 
   handleOpenProfile = () => {
     const { notification } = this.props;
-    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+    this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
   }
 
   handleMention = e => {
@@ -89,7 +89,7 @@ class FollowRequest extends ImmutablePureComponent {
         className='notification__display-name'
         href={account.get('url')}
         title={account.get('acct')}
-        to={`/accounts/${account.get('id')}`}
+        to={`/@${account.get('acct')}`}
         dangerouslySetInnerHTML={{ __html: displayName }}
       /></bdi>
     );
@@ -111,7 +111,7 @@ class FollowRequest extends ImmutablePureComponent {
 
           <div className='account'>
             <div className='account__wrapper'>
-              <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+              <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
                 <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
                 <DisplayName account={account} />
               </Permalink>
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 6fc951e37..075e729b1 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -99,7 +99,6 @@ class Notifications extends React.PureComponent {
     notifications: ImmutablePropTypes.list.isRequired,
     showFilterBar: PropTypes.bool.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     intl: PropTypes.object.isRequired,
     isLoading: PropTypes.bool,
     isUnread: PropTypes.bool,
@@ -220,7 +219,7 @@ class Notifications extends React.PureComponent {
   }
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
+    const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const { notifCleaning, notifCleaningActive } = this.props;
     const { animatingNCD } = this.state;
     const pinned = !!columnId;
@@ -273,7 +272,6 @@ class Notifications extends React.PureComponent {
         onLoadPending={this.handleLoadPending}
         onScrollToTop={this.handleScrollToTop}
         onScroll={this.handleScroll}
-        shouldUpdateScroll={shouldUpdateScroll}
         bindToDocument={!multiColumn}
       >
         {scrollableContent}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
index fcb2df527..e01d277a1 100644
--- a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
@@ -116,9 +116,13 @@ class Footer extends ImmutablePureComponent {
       return;
     }
 
-    const { status } = this.props;
+    const { status, onClose } = this.props;
 
-    router.history.push(`/statuses/${status.get('id')}`);
+    if (onClose) {
+      onClose();
+    }
+
+    router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
index 28526ca88..26f2da374 100644
--- a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
@@ -34,7 +34,7 @@ class Header extends ImmutablePureComponent {
 
     return (
       <div className='picture-in-picture__header'>
-        <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'>
+        <Link to={`/@${account.get('acct')}/${statusId}`} className='picture-in-picture__header__account'>
           <Avatar account={account} size={36} />
           <DisplayName account={account} />
         </Link>
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 6ed5f3865..eb4583026 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -146,6 +146,7 @@ class ActionBar extends React.PureComponent {
     const { status, intl } = this.props;
 
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
     const mutingConversation = status.get('muted');
     const writtenByMe        = status.getIn(['account', 'id']) === me;
 
@@ -158,7 +159,7 @@ class ActionBar extends React.PureComponent {
     }
 
     if (writtenByMe) {
-      if (publicStatus) {
+      if (pinnableStatus) {
         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
         menu.push(null);
       }
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 4cc1d1af5..4b3a6aaaa 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -7,7 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content';
 import MediaGallery from 'flavours/glitch/components/media_gallery';
 import AttachmentList from 'flavours/glitch/components/attachment_list';
 import { Link } from 'react-router-dom';
-import { FormattedDate } from 'react-intl';
+import { injectIntl, FormattedDate, FormattedMessage } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from 'flavours/glitch/features/video';
@@ -20,7 +20,8 @@ import Icon from 'flavours/glitch/components/icon';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
 import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
-export default class DetailedStatus extends ImmutablePureComponent {
+export default @injectIntl
+class DetailedStatus extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -40,6 +41,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     showMedia: PropTypes.bool,
     usingPiP: PropTypes.bool,
     onToggleMediaVisibility: PropTypes.func,
+    intl: PropTypes.object.isRequired,
   };
 
   state = {
@@ -51,7 +53,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
       e.preventDefault();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
     }
 
     e.stopPropagation();
@@ -111,7 +113,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
-    const { expanded, onToggleHidden, settings, usingPiP } = this.props;
+    const { expanded, onToggleHidden, settings, usingPiP, intl } = this.props;
     const outerStyle = { boxSizing: 'border-box' };
     const { compact } = this.props;
 
@@ -119,30 +121,32 @@ export default class DetailedStatus extends ImmutablePureComponent {
       return null;
     }
 
-    let media           = null;
-    let mediaIcon       = null;
+    let media           = [];
+    let mediaIcons      = [];
     let applicationLink = '';
     let reblogLink = '';
     let reblogIcon = 'retweet';
     let favouriteLink = '';
+    let edited = '';
 
     if (this.props.measureHeight) {
       outerStyle.height = `${this.state.height}px`;
     }
 
     if (status.get('poll')) {
-      media = <PollContainer pollId={status.get('poll')} />;
-      mediaIcon = 'tasks';
-    } else if (usingPiP) {
-      media = <PictureInPicturePlaceholder />;
-      mediaIcon = 'video-camera';
+      media.push(<PollContainer pollId={status.get('poll')} />);
+      mediaIcons.push('tasks');
+    }
+    if (usingPiP) {
+      media.push(<PictureInPicturePlaceholder />);
+      mediaIcons.push('video-camera');
     } else if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
-        media = <AttachmentList media={status.get('media_attachments')} />;
+        media.push(<AttachmentList media={status.get('media_attachments')} />);
       } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Audio
             src={attachment.get('url')}
             alt={attachment.get('description')}
@@ -152,12 +156,12 @@ export default class DetailedStatus extends ImmutablePureComponent {
             foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
             accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
             height={150}
-          />
+          />,
         );
-        mediaIcon = 'music';
+        mediaIcons.push('music');
       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
-        media = (
+        media.push(
           <Video
             preview={attachment.get('preview_url')}
             frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
@@ -173,11 +177,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
             autoplay
             visible={this.props.showMedia}
             onToggleVisibility={this.props.onToggleMediaVisibility}
-          />
+          />,
         );
-        mediaIcon = 'video-camera';
+        mediaIcons.push('video-camera');
       } else {
-        media = (
+        media.push(
           <MediaGallery
             standalone
             sensitive={status.get('sensitive')}
@@ -188,13 +192,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
             onOpenMedia={this.props.onOpenMedia}
             visible={this.props.showMedia}
             onToggleVisibility={this.props.onToggleMediaVisibility}
-          />
+          />,
         );
-        mediaIcon = 'picture-o';
+        mediaIcons.push('picture-o');
       }
     } else if (status.get('card')) {
-      media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
-      mediaIcon = 'link';
+      media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
+      mediaIcons.push('link');
     }
 
     if (status.get('application')) {
@@ -215,7 +219,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
       reblogLink = (
         <React.Fragment>
           <React.Fragment> · </React.Fragment>
-          <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+          <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
             <Icon id={reblogIcon} />
             <span className='detailed-status__reblogs'>
               <AnimatedNumber value={status.get('reblogs_count')} />
@@ -239,7 +243,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     if (this.context.router) {
       favouriteLink = (
-        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+        <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
           <Icon id='star' />
           <span className='detailed-status__favorites'>
             <AnimatedNumber value={status.get('favourites_count')} />
@@ -257,6 +261,15 @@ export default class DetailedStatus extends ImmutablePureComponent {
       );
     }
 
+    if (status.get('edited_at')) {
+      edited = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
+        </React.Fragment>
+      );
+    }
+
     return (
       <div style={outerStyle}>
         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
@@ -268,7 +281,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <StatusContent
             status={status}
             media={media}
-            mediaIcon={mediaIcon}
+            mediaIcons={mediaIcons}
             expanded={expanded}
             collapsed={false}
             onExpandedToggle={onToggleHidden}
@@ -282,7 +295,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <div className='detailed-status__meta'>
             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-            </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+            </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
           </div>
         </div>
       </div>
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 513a6227f..12ea407ad 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -32,7 +32,7 @@ import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
 import { initBoostModal } from 'flavours/glitch/actions/boosts';
 import { makeGetStatus } from 'flavours/glitch/selectors';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 import ColumnBackButton from 'flavours/glitch/components/column_back_button';
 import ColumnHeader from '../../components/column_header';
 import StatusContainer from 'flavours/glitch/containers/status_container';
@@ -70,7 +70,7 @@ const makeMapStateToProps = () => {
     ancestorsIds = ancestorsIds.withMutations(mutable => {
       let id = statusId;
 
-      while (id) {
+      while (id && !mutable.includes(id)) {
         mutable.unshift(id);
         id = inReplyTos.get(id);
       }
@@ -88,7 +88,7 @@ const makeMapStateToProps = () => {
     const ids = [statusId];
 
     while (ids.length > 0) {
-      let id        = ids.shift();
+      let id        = ids.pop();
       const replies = contextReplies.get(id);
 
       if (statusId !== id) {
@@ -97,7 +97,7 @@ const makeMapStateToProps = () => {
 
       if (replies) {
         replies.reverse().forEach(reply => {
-          ids.unshift(reply);
+          if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
         });
       }
     }
@@ -405,7 +405,7 @@ class Status extends ImmutablePureComponent {
   handleHotkeyOpenProfile = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
   }
 
   handleMoveUp = id => {
@@ -507,11 +507,6 @@ class Status extends ImmutablePureComponent {
     this.setState({ fullscreen: isFullscreen() });
   }
 
-  shouldUpdateScroll = (prevRouterProps, { location }) => {
-    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
-    return !(location.state && location.state.mastodonModalOpen);
-  }
-
   render () {
     let ancestors, descendants;
     const { setExpansion } = this;
@@ -562,7 +557,7 @@ class Status extends ImmutablePureComponent {
           )}
         />
 
-        <ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
+        <ScrollContainer scrollKey='thread'>
           <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
             {ancestors}
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
index c4af25599..c23aec5ee 100644
--- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
@@ -69,7 +69,7 @@ class BoostModal extends ImmutablePureComponent {
       this.props.onClose();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index d4e0bedac..e39a31e5d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -47,7 +47,7 @@ const componentMap = {
   'DIRECTORY': Directory,
 };
 
-const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started|^\/start/);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/search|^\/getting-started|^\/start/);
 
 const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
@@ -216,7 +216,7 @@ class ColumnsArea extends ImmutablePureComponent {
     const columnIndex = getIndex(this.context.router.history.location.pathname);
 
     if (singleColumn) {
-      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
+      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
 
       const content = columnIndex !== -1 ? (
         <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}>
diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
index 47a49c0c7..a665b9fb1 100644
--- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
@@ -13,16 +13,23 @@ class ConfirmationModal extends React.PureComponent {
     onConfirm: PropTypes.func.isRequired,
     secondary: PropTypes.string,
     onSecondary: PropTypes.func,
+    closeWhenConfirm: PropTypes.bool,
     onDoNotAsk: PropTypes.func,
     intl: PropTypes.object.isRequired,
   };
 
+  static defaultProps = {
+    closeWhenConfirm: true,
+  };
+
   componentDidMount() {
     this.button.focus();
   }
 
   handleClick = () => {
-    this.props.onClose();
+    if (this.props.closeWhenConfirm) {
+      this.props.onClose();
+    }
     this.props.onConfirm();
     if (this.props.onDoNotAsk && this.doNotAskCheckbox.checked) {
       this.props.onDoNotAsk();
diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
index ea1d7876e..9b7f9d1fb 100644
--- a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
@@ -49,7 +49,7 @@ class FavouriteModal extends ImmutablePureComponent {
       this.props.onClose();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index b7ec63333..5a4baa5a1 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { connect } from 'react-redux';
 import classNames from 'classnames';
-import { changeUploadCompose, uploadThumbnail } from 'flavours/glitch/actions/compose';
+import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from 'flavours/glitch/actions/compose';
 import { getPointerPosition } from 'flavours/glitch/features/video';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import IconButton from 'flavours/glitch/components/icon_button';
@@ -27,14 +27,22 @@ import { assetHost } from 'flavours/glitch/util/config';
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
   apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+  applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
   placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
   chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
+  discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
+  discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
 });
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
   account: state.getIn(['accounts', me]),
   isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
+  description: state.getIn(['compose', 'media_modal', 'description']),
+  focusX: state.getIn(['compose', 'media_modal', 'focusX']),
+  focusY: state.getIn(['compose', 'media_modal', 'focusY']),
+  dirty: state.getIn(['compose', 'media_modal', 'dirty']),
+  is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
 });
 
 const mapDispatchToProps = (dispatch, { id }) => ({
@@ -43,6 +51,14 @@ const mapDispatchToProps = (dispatch, { id }) => ({
     dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
   },
 
+  onChangeDescription: (description) => {
+    dispatch(onChangeMediaDescription(description));
+  },
+
+  onChangeFocus: (focusX, focusY) => {
+    dispatch(onChangeMediaFocus(focusX, focusY));
+  },
+
   onSelectThumbnail: files => {
     dispatch(uploadThumbnail(id, files[0]));
   },
@@ -83,8 +99,8 @@ class ImageLoader extends React.PureComponent {
 
 }
 
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
+export default @connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })
+@(component => injectIntl(component, { withRef: true }))
 class FocalPointModal extends ImmutablePureComponent {
 
   static propTypes = {
@@ -92,34 +108,21 @@ class FocalPointModal extends ImmutablePureComponent {
     account: ImmutablePropTypes.map.isRequired,
     isUploadingThumbnail: PropTypes.bool,
     onSave: PropTypes.func.isRequired,
+    onChangeDescription: PropTypes.func.isRequired,
+    onChangeFocus: PropTypes.func.isRequired,
     onSelectThumbnail: PropTypes.func.isRequired,
     onClose: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
   state = {
-    x: 0,
-    y: 0,
-    focusX: 0,
-    focusY: 0,
     dragging: false,
-    description: '',
     dirty: false,
     progress: 0,
     loading: true,
     ocrStatus: '',
   };
 
-  componentWillMount () {
-    this.updatePositionFromMedia(this.props.media);
-  }
-
-  componentWillReceiveProps (nextProps) {
-    if (this.props.media.get('id') !== nextProps.media.get('id')) {
-      this.updatePositionFromMedia(nextProps.media);
-    }
-  }
-
   componentWillUnmount () {
     document.removeEventListener('mousemove', this.handleMouseMove);
     document.removeEventListener('mouseup', this.handleMouseUp);
@@ -164,54 +167,37 @@ class FocalPointModal extends ImmutablePureComponent {
     const focusX   = (x - .5) *  2;
     const focusY   = (y - .5) * -2;
 
-    this.setState({ x, y, focusX, focusY, dirty: true });
-  }
-
-  updatePositionFromMedia = media => {
-    const focusX      = media.getIn(['meta', 'focus', 'x']);
-    const focusY      = media.getIn(['meta', 'focus', 'y']);
-    const description = media.get('description') || '';
-
-    if (focusX && focusY) {
-      const x = (focusX /  2) + .5;
-      const y = (focusY / -2) + .5;
-
-      this.setState({
-        x,
-        y,
-        focusX,
-        focusY,
-        description,
-        dirty: false,
-      });
-    } else {
-      this.setState({
-        x: 0.5,
-        y: 0.5,
-        focusX: 0,
-        focusY: 0,
-        description,
-        dirty: false,
-      });
-    }
+    this.props.onChangeFocus(focusX, focusY);
   }
 
   handleChange = e => {
-    this.setState({ description: e.target.value, dirty: true });
+    this.props.onChangeDescription(e.target.value);
   }
 
   handleKeyDown = (e) => {
     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       e.stopPropagation();
-      this.setState({ description: e.target.value, dirty: true });
+      this.props.onChangeDescription(e.target.value);
       this.handleSubmit();
     }
   }
 
   handleSubmit = () => {
-    this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
-    this.props.onClose();
+    this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
+  }
+
+  getCloseConfirmationMessage = () => {
+    const { intl, dirty } = this.props;
+
+    if (dirty) {
+      return {
+        message: intl.formatMessage(messages.discardMessage),
+        confirm: intl.formatMessage(messages.discardConfirm),
+      };
+    } else {
+      return null;
+    }
   }
 
   setRef = c => {
@@ -257,7 +243,8 @@ class FocalPointModal extends ImmutablePureComponent {
         await worker.loadLanguage('eng');
         await worker.initialize('eng');
         const { data: { text } } = await worker.recognize(media_url);
-        this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false });
+        this.setState({ detecting: false });
+        this.props.onChangeDescription(removeExtraLineBreaks(text));
         await worker.terminate();
       })().catch((e) => {
         if (refreshCache) {
@@ -274,7 +261,6 @@ class FocalPointModal extends ImmutablePureComponent {
 
   handleThumbnailChange = e => {
     if (e.target.files.length > 0) {
-      this.setState({ dirty: true });
       this.props.onSelectThumbnail(e.target.files);
     }
   }
@@ -288,8 +274,10 @@ class FocalPointModal extends ImmutablePureComponent {
   }
 
   render () {
-    const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
-    const { x, y, dragging, description, dirty, detecting, progress, ocrStatus } = this.state;
+    const { media, intl, account, onClose, isUploadingThumbnail, description, focusX, focusY, dirty, is_changing_upload } = this.props;
+    const { dragging, detecting, progress, ocrStatus } = this.state;
+    const x = (focusX /  2) + .5;
+    const y = (focusY / -2) + .5;
 
     const width  = media.getIn(['meta', 'original', 'width']) || null;
     const height = media.getIn(['meta', 'original', 'height']) || null;
@@ -344,7 +332,7 @@ class FocalPointModal extends ImmutablePureComponent {
                     accept='image/png,image/jpeg'
                     onChange={this.handleThumbnailChange}
                     style={{ display: 'none' }}
-                    disabled={isUploadingThumbnail}
+                    disabled={isUploadingThumbnail || is_changing_upload}
                   />
                 </label>
 
@@ -363,7 +351,7 @@ class FocalPointModal extends ImmutablePureComponent {
                 value={detecting ? '…' : description}
                 onChange={this.handleChange}
                 onKeyDown={this.handleKeyDown}
-                disabled={detecting}
+                disabled={detecting || is_changing_upload}
                 autoFocus
               />
 
@@ -373,11 +361,11 @@ class FocalPointModal extends ImmutablePureComponent {
             </div>
 
             <div className='setting-text__toolbar'>
-              <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
+              <button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
               <CharacterCounter max={1500} text={detecting ? '' : description} />
             </div>
 
-            <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
+            <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} />
           </div>
 
           <div className='focal-point-modal__content'>
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 533d9e09b..5d566e516 100644
--- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -3,7 +3,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
-import { invitesEnabled, version, limitedFederationMode, repository, source_url } from 'flavours/glitch/util/initial_state';
+import { invitesEnabled, limitedFederationMode, version, repository, source_url } from 'flavours/glitch/util/initial_state';
 import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links';
 import { logOut } from 'flavours/glitch/util/log_out';
 import { openModal } from 'flavours/glitch/actions/modal';
@@ -18,6 +18,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.logoutMessage),
       confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
       onConfirm: () => logOut(),
     }));
   },
diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
index 354e35027..e61234283 100644
--- a/app/javascript/flavours/glitch/features/ui/components/list_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
@@ -46,7 +46,7 @@ class ListPanel extends ImmutablePureComponent {
         <hr />
 
         {lists.map(list => (
-          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
+          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/lists/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
         ))}
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
index a8cbb837e..6974aab26 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -112,15 +112,6 @@ class MediaModal extends ImmutablePureComponent {
     }));
   };
 
-  handleStatusClick = e => {
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      e.preventDefault();
-      this.context.router.history.push(`/statuses/${this.props.statusId}`);
-    }
-
-    this._sendBackgroundColor();
-  }
-
   componentDidUpdate (prevProps, prevState) {
     if (prevState.index !== this.state.index) {
       this._sendBackgroundColor();
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
index 0fd70de34..62bb167a0 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -59,12 +59,8 @@ export default class ModalRoot extends React.PureComponent {
     backgroundColor: null,
   };
 
-  getSnapshotBeforeUpdate () {
-    return { visible: !!this.props.type };
-  }
-
-  componentDidUpdate (prevProps, prevState, { visible }) {
-    if (visible) {
+  componentDidUpdate () {
+    if (!!this.props.type) {
       document.body.classList.add('with-modals--active');
       document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
     } else {
@@ -87,16 +83,33 @@ export default class ModalRoot extends React.PureComponent {
     return <BundleModalError {...props} onClose={onClose} />;
   }
 
+  handleClose = () => {
+    const { onClose } = this.props;
+    let message = null;
+    try {
+      message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.();
+    } catch (_) {
+      // injectIntl defines `getWrappedInstance` but errors out if `withRef`
+      // isn't set.
+      // This would be much smoother with react-intl 3+ and `forwardRef`.
+    }
+    onClose(message);
+  }
+
+  setModalRef = (c) => {
+    this._modal = c;
+  }
+
   render () {
-    const { type, props, onClose } = this.props;
+    const { type, props } = this.props;
     const { backgroundColor } = this.state;
     const visible = !!type;
 
     return (
-      <Base backgroundColor={backgroundColor} onClose={onClose} noEsc={props ? props.noEsc : false}>
+      <Base backgroundColor={backgroundColor} onClose={this.handleClose} noEsc={props ? props.noEsc : false}>
         {visible && (
           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-            {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={onClose} />}
+            {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
           </BundleContainer>
         )}
       </Base>
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
index 50e7d5c48..2dcd535ca 100644
--- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
@@ -11,12 +11,12 @@ import TrendsContainer from 'flavours/glitch/features/getting_started/containers
 
 const NavigationPanel = ({ onOpenSettings }) => (
   <div className='navigation-panel'>
-    <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
     <FollowRequestsNavLink />
-    <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
-    <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
-    <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
+    <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
     {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
     <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
index 8f993520a..82a1ee4a4 100644
--- a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
@@ -10,7 +10,7 @@ import ComposeForm from 'flavours/glitch/features/compose/components/compose_for
 import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar';
 import Search from 'flavours/glitch/features/compose/components/search';
 import ColumnHeader from './column_header';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me, source_url } from 'flavours/glitch/util/initial_state';
 
 const noop = () => { };
 
@@ -79,7 +79,7 @@ const PageThree = ({ intl, myAccount }) => (
       </div>
     </div>
 
-    <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }} /></p>
+    <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }} /></p>
     <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p>
   </div>
 );
@@ -130,7 +130,7 @@ const PageSix = ({ admin, domain }) => {
   if (admin) {
     adminSection = (
       <p>
-        <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} />
+        <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/@${admin.get('acct')}`}>@{admin.get('acct')}</Permalink> }} />
         <br />
         <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{ domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }} />
       </p>
diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
index a67405215..55cc84f5e 100644
--- a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
+++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
@@ -8,10 +8,10 @@ import Icon from 'flavours/glitch/components/icon';
 import NotificationsCounterIcon from './notifications_counter_icon';
 
 export const links = [
-  <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
   <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
-  <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
   <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
   <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
 ];
diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
index f074002e4..039aabd8a 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
@@ -1,15 +1,25 @@
 import { connect } from 'react-redux';
-import { closeModal } from 'flavours/glitch/actions/modal';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
 import ModalRoot from '../components/modal_root';
 
 const mapStateToProps = state => ({
-  type: state.get('modal').modalType,
-  props: state.get('modal').modalProps,
+  type: state.getIn(['modal', 0, 'modalType'], null),
+  props: state.getIn(['modal', 0, 'modalProps'], {}),
 });
 
 const mapDispatchToProps = dispatch => ({
-  onClose () {
-    dispatch(closeModal());
+  onClose (confirmationMessage) {
+    if (confirmationMessage) {
+      dispatch(
+        openModal('CONFIRM', {
+          message: confirmationMessage.message,
+          confirm: confirmationMessage.confirm,
+          onConfirm: () => dispatch(closeModal()),
+        }),
+      );
+    } else {
+      dispatch(closeModal());
+    }
   },
 });
 
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 1149eb14e..7ca1adf7c 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -78,6 +78,7 @@ const mapStateToProps = state => ({
   hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
   moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]),
   firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
+  username: state.getIn(['accounts', me, 'username']),
 });
 
 const keyMap = {
@@ -190,7 +191,7 @@ class SwitchingColumnsArea extends React.PureComponent {
   render () {
     const { children, navbarUnder } = this.props;
     const singleColumn = this.state.mobile;
-    const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
+    const redirect = singleColumn ? <Redirect from='/' to='/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
 
     return (
       <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn} navbarUnder={navbarUnder}>
@@ -198,33 +199,40 @@ class SwitchingColumnsArea extends React.PureComponent {
           {redirect}
           <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
           <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
-          <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
-          <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
-          <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
-          <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
-          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
-          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
 
+          <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
+          <WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
+          <WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
+          <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
+          <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
           <WrappedRoute path='/notifications' component={Notifications} content={children} />
           <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+
           <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
           <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
           <WrappedRoute path='/search' component={Search} content={children} />
-          <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-
-          <WrappedRoute path='/statuses/new' component={Compose} content={children} />
+          <WrappedRoute path='/directory' component={Directory} content={children} />
+          <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
+
+          <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
+          <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <WrappedRoute path={['/@:acct/followers', '/accounts/:id/followers']} component={Followers} content={children} />
+          <WrappedRoute path={['/@:acct/following', '/accounts/:id/following']} component={Following} content={children} />
+          <WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
+          <WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
+
+          {/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
+          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
           <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
           <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
           <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
 
-          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
-          <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
-          <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
-          <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
-
           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
           <WrappedRoute path='/blocks' component={Blocks} content={children} />
           <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
@@ -265,6 +273,7 @@ class UI extends React.Component {
     showFaviconBadge: PropTypes.bool,
     moved: PropTypes.map,
     firstLaunch: PropTypes.bool,
+    username: PropTypes.string,
   };
 
   state = {
@@ -517,7 +526,7 @@ class UI extends React.Component {
   }
 
   handleHotkeyGoToHome = () => {
-    this.props.history.push('/timelines/home');
+    this.props.history.push('/home');
   }
 
   handleHotkeyGoToNotifications = () => {
@@ -525,15 +534,15 @@ class UI extends React.Component {
   }
 
   handleHotkeyGoToLocal = () => {
-    this.props.history.push('/timelines/public/local');
+    this.props.history.push('/public/local');
   }
 
   handleHotkeyGoToFederated = () => {
-    this.props.history.push('/timelines/public');
+    this.props.history.push('/public');
   }
 
   handleHotkeyGoToDirect = () => {
-    this.props.history.push('/timelines/direct');
+    this.props.history.push('/conversations');
   }
 
   handleHotkeyGoToStart = () => {
@@ -549,7 +558,7 @@ class UI extends React.Component {
   }
 
   handleHotkeyGoToProfile = () => {
-    this.props.history.push(`/accounts/${me}`);
+    this.props.history.push(`/@${this.props.username}`);
   }
 
   handleHotkeyGoToBlocked = () => {
@@ -616,7 +625,7 @@ class UI extends React.Component {
               id='moved_to_warning'
               defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.'
               values={{ moved_to_link: (
-                <PermaLink href={moved.get('url')} to={`/accounts/${moved.get('id')}`}>
+                <PermaLink href={moved.get('url')} to={`/@${moved.get('acct')}`}>
                   @{moved.get('acct')}
                 </PermaLink>
               )}}
diff --git a/app/javascript/flavours/glitch/locales/ja.js b/app/javascript/flavours/glitch/locales/ja.js
index c323956c6..0ca5e5fc7 100644
--- a/app/javascript/flavours/glitch/locales/ja.js
+++ b/app/javascript/flavours/glitch/locales/ja.js
@@ -7,6 +7,7 @@ const messages = {
   'layout.desktop': 'デスクトップ',
   'layout.single': 'モバイル',
   'navigation_bar.app_settings': 'アプリ設定',
+  'navigation_bar.featured_users': '紹介しているアカウント',
   'getting_started.onboarding': '解説を表示',
   'onboarding.page_one.federation': '{domain}はMastodonのインスタンスです。Mastodonとは、独立したサーバが連携して作るソーシャルネットワークです。これらのサーバーをインスタンスと呼びます。',
   'onboarding.page_one.welcome': '{domain}へようこそ!',
@@ -20,7 +21,7 @@ const messages = {
   'settings.auto_collapse_reblogs': 'ブースト',
   'settings.auto_collapse_replies': '返信',
   'settings.close': '閉じる',
-  'settings.collapsed_statuses': 'トゥート',
+  'settings.collapsed_statuses': 'トゥート折りたたみ',
   'settings.confirm_missing_media_description': '画像に対する補助記載がないときに投稿前の警告を表示する',
   'settings.content_warnings': 'コンテンツワーニング',
   'settings.content_warnings_filter': '説明に指定した文字が含まれているものを自動で展開しないようにする',
@@ -53,12 +54,15 @@ const messages = {
   'status.collapse': '折りたたむ',
   'status.uncollapse': '折りたたみを解除',
 
-  'confirmations.missing_media_description.message': '少なくとも1つの画像に視聴覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。',
+  'confirmations.missing_media_description.message': '少なくとも1つの画像に視覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。',
   'confirmations.missing_media_description.confirm': 'このまま投稿',
+  'confirmations.missing_media_description.edit': 'メディアを編集',
 
   'favourite_modal.combo': '次からは {combo} を押せば、これをスキップできます。',
 
   'home.column_settings.show_direct': 'DMを表示',
+  'home.column_settings.advanced': '高度',
+  'home.column_settings.filter_regex': '正規表現でフィルター',
 
   'notification.markForDeletion': '選択',
   'notifications.clear': '通知を全てクリアする',
@@ -71,8 +75,8 @@ const messages = {
   'notification_purge.btn_apply': '選択したものを\n削除',
 
   'compose.attach.upload': 'ファイルをアップロード',
-  'compose.attach.doodle': '落書きをする',
-  'compose.attach': 'アタッチ...',
+  'compose.attach.doodle': 'お絵描きをする',
+  'compose.attach': '添付...',
 
   'advanced_options.local-only.short': 'ローカル限定',
   'advanced_options.local-only.long': '他のインスタンスには投稿されません',
@@ -84,7 +88,70 @@ const messages = {
 
   'navigation_bar.direct': 'ダイレクトメッセージ',
   'navigation_bar.bookmarks': 'ブックマーク',
-  'column.bookmarks': 'ブックマーク'
+  'column.bookmarks': 'ブックマーク',
+
+  'account.add_account_note': '@{name}のメモを追加',
+  'account.disclaimer_full': 'このユーザー情報は不正確な可能性があります。',
+  'account.follows': 'フォロー',
+  'account.suspended_disclaimer_full': 'このユーザーはモデレータにより停止されました。',
+  'account.view_full_profile': '正確な情報を見る',
+  'account_note.cancel': 'キャンセル',
+  'account_note.edit': '編集',
+  'account_note.glitch_placeholder': 'メモがありません',
+  'account_note.save': '保存',
+  'boost_modal.missing_description': 'このトゥートには少なくとも1つの画像に説明が付与されていません',
+  'community.column_settings.allow_local_only': 'ローカル限定投稿を表示する',
+  'compose.content-type.html': 'HTML',
+  'compose.content-type.markdown': 'マークダウン',
+  'compose.content-type.plain': 'プレーンテキスト',
+  'compose_form.poll.multiple_choices': '複数回答を許可',
+  'compose_form.poll.single_choice': '単一回答を許可',
+  'compose_form.spoiler': '本文は警告の後ろに隠す',
+  'confirmation_modal.do_not_ask_again': 'もう1度尋ねない',
+  'confirmations.discard_edit_media.confirm': '破棄',
+  'confirmations.discard_edit_media.message': 'メディアの説明・プレビューに保存していない変更があります。破棄してもよろしいですか?',
+  'confirmations.unfilter': 'このフィルターされたトゥートについての情報',
+  'confirmations.unfilter.author': '筆者',
+  'confirmations.unfilter.confirm': '見る',
+  'confirmations.unfilter.edit_filter': 'フィルターを編集',
+  'confirmations.unfilter.filters': '適用されたフィルター',
+  'content-type.change': 'コンテンツ形式を変更',
+  'direct.conversations_mode': '会話',
+  'direct.timeline_mode': 'タイムライン',
+  'endorsed_accounts_editor.endorsed_accounts': '紹介しているユーザー',
+  'keyboard_shortcuts.bookmark': 'ブックマーク',
+  'keyboard_shortcuts.secondary_toot': 'セカンダリートゥートの公開範囲でトゥートする',
+  'keyboard_shortcuts.toggle_collapse': '折りたたむ/折りたたみを解除',
+  'moved_to_warning': 'このアカウント{moved_to_link}に引っ越したため、新しいフォロワーを受け入れていません。',
+  'settings.show_action_bar': 'アクションバーを表示',
+  'settings.filtering_behavior': 'フィルターの振る舞い',
+  'settings.filtering_behavior.cw': '警告文にフィルターされた単語を付加して表示します',
+  'settings.filtering_behavior.drop': 'フィルターされたトゥートを完全に隠します',
+  'settings.filtering_behavior.hide': '\'フィルターされました\'とその理由を確認するボタンを表示する',
+  'settings.filtering_behavior.upstream': '\'フィルターされました\'とバニラMastodonと同じように表示する',
+  'settings.filters': 'フィルター',
+  'settings.hicolor_privacy_icons': 'ハイカラーの公開範囲アイコン',
+  'settings.hicolor_privacy_icons.hint': '公開範囲アイコンを明るく表示し見分けやすい色にします',
+  'settings.confirm_boost_missing_media_description': 'メディアの説明が欠けているトゥートをブーストする前に確認ダイアログを表示する',
+  'settings.tag_misleading_links': '誤解を招くリンクにタグをつける',
+  'settings.tag_misleading_links.hint': '明示的に言及していないすべてのリンクに、リンクターゲットホストを含む視覚的な表示を追加します',
+  'settings.rewrite_mentions': '表示されたトゥートの返信先表示を書き換える',
+  'settings.rewrite_mentions_acct': 'ユーザー名とドメイン名(アカウントがリモートの場合)を表示するように書き換える',
+  'settings.rewrite_mentions_no': '書き換えない',
+  'settings.rewrite_mentions_username': 'ユーザー名を表示するように書き換える',
+  'settings.swipe_to_change_columns': 'スワイプでカラムを切り替え可能にする(モバイルのみ)',
+  'settings.prepend_cw_re': '返信するとき警告に "re: "を付加する',
+  'settings.preselect_on_reply': '返信するときユーザー名を事前選択する',
+  'settings.confirm_before_clearing_draft': '作成しているメッセージが上書きされる前に確認ダイアログを表示する',
+  'settings.show_content_type_choice': 'トゥートを書くときコンテンツ形式の選択ボタンを表示する',
+  'settings.inline_preview_cards': '外部リンクに埋め込みプレビューを有効にする',
+  'settings.media_reveal_behind_cw': '既定で警告指定されているトゥートの閲覧注意メディアを表示する',
+  'settings.pop_in_left': '左',
+  'settings.pop_in_player': 'ポップインプレイヤーを有効化する',
+  'settings.pop_in_position': 'ポップインプレーヤーの位置:',
+  'settings.pop_in_right': '右',
+  'status.show_filter_reason': '(理由を見る)',
+
 };
 
-export default Object.assign({}, inherited, messages);
\ No newline at end of file
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/ko.js b/app/javascript/flavours/glitch/locales/ko.js
index 3b55f89b9..b67fec187 100644
--- a/app/javascript/flavours/glitch/locales/ko.js
+++ b/app/javascript/flavours/glitch/locales/ko.js
@@ -1,7 +1,201 @@
 import inherited from 'mastodon/locales/ko.json';
 
 const messages = {
-  //  No translations available.
+  'account.add_account_note': '@{name} 님에 대한 메모 추가',
+  'account.disclaimer_full': '아래에 있는 정보들은 사용자의 프로필을 완벽하게 나타내지 못하고 있을 수도 있습니다.',
+  'account.follows': '팔로우',
+  'account.suspended_disclaimer_full': '이 사용자는 중재자에 의해 정지되었습니다.',
+  'account.view_full_profile': '전체 프로필 보기',
+  'account_note.cancel': '취소',
+  'account_note.edit': '편집',
+  'account_note.glitch_placeholder': '코멘트가 없습니다',
+  'account_note.save': '저장',
+  'advanced_options.icon_title': '고급 옵션',
+  'advanced_options.local-only.long': '다른 서버에 게시하지 않기',
+  'advanced_options.local-only.short': '로컬 전용',
+  'advanced_options.local-only.tooltip': '이 게시물은 로컬 전용입니다',
+  'advanced_options.threaded_mode.long': '글을 작성하고 자동으로 답글 열기',
+  'advanced_options.threaded_mode.short': '글타래 모드',
+  'advanced_options.threaded_mode.tooltip': '글타래 모드 활성화됨',
+  'boost_modal.missing_description': '이 게시물은 설명이 없는 미디어를 포함하고 있습니다',
+  'column.favourited_by': '즐겨찾기 한 사람',
+  'column.heading': '기타',
+  'column.reblogged_by': '부스트 한 사람',
+  'column.subheading': '다양한 옵션',
+  'column.toot': '게시물과 답글',
+  'column_header.profile': '프로필',
+  'column_subheading.lists': '리스트',
+  'column_subheading.navigation': '탐색',
+  'community.column_settings.allow_local_only': '로컬 전용 글 보기',
+  'compose.attach': '첨부…',
+  'compose.attach.doodle': '뭔가 그려보세요',
+  'compose.attach.upload': '파일 업로드',
+  'compose.content-type.html': 'HTML',
+  'compose.content-type.markdown': '마크다운',
+  'compose.content-type.plain': '일반 텍스트',
+  'compose_form.poll.multiple_choices': '여러 개 선택 가능',
+  'compose_form.poll.single_choice': '하나만 선택 가능',
+  'compose_form.spoiler': '경고 메시지로 숨기기',
+  'confirmation_modal.do_not_ask_again': '다음부터 확인창을 띄우지 않기',
+  'confirmations.discard_edit_media.confirm': '취소',
+  'confirmations.discard_edit_media.message': '저장하지 않은 미디어 설명이나 미리보기가 있습니다, 그냥 닫을까요?',
+  'confirmations.missing_media_description.confirm': '그냥 보내기',
+  'confirmations.missing_media_description.edit': '미디어 편집',
+  'confirmations.missing_media_description.message': '하나 이상의 미디어에 대해 설명을 작성하지 않았습니다. 시각장애인을 위해 모든 미디어에 설명을 추가하는 것을 고려해주세요.',
+  'confirmations.unfilter': '이 필터링 된 글에 대한 정보',
+  'confirmations.unfilter.author': '작성자',
+  'confirmations.unfilter.confirm': '보기',
+  'confirmations.unfilter.edit_filter': '필터 편집',
+  'confirmations.unfilter.filters': '적용된 {count, plural, one {필터} other {필터들}}',
+  'content-type.change': '콘텐트 타입',
+  'direct.conversations_mode': '대화',
+  'direct.timeline_mode': '타임라인',
+  'endorsed_accounts_editor.endorsed_accounts': '추천하는 계정들',
+  'favourite_modal.combo': '다음엔 {combo}를 눌러 건너뛸 수 있습니다',
+  'getting_started.onboarding': '둘러보기',
+  'getting_started.open_source_notice': '글리치는 {Mastodon}의 자유 오픈소스 포크버전입니다. {github}에서 문제를 리포팅 하거나 기여를 할 수 있습니다.',
+  'home.column_settings.advanced': '고급',
+  'home.column_settings.filter_regex': '정규표현식으로 필터',
+  'home.column_settings.show_direct': 'DM 보여주기',
+  'home.settings': '컬럼 설정',
+  'keyboard_shortcuts.bookmark': '북마크',
+  'keyboard_shortcuts.secondary_toot': '보조 프라이버시 설정으로 글 보내기',
+  'keyboard_shortcuts.toggle_collapse': '글 접거나 펼치기',
+  'layout.auto': '자동',
+  'layout.current_is': '현재 레이아웃:',
+  'layout.desktop': '데스크탑',
+  'layout.hint.auto': '“고급 웹 인터페이스 활성화” 설정과 화면 크기에 따라 자동으로 레이아웃을 고릅니다.',
+  'layout.hint.desktop': '“고급 웹 인터페이스 활성화” 설정이나 화면 크기에 관계 없이 멀티 컬럼 레이아웃을 사용합니다.',
+  'layout.hint.single': '“고급 웹 인터페이스 활성화” 설정이나 화면 크기에 관계 없이 싱글 컬럼 레이아웃을 사용합니다.',
+  'layout.single': '모바일',
+  'media_gallery.sensitive': '민감함',
+  'moved_to_warning': '이 계정은 {moved_to_link}로 이동한 것으로 표시되었고, 새 팔로우를 받지 않는 것 같습니다.',
+  'navigation_bar.app_settings': '앱 설정',
+  'navigation_bar.featured_users': '추천된 계정들',
+  'navigation_bar.misc': '다양한 옵션들',
+  'notification.markForDeletion': '삭제하기 위해 표시',
+  'notification_purge.btn_all': '전체선택',
+  'notification_purge.btn_apply': '선택된 알림 삭제',
+  'notification_purge.btn_invert': '선택반전',
+  'notification_purge.btn_none': '전체선택해제',
+  'notification_purge.start': '알림 삭제모드로 들어가기',
+  'notifications.clear': '내 알림 모두 지우기',
+  'notifications.marked_clear': '선택된 알림 모두 삭제',
+  'notifications.marked_clear_confirmation': '정말로 선택된 알림들을 영구적으로 삭제할까요?',
+  'onboarding.done': '완료',
+  'onboarding.next': '다음',
+  'onboarding.page_five.public_timelines': '로컬 타임라인은 {domain}에 있는 모든 사람의 공개글을 보여줍니다. 연합 타임라인은 {domain}에 있는 사람들이 팔로우 하는 모든 사람의 공개글을 보여줍니다. 이것들은 공개 타임라인이라고 불리며, 새로운 사람들을 발견할 수 있는 좋은 방법입니다.',
+  'onboarding.page_four.home': '홈 타임라인은 당신이 팔로우 한 사람들의 글을 보여줍니다.',
+  'onboarding.page_four.notifications': '알림 컬럼은 누군가가 당신과 상호작용한 것들을 보여줍니다.',
+  'onboarding.page_one.federation': '{domain}은 마스토돈의 \'인스턴스\'입니다. 마스토돈은 하나의 거대한 소셜 네트워크를 만들기 위해 참여한 서버들의 네트워크입니다. 우린 이 서버들을 인스턴스라고 부릅니다.',
+  'onboarding.page_one.handle': '당신은 {domain}에 속해 있으며, 전체 핸들은 {handle} 입니다.',
+  'onboarding.page_one.welcome': '{domain}에 오신 것을 환영합니다!',
+  'onboarding.page_six.admin': '우리 서버의 관리자는 {admin} 님입니다.',
+  'onboarding.page_six.almost_done': '거의 다 되었습니다…',
+  'onboarding.page_six.appetoot': '본 아페툿!',
+  'onboarding.page_six.apps_available': 'iOS, 안드로이드, 그리고 다른 플랫폼들을 위한 {apps}이 존재합니다.',
+  'onboarding.page_six.github': '{domain}은 글리치를 통해 구동 됩니다. 글리치는 {Mastodon}의 {fork}입니다, 그리고 어떤 마스토돈 인스턴스나 앱과도 호환 됩니다. 글리치는 완전한 자유 오픈소스입니다. {github}에서 버그를 리포팅 하거나, 기능을 제안하거나, 코드를 기여할 수 있습니다.',
+  'onboarding.page_six.guidelines': '커뮤니티 가이드라인',
+  'onboarding.page_six.read_guidelines': '{domain}의 {guidelines}을 읽어주세요!',
+  'onboarding.page_six.various_app': '모바일 앱',
+  'onboarding.page_three.profile': '프로필을 수정해 아바타, 바이오, 표시되는 이름을 설정하세요. 거기에서 다른 설정들도 찾을 수 있습니다.',
+  'onboarding.page_three.search': '검색창을 사용해 사람들과 해시태그를 찾아보세요. 예를 들면 {illustration}이라든지 {introcustions} 같은 것으로요. 이 인스턴스에 있지 않은 사람을 찾으려면, 전체 핸들을 사용하세요.',
+  'onboarding.page_two.compose': '작성 컬럼에서 게시물을 작성하세요. 그림을 업로드 할 수 있고, 공개설정을 바꿀 수도 있으며, 아래 아이콘을 통해 열람주의 텍스트를 설정할 수 있습니다.',
+  'onboarding.skip': '건너뛰기',
+  'settings.always_show_spoilers_field': '열람주의 항목을 언제나 활성화',
+  'settings.auto_collapse': '자동으로 접기',
+  'settings.auto_collapse_all': '모두',
+  'settings.auto_collapse_lengthy': '긴 글',
+  'settings.auto_collapse_media': '미디어 포함 글',
+  'settings.auto_collapse_notifications': '알림',
+  'settings.auto_collapse_reblogs': '부스트',
+  'settings.auto_collapse_replies': '답글',
+  'settings.close': '닫기',
+  'settings.collapsed_statuses': '접힌 글',
+  'settings.compose_box_opts': '작성 상자',
+  'settings.confirm_before_clearing_draft': '작성 중인 메시지를 덮어씌우기 전에 확인창을 보여주기',
+  'settings.confirm_boost_missing_media_description': '미디어 설명이 없는 글을 부스트하려 할 때 확인창을 보여주기',
+  'settings.confirm_missing_media_description': '미디어 설명이 없는 글을 작성하려 할 때 확인창을 보여주기',
+  'settings.content_warnings': '열람주의',
+  'settings.content_warnings.regexp': '정규표현식',
+  'settings.content_warnings_filter': '자동으로 펼치지 않을 열람주의 문구:',
+  'settings.enable_collapsed': '접힌 글 활성화',
+  'settings.enable_content_warnings_auto_unfold': '자동으로 열람주의 펼치기',
+  'settings.filtering_behavior': '필터링 동작',
+  'settings.filtering_behavior.cw': '게시물을 보여주되, 필터된 단어를 열람주의에 추가합니다',
+  'settings.filtering_behavior.drop': '완전히 숨깁니다',
+  'settings.filtering_behavior.hide': '\'필터됨\'이라고 표시하고 이유를 표시하는 버튼을 추가합니다',
+  'settings.filtering_behavior.upstream': '\'필터됨\'이라고 일반 마스토돈처럼 표시합니다',
+  'settings.filters': '필터',
+  'settings.general': '일반',
+  'settings.hicolor_privacy_icons': '높은 채도의 공개설정 아이콘',
+  'settings.hicolor_privacy_icons.hint': '공개설정 아이콘들을 밝고 구분하기 쉬운 색으로 표시합니다',
+  'settings.image_backgrounds': '이미지 배경',
+  'settings.image_backgrounds_media': '접힌 글의 미디어 미리보기',
+  'settings.image_backgrounds_users': '접힌 글에 이미지 배경 주기',
+  'settings.inline_preview_cards': '외부 링크에 대한 미리보기 카드를 같이 표시',
+  'settings.layout': '레이아웃:',
+  'settings.layout_opts': '레이아웃 옵션',
+  'settings.media': '미디어',
+  'settings.media_fullwidth': '최대폭 미디어 미리보기',
+  'settings.media_letterbox': '레터박스 미디어',
+  'settings.media_letterbox_hint': '확대하고 자르는 대신 축소하고 레터박스에 넣어 이미지를 보여줍니다',
+  'settings.media_reveal_behind_cw': '열람주의로 가려진 미디어를 기본으로 펼쳐 둡니다',
+  'settings.navbar_under': '내비바를 하단에 (모바일 전용)',
+  'settings.notifications.favicon_badge': '읽지 않은 알림 파비콘 배지',
+  'settings.notifications.favicon_badge.hint': '읽지 않은 알림 배지를 파비콘에 추가합니다',
+  'settings.notifications.tab_badge': '읽지 않은 알림 배지',
+  'settings.notifications.tab_badge.hint': '알림 컬럼이 열려 있지 않을 때 알림 컬럼에 알림이 있다는 배지를 표시합니다',
+  'settings.notifications_opts': '알림 옵션',
+  'settings.pop_in_left': '왼쪽',
+  'settings.pop_in_player': '떠있는 재생기 활성화',
+  'settings.pop_in_position': '떠있는 재생기 위치:',
+  'settings.pop_in_right': '오른쪽',
+  'settings.preferences': '사용자 설정',
+  'settings.prepend_cw_re': '열람주의가 달린 글에 답장을 할 때 열람주의 문구 앞에 “re: ”를 추가합니다',
+  'settings.preselect_on_reply': '답글 달 때 사용자명 미리 선택',
+  'settings.preselect_on_reply_hint': '답글을 달 때 이미 멘션 된 사람의 사용자명을 미리 블럭으로 설정해 놓습니다',
+  'settings.rewrite_mentions': '표시되는 게시물의 멘션 표시 바꾸기',
+  'settings.rewrite_mentions_acct': '사용자명과 도메인으로 바꾸기(계정이 원격일 때)',
+  'settings.rewrite_mentions_no': '멘션을 그대로 두기',
+  'settings.rewrite_mentions_username': '사용자명으로 바꾸기',
+  'settings.show_action_bar': '접힌 글에 액션 버튼들 보이기',
+  'settings.show_content_type_choice': '글을 작성할 때 콘텐트 타입을 고를 수 있도록 합니다',
+  'settings.show_reply_counter': '대략적인 답글 개수를 표시합니다',
+  'settings.side_arm': '보조 작성 버튼:',
+  'settings.side_arm.none': '없음',
+  'settings.side_arm_reply_mode': '답글을 작성할 때:',
+  'settings.side_arm_reply_mode.copy': '답글을 달려는 글의 공개설정을 복사합니다',
+  'settings.side_arm_reply_mode.keep': '보조 작성 버튼의 공개설정을 유지합니다',
+  'settings.side_arm_reply_mode.restrict': '답글을 달려는 글의 공개설정에 맞게 제한합니다',
+  'settings.swipe_to_change_columns': '스와이프하여 컬럼간 전환을 허용합니다 (모바일 전용)',
+  'settings.tag_misleading_links': '오해의 소지가 있는 링크를 표시합니다',
+  'settings.tag_misleading_links.hint': '링크에 명시적으로 주소가 없는 경우엔 대상 호스트를 보이도록 표시합니다',
+  'settings.wide_view': '넓은 뷰 (데스크탑 모드 전용)',
+  'settings.wide_view_hint': '컬럼들을 늘려서 활용 가능한 공간을 사용합니다.',
+  'status.collapse': '접기',
+  'status.has_audio': '소리 파일이 첨부되어 있습니다',
+  'status.has_pictures': '그림 파일이 첨부되어 있습니다',
+  'status.has_preview_card': '미리보기 카드가 첨부되어 있습니다',
+  'status.has_video': '영상이 첨부되어 있습니다',
+  'status.hide': '글 가리기',
+  'status.in_reply_to': '이 글은 답글입니다',
+  'status.is_poll': '이 글은 설문입니다',
+  'status.local_only': '당신의 서버에서만 보입니다',
+  'status.sensitive_toggle': '클릭해서 보기',
+  'status.show_filter_reason': '(이유 보기)',
+  'status.uncollapse': '펼치기',
+  'upload_modal.applying': '적용중…',
+  'web_app_crash.change_your_settings': '{settings}을 바꾸세요',
+  'web_app_crash.content': '이것들을 시도해 볼 수 있습니다:',
+  'web_app_crash.debug_info': '디버그 정보',
+  'web_app_crash.disable_addons': '브라우저 애드온이나 기본 번역 도구를 비활성화 합니다',
+  'web_app_crash.issue_tracker': '이슈 트래커',
+  'web_app_crash.reload': '새로고침',
+  'web_app_crash.reload_page': '이 페이지를 {reload}',
+  'web_app_crash.report_issue': '{issuetracker}에 버그 제보',
+  'web_app_crash.settings': '설정',
+  'web_app_crash.title': '죄송합니다, 하지만 마스토돈 앱이 뭔가 잘못되었습니다.',
 };
 
 export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/zh-CN.js b/app/javascript/flavours/glitch/locales/zh-CN.js
index 944588e02..21a68fc01 100644
--- a/app/javascript/flavours/glitch/locales/zh-CN.js
+++ b/app/javascript/flavours/glitch/locales/zh-CN.js
@@ -1,7 +1,201 @@
 import inherited from 'mastodon/locales/zh-CN.json';
 
 const messages = {
-  //  No translations available.
+  'account.add_account_note': '为 @{name} 添加备注',
+  'account.disclaimer_full': '以下信息可能无法完整代表你的个人资料。',
+  'account.follows': '正在关注',
+  'account.suspended_disclaimer_full': '该用户已被封禁。',
+  'account.view_full_profile': '查看完整资料',
+  'account_note.cancel': '取消',
+  'account_note.edit': '编辑',
+  'account_note.glitch_placeholder': '暂无备注',
+  'account_note.save': '保存',
+  'advanced_options.icon_title': '高级选项',
+  'advanced_options.local-only.long': '不要发布嘟文到其他实例',
+  'advanced_options.local-only.short': '本地模式',
+  'advanced_options.local-only.tooltip': '这条嘟文仅限于本实例',
+  'advanced_options.threaded_mode.long': '发嘟时自动打开回复',
+  'advanced_options.threaded_mode.short': '线程模式',
+  'advanced_options.threaded_mode.tooltip': '线程模式已启用',
+  'boost_modal.missing_description': '这条嘟文未包含媒体描述',
+  'column.favourited_by': '喜欢',
+  'column.heading': '标题',
+  'column.reblogged_by': '转嘟',
+  'column.subheading': '其他选项',
+  'column.toot': '嘟文和回复',
+  'column_header.profile': '个人资料',
+  'column_subheading.lists': '列表',
+  'column_subheading.navigation': '导航',
+  'community.column_settings.allow_local_only': '只显示本地模式嘟文',
+  'compose.attach': '附上...',
+  'compose.attach.doodle': '画点什么',
+  'compose.attach.upload': '上传文件',
+  'compose.content-type.html': 'HTML',
+  'compose.content-type.markdown': 'Markdown',
+  'compose.content-type.plain': '纯文本',
+  'compose_form.poll.multiple_choices': '允许多选',
+  'compose_form.poll.single_choice': '允许单选',
+  'compose_form.spoiler': '隐藏为内容警告',
+  'confirmation_modal.do_not_ask_again': '下次不显示确认窗口',
+  'confirmations.discard_edit_media.confirm': '确认',
+  'confirmations.discard_edit_media.message': '有未保存的媒体描述或预览,确认要关闭?',
+  'confirmations.missing_media_description.confirm': '确认',
+  'confirmations.missing_media_description.edit': '编辑',
+  'confirmations.missing_media_description.message': '你没有为一种或多种媒体撰写描述。请考虑为视障人士添加描述。',
+  'confirmations.unfilter': '关于此过滤后嘟文的信息',
+  'confirmations.unfilter.author': '作者',
+  'confirmations.unfilter.confirm': '查看',
+  'confirmations.unfilter.edit_filter': '编辑过滤器',
+  'confirmations.unfilter.filters': '应用 {count, plural, one {过滤器} other {过滤器}}',
+  'content-type.change': '内容类型 ',
+  'direct.conversations_mode': '对话模式',
+  'direct.timeline_mode': '时间线模式',
+  'endorsed_accounts_editor.endorsed_accounts': '推荐用户',
+  'favourite_modal.combo': '下次你可以按 {combo} 跳过这个',
+  'getting_started.onboarding': '参观一下',
+  'getting_started.open_source_notice': 'Glitchsoc 是由 {Mastodon} 分叉出来的免费开源软件。你可以在 GitHub 上贡献或报告问题,地址是 {github}。',
+  'home.column_settings.advanced': '高级',
+  'home.column_settings.filter_regex': '按正则表达式过滤',
+  'home.column_settings.show_direct': '显示私信',
+  'home.settings': '列表设置',
+  'keyboard_shortcuts.bookmark': '书签',
+  'keyboard_shortcuts.secondary_toot': '使用二级隐私设置发送嘟文',
+  'keyboard_shortcuts.toggle_collapse': '折叠或展开嘟文',
+  'layout.auto': '自动模式',
+  'layout.current_is': '你目前的布局是:',
+  'layout.desktop': '桌面模式',
+  'layout.hint.auto': '根据“启用高级 Web 界面”设置和屏幕大小自动选择布局。',
+  'layout.hint.desktop': '“使用多列布局,无论“启用高级 Web 界面”设置和屏幕大小如何。',
+  'layout.hint.single': '使用单列布局,无论“启用高级 Web 界面”设置和屏幕大小如何。',
+  'layout.single': '移动模式',
+  'media_gallery.sensitive': '敏感内容',
+  'moved_to_warning': '此帐户已被标记为移至 {moved_to_link},并且似乎没有收到新关注者。',
+  'navigation_bar.app_settings': '应用选项',
+  'navigation_bar.featured_users': '推荐用户',
+  'navigation_bar.misc': '杂项',
+  'notification.markForDeletion': '标记以删除',
+  'notification_purge.btn_all': '全选',
+  'notification_purge.btn_apply': '清除已选',
+  'notification_purge.btn_invert': '反向选择',
+  'notification_purge.btn_none': '取消全选',
+  'notification_purge.start': '进入通知清除模式',
+  'notifications.clear': '清除所有通知',
+  'notifications.marked_clear': '清除选择的通知',
+  'notifications.marked_clear_confirmation': '你确定要永久清除所有选择的通知吗?',
+  'onboarding.done': '完成',
+  'onboarding.next': '下一个',
+  'onboarding.page_five.public_timelines': '本地时间线显示来自 {domain} 中所有人的公开嘟文。跨站时间线显示了 {domain} 用户关注的每个人的公开嘟文。这些被称为公共时间线,是发现新朋友的好方法。',
+  'onboarding.page_four.home': '你的主页时间线会显示你关注的人的嘟文。',
+  'onboarding.page_four.notifications': '通知栏显示某人与你互动的内容。',
+  'onboarding.page_one.federation': '{domain} 是 Mastodon 的一个“实例”。Mastodon 是一个由独立服务器组成的,通过不断联合形成的社交网络。我们称这些服务器为实例。',
+  'onboarding.page_one.handle': '你位于 {domain},因此你的完整用户名是 {handle} 。',
+  'onboarding.page_one.welcome': '欢迎来到 {domain}!',
+  'onboarding.page_six.admin': '实例的管理员是 {admin}。',
+  'onboarding.page_six.almost_done': '就快完成了...',
+  'onboarding.page_six.appetoot': '尽情享用吧!',
+  'onboarding.page_six.apps_available': '有适用于 iOS、Android 和其他平台的应用程序。',
+  'onboarding.page_six.github': '{domain} 在 Glitchsoc 上运行。Glitchsoc 是 {Mastodon} 的一个友好 {fork},与任何 Mastodon 实例或应用兼容。Glitchsoc 是完全免费和开源的。你可以在 {github} 上报告错误、请求功能或贡献代码。',
+  'onboarding.page_six.guidelines': '社区准则',
+  'onboarding.page_six.read_guidelines': '请阅读 {domain} 的 {guidelines}!',
+  'onboarding.page_six.various_app': '应用程序',
+  'onboarding.page_three.profile': '编辑你的个人资料,更改你的头像、个人简介和昵称。在那里,你还会发现其他设置。',
+  'onboarding.page_three.search': '使用搜索栏查找用户并查看标签,例如 #illustration 和 #introductions。要查找不在此实例中的用户,请使用他们的完整用户名。',
+  'onboarding.page_two.compose': '在撰写框中撰写嘟文。你可以使用下方图标上传图像、更改隐私设置和添加内容警告。',
+  'onboarding.skip': '跳过',
+  'settings.always_show_spoilers_field': '始终显示内容警告框',
+  'settings.auto_collapse': '自动折叠',
+  'settings.auto_collapse_all': '所有',
+  'settings.auto_collapse_lengthy': '长嘟文',
+  'settings.auto_collapse_media': '带媒体文件的嘟文',
+  'settings.auto_collapse_notifications': '通知',
+  'settings.auto_collapse_reblogs': '转嘟',
+  'settings.auto_collapse_replies': '回复',
+  'settings.close': '关闭',
+  'settings.collapsed_statuses': '折叠嘟文',
+  'settings.compose_box_opts': '撰写框',
+  'settings.confirm_before_clearing_draft': '在覆盖正在写入的嘟文之前显示确认对话框',
+  'settings.confirm_boost_missing_media_description': '在转嘟缺少媒体描述的嘟文之前显示确认对话框',
+  'settings.confirm_missing_media_description': '在发送缺少媒体描述的嘟文之前显示确认对话框',
+  'settings.content_warnings': '内容警告',
+  'settings.content_warnings.regexp': '正则表达式',
+  'settings.content_warnings_filter': '不会自动展开的内容警告:',
+  'settings.enable_collapsed': '启用折叠嘟文',
+  'settings.enable_content_warnings_auto_unfold': '自动展开内容警告',
+  'settings.filtering_behavior': '过滤器行为',
+  'settings.filtering_behavior.cw': '仍然显示嘟文,并在内容警告中添加过滤词',
+  'settings.filtering_behavior.drop': '完全隐藏过滤的嘟文',
+  'settings.filtering_behavior.hide': '显示“已过滤”并添加一个按钮来显示原因',
+  'settings.filtering_behavior.upstream': '像原版 Mastodon 一样显示“已过滤”',
+  'settings.filters': '过滤器',
+  'settings.general': '一般',
+  'settings.hicolor_privacy_icons': '彩色隐私图标 ',
+  'settings.hicolor_privacy_icons.hint': '以明亮且易于区分的颜色显示隐私图标',
+  'settings.image_backgrounds': '图片背景',
+  'settings.image_backgrounds_media': '预览折叠嘟文的媒体文件',
+  'settings.image_backgrounds_users': '为折叠嘟文附加图片背景',
+  'settings.inline_preview_cards': '外部链接的内嵌预览卡片',
+  'settings.layout': '布局:',
+  'settings.layout_opts': '布局选项',
+  'settings.media': '媒体',
+  'settings.media_fullwidth': '全宽媒体预览',
+  'settings.media_letterbox': '信箱媒体',
+  'settings.media_letterbox_hint': '缩小媒体以填充图像容器而不是拉伸和裁剪它们',
+  'settings.media_reveal_behind_cw': '默认显示内容警告后的敏感媒体',
+  'settings.navbar_under': '底部导航栏(仅限于移动模式)',
+  'settings.notifications.favicon_badge': '未读通知网站图标',
+  'settings.notifications.favicon_badge.hint': '将未读通知添加到网站图标',
+  'settings.notifications.tab_badge': '未读通知图标',
+  'settings.notifications.tab_badge.hint': '当通知栏未打开时,显示未读通知图标',
+  'settings.notifications_opts': '通知选项',
+  'settings.pop_in_left': '左边',
+  'settings.pop_in_player': '启用悬浮播放器',
+  'settings.pop_in_position': '悬浮播放器位置:',
+  'settings.pop_in_right': '右边',
+  'settings.preferences': '用户选项',
+  'settings.prepend_cw_re': '回复时在内容警告前加上“re:”',
+  'settings.preselect_on_reply': '回复时预先选择用户名',
+  'settings.preselect_on_reply_hint': '回复与多个参与者的对话时,预先选择第一个用户名',
+  'settings.rewrite_mentions': '重写嘟文中的提及',
+  'settings.rewrite_mentions_acct': '重写为用户名和域名(当帐户为远程时)',
+  'settings.rewrite_mentions_no': '不要重写',
+  'settings.rewrite_mentions_username': '重写为用户名',
+  'settings.show_action_bar': '在折叠的嘟文中显示操作按钮',
+  'settings.show_content_type_choice': '允许你在撰写嘟文时选择格式类型',
+  'settings.show_reply_counter': '显示回复的大致数量',
+  'settings.side_arm': '辅助发嘟按钮:',
+  'settings.side_arm.none': '无',
+  'settings.side_arm_reply_mode': '当回复嘟文时:',
+  'settings.side_arm_reply_mode.copy': '复制被回复嘟文的隐私设置',
+  'settings.side_arm_reply_mode.keep': '保留辅助发嘟按钮以设置隐私',
+  'settings.side_arm_reply_mode.restrict': '将隐私设置限制为正在回复的那条嘟文',
+  'settings.swipe_to_change_columns': '允许滑动以在列之间切换(仅限移动模式)',
+  'settings.tag_misleading_links': '标记误导性链接',
+  'settings.tag_misleading_links.hint': '将带有目标网页链接的视觉指示添加到每个未明确的链接',
+  'settings.wide_view': '宽视图(仅限于桌面模式)',
+  'settings.wide_view_hint': '拉伸列宽以更好地填充可用空间。',
+  'status.collapse': '折叠',
+  'status.has_audio': '附带音频文件',
+  'status.has_pictures': '附带图片文件',
+  'status.has_preview_card': '附带预览卡片',
+  'status.has_video': '附带视频文件',
+  'status.hide': '隐藏内容',
+  'status.in_reply_to': '此嘟文是回复',
+  'status.is_poll': '此嘟文是投票',
+  'status.local_only': '此嘟文仅本实例可见',
+  'status.sensitive_toggle': '点击查看',
+  'status.show_filter_reason': '(显示原因)',
+  'status.uncollapse': '不折叠',
+  'upload_modal.applying': '正在应用...',
+  'web_app_crash.change_your_settings': '更改 {settings}',
+  'web_app_crash.content': '你可以尝试这些:',
+  'web_app_crash.debug_info': '调试信息',
+  'web_app_crash.disable_addons': '禁用浏览器插件或本地翻译工具',
+  'web_app_crash.issue_tracker': '问题追踪器',
+  'web_app_crash.reload': '刷新',
+  'web_app_crash.reload_page': '{reload} 此页面',
+  'web_app_crash.report_issue': '将错误报告给 {issuetracker}',
+  'web_app_crash.settings': '设置',
+  'web_app_crash.title': '抱歉,Mastodon 出了点问题。',
 };
 
-export default Object.assign({}, inherited, messages);
+export default Object.assign({}, inherited, messages);
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/packs/admin.js b/app/javascript/flavours/glitch/packs/admin.js
new file mode 100644
index 000000000..b26df932c
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/admin.js
@@ -0,0 +1,48 @@
+import 'packs/public-path';
+import loadPolyfills from 'flavours/glitch/util/load_polyfills';
+import ready from 'flavours/glitch/util/ready';
+import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
+
+function main() {
+  const { delegate } = require('@rails/ujs');
+
+  ready(() => {
+    const React    = require('react');
+    const ReactDOM = require('react-dom');
+
+    [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
+      const componentName  = element.getAttribute('data-admin-component');
+      const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
+
+      import('flavours/glitch/containers/admin_component').then(({ default: AdminComponent }) => {
+        return import('flavours/glitch/components/admin/' + componentName).then(({ default: Component }) => {
+          ReactDOM.render((
+            <AdminComponent locale={locale}>
+              <Component {...componentProps} />
+            </AdminComponent>
+          ), element);
+        });
+      }).catch(error => {
+        console.error(error);
+      });
+    });
+  });
+
+  delegate(document, '.sidebar__toggle__icon', 'click', () => {
+    const target = document.querySelector('.sidebar ul');
+
+    if (target.style.display === 'block') {
+      target.style.display = 'none';
+    } else {
+      target.style.display = 'block';
+    }
+  });
+}
+
+loadPolyfills()
+  .then(main)
+  .then(loadKeyboardExtensions)
+  .catch(error => {
+    console.error(error);
+
+  });
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index dccdbc8d0..a92f3d5a8 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -99,7 +99,9 @@ function main() {
     delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
       const password = document.getElementById('registration_user_password');
       const confirmation = document.getElementById('registration_user_password_confirmation');
-      if (password.value && password.value !== confirmation.value) {
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
         confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
       } else {
         confirmation.setCustomValidity('');
@@ -111,7 +113,9 @@ function main() {
       const confirmation = document.getElementById('user_password_confirmation');
       if (!confirmation) return;
 
-      if (password.value && password.value !== confirmation.value) {
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
         confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
       } else {
         confirmation.setCustomValidity('');
diff --git a/app/javascript/flavours/glitch/reducers/accounts_map.js b/app/javascript/flavours/glitch/reducers/accounts_map.js
new file mode 100644
index 000000000..e0d42e9cd
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts_map.js
@@ -0,0 +1,15 @@
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function accountsMap(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_IMPORT:
+    return state.set(action.account.acct, action.account.id);
+  case ACCOUNTS_IMPORT:
+    return state.withMutations(map => action.accounts.forEach(account => map.set(account.acct, account.id)));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index e989401d8..d2ea0a924 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -22,6 +22,7 @@ import {
   COMPOSE_SUGGESTIONS_CLEAR,
   COMPOSE_SUGGESTIONS_READY,
   COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SUGGESTION_IGNORE,
   COMPOSE_SUGGESTION_TAGS_UPDATE,
   COMPOSE_TAG_HISTORY_UPDATE,
   COMPOSE_ADVANCED_OPTIONS_CHANGE,
@@ -42,6 +43,9 @@ import {
   COMPOSE_POLL_OPTION_CHANGE,
   COMPOSE_POLL_OPTION_REMOVE,
   COMPOSE_POLL_SETTINGS_CHANGE,
+  INIT_MEDIA_EDIT_MODAL,
+  COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+  COMPOSE_CHANGE_MEDIA_FOCUS,
 } from 'flavours/glitch/actions/compose';
 import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
 import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
@@ -97,6 +101,13 @@ const initialState = ImmutableMap({
   resetFileKey: Math.floor((Math.random() * 0x10000)),
   idempotencyKey: null,
   tagHistory: ImmutableList(),
+  media_modal: ImmutableMap({
+    id: null,
+    description: '',
+    focusX: 0,
+    focusY: 0,
+    dirty: false,
+  }),
   doodle: ImmutableMap({
     fg: 'rgb(  0,    0,    0)',
     bg: 'rgb(255,  255,  255)',
@@ -242,6 +253,17 @@ const insertSuggestion = (state, position, token, completion, path) => {
   });
 };
 
+const ignoreSuggestion = (state, position, token, completion, path) => {
+  return state.withMutations(map => {
+    map.updateIn(path, oldText => `${oldText.slice(0, position + token.length)} ${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.set('suggestions', ImmutableList());
+    map.set('focusDate', new Date());
+    map.set('caretPosition', position + token.length + 1);
+    map.set('idempotencyKey', uuid());
+  });
+};
+
 const sortHashtagsByUse = (state, tags) => {
   const personalHistory = state.get('tagHistory');
 
@@ -455,6 +477,19 @@ export default function compose(state = initialState, action) {
 
         return item;
       }));
+  case INIT_MEDIA_EDIT_MODAL:
+    const media =  state.get('media_attachments').find(item => item.get('id') === action.id);
+    return state.set('media_modal', ImmutableMap({
+      id: action.id,
+      description: media.get('description') || '',
+      focusX: media.getIn(['meta', 'focus', 'x'], 0),
+      focusY: media.getIn(['meta', 'focus', 'y'], 0),
+      dirty: false,
+    }));
+  case COMPOSE_CHANGE_MEDIA_DESCRIPTION:
+    return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true);
+  case COMPOSE_CHANGE_MEDIA_FOCUS:
+    return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true);
   case COMPOSE_MENTION:
     return state.withMutations(map => {
       map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
@@ -476,6 +511,8 @@ export default function compose(state = initialState, action) {
     return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
   case COMPOSE_SUGGESTION_SELECT:
     return insertSuggestion(state, action.position, action.token, action.completion, action.path);
+  case COMPOSE_SUGGESTION_IGNORE:
+    return ignoreSuggestion(state, action.position, action.token, action.completion, action.path);
   case COMPOSE_SUGGESTION_TAGS_UPDATE:
     return updateSuggestionTags(state, action.token);
   case COMPOSE_TAG_HISTORY_UPDATE:
@@ -491,6 +528,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_CHANGE_SUCCESS:
     return state
       .set('is_changing_upload', false)
+      .setIn(['media_modal', 'dirty'], false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
           return fromJS(action.media);
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index c452e834c..7d7fe6fd3 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -40,6 +40,7 @@ import announcements from './announcements';
 import markers from './markers';
 import account_notes from './account_notes';
 import picture_in_picture from './picture_in_picture';
+import accounts_map from './accounts_map';
 
 const reducers = {
   announcements,
@@ -54,6 +55,7 @@ const reducers = {
   status_lists,
   accounts,
   accounts_counters,
+  accounts_map,
   statuses,
   relationships,
   settings,
diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js
index 52b05d69b..ae205c6d5 100644
--- a/app/javascript/flavours/glitch/reducers/modal.js
+++ b/app/javascript/flavours/glitch/reducers/modal.js
@@ -1,19 +1,18 @@
 import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
 import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from 'flavours/glitch/actions/compose';
+import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
 
-const initialState = {
-  modalType: null,
-  modalProps: {},
-};
-
-export default function modal(state = initialState, action) {
+export default function modal(state = ImmutableStack(), action) {
   switch(action.type) {
   case MODAL_OPEN:
-    return { modalType: action.modalType, modalProps: action.modalProps };
+    return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
   case MODAL_CLOSE:
-    return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
+    return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
   case TIMELINE_DELETE:
-    return (state.modalProps.statusId === action.id) ? initialState : state;
+    return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index d66a2b237..920aa6331 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -326,3 +326,45 @@
     margin-top: 10px;
   }
 }
+
+.batch-table__row--muted {
+  color: lighten($ui-base-color, 26%);
+}
+
+.batch-table__row--muted .pending-account__header,
+.batch-table__row--muted .accounts-table {
+  &,
+  a,
+  strong {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--muted .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--attention {
+  color: $gold-star;
+}
+
+.batch-table__row--attention .pending-account__header,
+.batch-table__row--attention .accounts-table {
+  &,
+  a,
+  strong {
+    color: $gold-star;
+  }
+}
+
+.batch-table__row--attention .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: $gold-star;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 4801a4644..92061585a 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -1,3 +1,5 @@
+@use "sass:math";
+
 $no-columns-breakpoint: 600px;
 $sidebar-width: 240px;
 $content-width: 840px;
@@ -593,39 +595,44 @@ body,
 
 .log-entry {
   line-height: 20px;
-  padding: 15px 0;
+  padding: 15px;
+  padding-left: 15px * 2 + 40px;
   background: $ui-base-color;
-  border-bottom: 1px solid lighten($ui-base-color, 4%);
+  border-bottom: 1px solid darken($ui-base-color, 8%);
+  position: relative;
+
+  &:first-child {
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+  }
 
   &:last-child {
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
     border-bottom: 0;
   }
 
+  &:hover {
+    background: lighten($ui-base-color, 4%);
+  }
+
   &__header {
-    display: flex;
-    justify-content: flex-start;
-    align-items: center;
     color: $darker-text-color;
     font-size: 14px;
-    padding: 0 10px;
   }
 
   &__avatar {
-    margin-right: 10px;
+    position: absolute;
+    left: 15px;
+    top: 15px;
 
     .avatar {
-      display: block;
-      margin: 0;
-      border-radius: 50%;
+      border-radius: 4px;
       width: 40px;
       height: 40px;
     }
   }
 
-  &__content {
-    max-width: calc(100% - 90px);
-  }
-
   &__title {
     word-wrap: break-word;
   }
@@ -641,6 +648,14 @@ body,
     text-decoration: none;
     font-weight: 500;
   }
+
+  a {
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
 }
 
 a.name-tag,
@@ -669,8 +684,9 @@ a.inline-name-tag,
 
 a.name-tag,
 .name-tag {
-  display: flex;
+  display: inline-flex;
   align-items: center;
+  vertical-align: top;
 
   .avatar {
     display: block;
@@ -845,6 +861,7 @@ a.name-tag,
     padding: 0 5px;
     margin-bottom: 10px;
     flex: 1 0 50%;
+    max-width: 100%;
   }
 
   .account__header__fields,
@@ -925,10 +942,489 @@ a.name-tag,
   }
 }
 
+.dashboard__counters.admin-account-counters {
+  margin-top: 10px;
+}
+
 .account-badges {
   margin: -2px 0;
 }
 
-.dashboard__counters.admin-account-counters {
-  margin-top: 10px;
+.retention {
+  overflow: auto;
+
+  > h4 {
+    position: sticky;
+    left: 0;
+  }
+
+  &__table {
+    &__number {
+      color: $secondary-text-color;
+      padding: 10px;
+    }
+
+    &__date {
+      white-space: nowrap;
+      padding: 10px 0;
+      text-align: left;
+      min-width: 120px;
+
+      &.retention__table__average {
+        font-weight: 700;
+      }
+    }
+
+    &__size {
+      text-align: center;
+      padding: 10px;
+    }
+
+    &__label {
+      font-weight: 700;
+      color: $darker-text-color;
+    }
+
+    &__box {
+      box-sizing: border-box;
+      background: $ui-highlight-color;
+      padding: 10px;
+      font-weight: 500;
+      color: $primary-text-color;
+      width: 52px;
+      margin: 1px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
+        }
+      }
+    }
+  }
+}
+
+.sparkline {
+  display: block;
+  text-decoration: none;
+  background: lighten($ui-base-color, 4%);
+  border-radius: 4px;
+  padding: 0;
+  position: relative;
+  padding-bottom: 55px + 20px;
+  overflow: hidden;
+
+  &__value {
+    display: flex;
+    line-height: 33px;
+    align-items: flex-end;
+    padding: 20px;
+    padding-bottom: 10px;
+
+    &__total {
+      display: block;
+      margin-right: 10px;
+      font-weight: 500;
+      font-size: 28px;
+      color: $primary-text-color;
+    }
+
+    &__change {
+      display: block;
+      font-weight: 500;
+      font-size: 18px;
+      color: $darker-text-color;
+      margin-bottom: -3px;
+
+      &.positive {
+        color: $valid-value-color;
+      }
+
+      &.negative {
+        color: $error-value-color;
+      }
+    }
+  }
+
+  &__label {
+    padding: 0 20px;
+    padding-bottom: 10px;
+    text-transform: uppercase;
+    color: $darker-text-color;
+    font-weight: 500;
+  }
+
+  &__graph {
+    position: absolute;
+    bottom: 0;
+    width: 100%;
+
+    svg {
+      display: block;
+      margin: 0;
+    }
+
+    path:first-child {
+      fill: rgba($highlight-text-color, 0.25) !important;
+      fill-opacity: 1 !important;
+    }
+
+    path:last-child {
+      stroke: lighten($highlight-text-color, 6%) !important;
+      fill: none !important;
+    }
+  }
+}
+
+a.sparkline {
+  &:hover,
+  &:focus,
+  &:active {
+    background: lighten($ui-base-color, 6%);
+  }
+}
+
+.skeleton {
+  background-color: lighten($ui-base-color, 8%);
+  background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%));
+  background-size: 200px 100%;
+  background-repeat: no-repeat;
+  border-radius: 4px;
+  display: inline-block;
+  line-height: 1;
+  width: 100%;
+  animation: skeleton 1.2s ease-in-out infinite;
+}
+
+@keyframes skeleton {
+  0% {
+    background-position: -200px 0;
+  }
+
+  100% {
+    background-position: calc(200px + 100%) 0;
+  }
+}
+
+.dimension {
+  table {
+    width: 100%;
+  }
+
+  &__item {
+    border-bottom: 1px solid lighten($ui-base-color, 4%);
+
+    &__key {
+      font-weight: 500;
+      padding: 11px 10px;
+    }
+
+    &__value {
+      text-align: right;
+      color: $darker-text-color;
+      padding: 11px 10px;
+    }
+
+    &__indicator {
+      display: inline-block;
+      width: 8px;
+      height: 8px;
+      border-radius: 50%;
+      background: $ui-highlight-color;
+      margin-right: 10px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
+        }
+      }
+    }
+
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
+.report-reason-selector {
+  border-radius: 4px;
+  background: $ui-base-color;
+  margin-bottom: 20px;
+
+  &__category {
+    cursor: pointer;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__label {
+      padding: 15px;
+    }
+
+    &__rules {
+      margin-left: 30px;
+    }
+  }
+
+  &__rule {
+    cursor: pointer;
+    padding: 15px;
+  }
+}
+
+.report-header {
+  display: grid;
+  grid-gap: 15px;
+  grid-template-columns: minmax(0, 1fr) 300px;
+
+  &__details {
+    &__item {
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+      padding: 15px 0;
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__header {
+        font-weight: 600;
+        padding: 4px 0;
+      }
+    }
+
+    &--horizontal {
+      display: grid;
+      grid-auto-columns: minmax(0, 1fr);
+      grid-auto-flow: column;
+
+      .report-header__details__item {
+        border-bottom: 0;
+      }
+    }
+  }
+}
+
+.account-card {
+  background: $ui-base-color;
+  border-radius: 4px;
+
+  &__header {
+    padding: 4px;
+    border-radius: 4px;
+    height: 128px;
+
+    img {
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      background: darken($ui-base-color, 8%);
+    }
+  }
+
+  &__title {
+    margin-top: -25px;
+    display: flex;
+    align-items: flex-end;
+
+    &__avatar {
+      padding: 15px;
+
+      img {
+        display: block;
+        margin: 0;
+        width: 56px;
+        height: 56px;
+        background: darken($ui-base-color, 8%);
+        border-radius: 8px;
+      }
+    }
+
+    .display-name {
+      color: $darker-text-color;
+      padding-bottom: 15px;
+      font-size: 15px;
+
+      bdi {
+        display: block;
+        color: $primary-text-color;
+        font-weight: 500;
+      }
+    }
+  }
+
+  &__bio {
+    padding: 0 15px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    word-wrap: break-word;
+    max-height: 18px * 2;
+    position: relative;
+
+    &::after {
+      display: block;
+      content: "";
+      width: 50px;
+      height: 18px;
+      position: absolute;
+      bottom: 0;
+      right: 15px;
+      background: linear-gradient(to left, $ui-base-color, transparent);
+      pointer-events: none;
+    }
+  }
+
+  &__actions {
+    display: flex;
+    align-items: center;
+    padding-top: 10px;
+
+    &__button {
+      flex: 0 0 auto;
+      padding: 0 15px;
+    }
+  }
+
+  &__counters {
+    flex: 1 1 auto;
+    display: grid;
+    grid-auto-columns: minmax(0, 1fr);
+    grid-auto-flow: column;
+
+    &__item {
+      padding: 15px;
+      text-align: center;
+      color: $primary-text-color;
+      font-weight: 600;
+      font-size: 15px;
+
+      small {
+        display: block;
+        color: $darker-text-color;
+        font-weight: 400;
+        font-size: 13px;
+      }
+    }
+  }
+}
+
+.report-notes {
+  margin-bottom: 20px;
+
+  &__item {
+    background: $ui-base-color;
+    position: relative;
+    padding: 15px;
+    padding-left: 15px * 2 + 40px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:first-child {
+      border-top-left-radius: 4px;
+      border-top-right-radius: 4px;
+    }
+
+    &:last-child {
+      border-bottom-left-radius: 4px;
+      border-bottom-right-radius: 4px;
+      border-bottom: 0;
+    }
+
+    &:hover {
+      background-color: lighten($ui-base-color, 4%);
+    }
+
+    &__avatar {
+      position: absolute;
+      left: 15px;
+      top: 15px;
+      border-radius: 4px;
+      width: 40px;
+      height: 40px;
+    }
+
+    &__header {
+      color: $darker-text-color;
+      font-size: 15px;
+      line-height: 20px;
+      margin-bottom: 4px;
+
+      .username a {
+        color: $primary-text-color;
+        font-weight: 500;
+        text-decoration: none;
+        margin-right: 5px;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+
+      time {
+        margin-left: 5px;
+        vertical-align: baseline;
+      }
+    }
+
+    &__content {
+      font-size: 15px;
+      line-height: 20px;
+      word-wrap: break-word;
+      font-weight: 400;
+      color: $primary-text-color;
+
+      p {
+        margin-bottom: 20px;
+        white-space: pre-wrap;
+        unicode-bidi: plaintext;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+
+    &__actions {
+      position: absolute;
+      top: 15px;
+      right: 15px;
+      text-align: right;
+    }
+  }
+}
+
+.report-actions {
+  border: 1px solid darken($ui-base-color, 8%);
+
+  &__item {
+    display: flex;
+    align-items: center;
+    line-height: 18px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__button {
+      flex: 0 0 auto;
+      width: 100px;
+      padding: 15px;
+      padding-right: 0;
+
+      .button {
+        display: block;
+        width: 100%;
+      }
+    }
+
+    &__description {
+      padding: 15px;
+      font-size: 14px;
+      color: $dark-text-color;
+    }
+  }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index ad17ed4b0..512a04376 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -437,12 +437,17 @@
 }
 
 .column-header__setting-btn {
-  &:hover {
+  &:hover,
+  &:focus {
     color: $darker-text-color;
     text-decoration: underline;
   }
 }
 
+.column-header__collapsible__extra + .column-header__setting-btn {
+  padding-top: 5px;
+}
+
 .column-header__permission-btn {
   display: inline;
   font-weight: inherit;
@@ -453,10 +458,15 @@
   float: right;
 
   .column-header__setting-btn {
-    padding: 0 10px;
+    padding: 5px;
+
+    &:first-child {
+      padding-right: 7px;
+    }
 
     &:last-child {
-      padding-right: 0;
+      padding-left: 7px;
+      margin-left: 5px;
     }
   }
 }
@@ -718,7 +728,8 @@
     }
 
     &__multi-value__label,
-    &__input {
+    &__input,
+    &__input-container {
       color: $darker-text-color;
     }
 
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index edc16e250..dfb9dc595 100644
--- a/app/javascript/flavours/glitch/styles/components/drawer.scss
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -124,20 +124,22 @@
 }
 
 .drawer--results {
-  background: $ui-base-color;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
-  flex: 1 1 auto;
+  overflow-x: hidden;
+  overflow-y: scroll;
+}
 
-  & > header {
-    color: $dark-text-color;
-    background: lighten($ui-base-color, 2%);
+.search-results__section {
+  margin-bottom: 5px;
+
+  h5 {
+    background: darken($ui-base-color, 4%);
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+    cursor: default;
+    display: flex;
     padding: 15px;
     font-weight: 500;
     font-size: 16px;
-    cursor: default;
-    flex: 0 0 auto;
+    color: $dark-text-color;
 
     .fa {
       display: inline-block;
@@ -145,48 +147,22 @@
     }
   }
 
-  & > .search-results__contents {
-    overflow-x: hidden;
-    overflow-y: scroll;
-    flex: 1 1 auto;
-
-    & > section {
-      margin-bottom: 5px;
-
-      h5 {
-        background: darken($ui-base-color, 4%);
-        border-bottom: 1px solid lighten($ui-base-color, 8%);
-        cursor: default;
-        display: flex;
-        padding: 15px;
-        font-weight: 500;
-        font-size: 16px;
-        color: $dark-text-color;
-
-        .fa {
-          display: inline-block;
-          margin-right: 5px;
-        }
-      }
+  .account:last-child,
+  & > div:last-child .status {
+    border-bottom: 0;
+  }
 
-      .account:last-child,
-      & > div:last-child .status {
-        border-bottom: 0;
-      }
+  & > .hashtag {
+    display: block;
+    padding: 10px;
+    color: $secondary-text-color;
+    text-decoration: none;
 
-      & > .hashtag {
-        display: block;
-        padding: 10px;
-        color: $secondary-text-color;
-        text-decoration: none;
-
-        &:hover,
-        &:active,
-        &:focus {
-          color: lighten($secondary-text-color, 4%);
-          text-decoration: underline;
-        }
-      }
+    &:hover,
+    &:active,
+    &:focus {
+      color: lighten($secondary-text-color, 4%);
+      text-decoration: underline;
     }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 24f750e1d..2656890d7 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -829,7 +829,7 @@
   transition: background-color 0.2s ease;
 }
 
-.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+.react-toggle:is(:hover, :focus-within):not(.react-toggle--disabled) .react-toggle-track {
   background-color: darken($ui-base-color, 10%);
 }
 
@@ -837,7 +837,7 @@
   background-color: $ui-highlight-color;
 }
 
-.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+.react-toggle--checked:is(:hover, :focus-within):not(.react-toggle--disabled) .react-toggle-track {
   background-color: lighten($ui-highlight-color, 10%);
 }
 
@@ -977,13 +977,13 @@
     }
 
     @media screen and (max-height: 810px) {
-      .trends__item:nth-child(3) {
+      .trends__item:nth-of-type(3) {
         display: none;
       }
     }
 
     @media screen and (max-height: 720px) {
-      .trends__item:nth-child(2) {
+      .trends__item:nth-of-type(2) {
         display: none;
       }
     }
@@ -1040,6 +1040,7 @@
   background: transparent;
   border: 0;
   border-bottom: 2px solid $ui-primary-color;
+  outline: 0;
   box-sizing: border-box;
   display: block;
   font-family: inherit;
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 855cd07a9..8a551be73 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -400,7 +400,8 @@
     opacity: 0.2;
   }
 
-  .video-player__buttons button {
+  .video-player__buttons button,
+  .video-player__buttons a {
     color: currentColor;
     opacity: 0.75;
 
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index eec2e64d6..f7415368b 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -94,10 +94,15 @@
 .search-results__header {
   color: $dark-text-color;
   background: lighten($ui-base-color, 2%);
-  border-bottom: 1px solid darken($ui-base-color, 4%);
-  padding: 15px 10px;
-  font-size: 14px;
+  padding: 15px;
   font-weight: 500;
+  font-size: 16px;
+  cursor: default;
+
+  .fa {
+    display: inline-block;
+    margin-right: 5px;
+  }
 }
 
 .search-results__info {
@@ -166,7 +171,6 @@
     &__current {
       flex: 0 0 auto;
       font-size: 24px;
-      line-height: 36px;
       font-weight: 500;
       text-align: right;
       padding-right: 15px;
@@ -188,5 +192,57 @@
         fill: none !important;
       }
     }
+
+    &--requires-review {
+      .trends__item__name {
+        color: $gold-star;
+
+        a {
+          color: $gold-star;
+        }
+      }
+
+      .trends__item__current {
+        color: $gold-star;
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba($gold-star, 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten($gold-star, 6%) !important;
+        }
+      }
+    }
+
+    &--disabled {
+      .trends__item__name {
+        color: lighten($ui-base-color, 12%);
+
+        a {
+          color: lighten($ui-base-color, 12%);
+        }
+      }
+
+      .trends__item__current {
+        color: lighten($ui-base-color, 12%);
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
+        }
+      }
+    }
+  }
+
+  &--compact &__item {
+    padding: 10px;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 69c9a6fe3..d9154e4c7 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -205,6 +205,17 @@
   }
 }
 
+.status__content__edited-label {
+  display: block;
+  cursor: default;
+  font-size: 15px;
+  line-height: 20px;
+  padding: 0;
+  padding-top: 8px;
+  color: $dark-text-color;
+  font-weight: 500;
+}
+
 .status__content__spoiler-link {
   display: inline-block;
   border-radius: 2px;
@@ -1103,6 +1114,7 @@ a.status-card.compact:hover {
     &__account {
       display: flex;
       text-decoration: none;
+      overflow: hidden;
     }
 
     .account__avatar {
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 63374f3c3..eb72eab28 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -44,7 +44,7 @@
 }
 
 .compose-standalone {
-  .compose-form {
+  .composer {
     width: 400px;
     margin: 0 auto;
     padding: 20px 0;
diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
index 0f3a6cc6d..9bd31cd7e 100644
--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss
+++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
@@ -1,17 +1,4 @@
 // components.scss
-.compose-form {
-  .compose-form__modifiers {
-    .compose-form__upload {
-      &-description {
-        input {
-          &::placeholder {
-            opacity: 1.0;
-          }
-        }
-      }
-    }
-  }
-}
 
 .rich-formatting a,
 .rich-formatting p a,
diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss
index c0944d417..0a881bc10 100644
--- a/app/javascript/flavours/glitch/styles/dashboard.scss
+++ b/app/javascript/flavours/glitch/styles/dashboard.scss
@@ -56,23 +56,70 @@
   }
 }
 
-.dashboard__widgets {
-  display: flex;
-  flex-wrap: wrap;
-  margin: 0 -5px;
+.dashboard {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+  grid-gap: 10px;
 
-  & > div {
-    flex: 0 0 33.333%;
-    margin-bottom: 20px;
+  @media screen and (max-width: 1350px) {
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  }
 
-    & > div {
-      padding: 0 5px;
+  &__item {
+    &--span-double-column {
+      grid-column: span 2;
+    }
+
+    &--span-double-row {
+      grid-row: span 2;
+    }
+
+    h4 {
+      padding-top: 20px;
     }
   }
 
-  a:not(.name-tag) {
-    color: $ui-secondary-color;
-    font-weight: 500;
+  &__quick-access {
+    display: flex;
+    align-items: baseline;
+    border-radius: 4px;
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+    transition: all 100ms ease-in;
+    font-size: 14px;
+    padding: 0 16px;
+    line-height: 36px;
+    height: 36px;
     text-decoration: none;
+    margin-bottom: 4px;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-highlight-color, 10%);
+      transition: all 200ms ease-out;
+    }
+
+    &.positive {
+      background: lighten($ui-base-color, 4%);
+      color: $valid-value-color;
+    }
+
+    &.negative {
+      background: lighten($ui-base-color, 4%);
+      color: $error-value-color;
+    }
+
+    span {
+      flex: 1 1 auto;
+    }
+
+    .fa {
+      flex: 0 0 auto;
+    }
+
+    strong {
+      font-weight: 700;
+    }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 0d8c35a764..034350525 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -1052,3 +1052,7 @@ code {
     display: none;
   }
 }
+
+.simple_form .h-captcha {
+  text-align: center;
+}
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
index 5fc41ed9e..a2cdecf06 100644
--- a/app/javascript/flavours/glitch/styles/polls.scss
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -150,6 +150,21 @@
     &:active {
       outline: 0 !important;
     }
+
+    &.disabled {
+      border-color: $dark-text-color;
+
+      &.active {
+        background: $dark-text-color;
+      }
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $dark-text-color;
+        border-width: 1px;
+      }
+    }
   }
 
   &__number {
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
index f6a90d271..afa05d93e 100644
--- a/app/javascript/flavours/glitch/styles/rtl.scss
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -51,7 +51,7 @@ body.rtl {
     margin-left: 5px;
   }
 
-  .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {
+  .composer .compose--counter-wrapper {
     margin-right: 0;
     margin-left: 4px;
   }
@@ -112,6 +112,20 @@ body.rtl {
 
   .column-header__setting-arrows {
     float: left;
+
+    .column-header__setting-btn {
+      &:first-child {
+        padding-left: 7px;
+        padding-right: 5px;
+      }
+
+      &:last-child {
+        padding-right: 7px;
+        padding-left: 5px;
+        margin-right: 5px;
+        margin-left: 0;
+      }
+    }
   }
 
   .setting-toggle__label {
@@ -428,11 +442,6 @@ body.rtl {
     margin-left: 5px;
   }
 
-  .column-header__setting-arrows .column-header__setting-btn:last-child {
-    padding-left: 0;
-    padding-right: 10px;
-  }
-
   .simple_form .input.radio_buttons .radio > label input {
     left: auto;
     right: 0;
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
index ec2ee7c1c..12c84a6c9 100644
--- a/app/javascript/flavours/glitch/styles/tables.scss
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -237,6 +237,11 @@ a.table-action-link {
         flex: 1 1 auto;
       }
 
+      &__quote {
+        padding: 12px;
+        padding-top: 0;
+      }
+
       &__extra {
         flex: 0 0 auto;
         text-align: right;
diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index 06bf55e1e..a88f3b2c7 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -434,6 +434,24 @@
     }
   }
 
+  tbody td.accounts-table__extra {
+    width: 120px;
+    text-align: right;
+    color: $darker-text-color;
+    padding-right: 16px;
+
+    a {
+      text-decoration: none;
+      color: inherit;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+  }
+
   &__comment {
     width: 50%;
     vertical-align: initial !important;
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
index 2a98e4c29..ee2b699b2 100644
--- a/app/javascript/flavours/glitch/theme.yml
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -1,7 +1,7 @@
 #  (REQUIRED) The location of the pack files.
 pack:
   about: packs/about.js
-  admin: packs/public.js
+  admin: packs/admin.js
   auth: packs/public.js
   common:
     filename: packs/common.js
diff --git a/app/javascript/flavours/glitch/util/api.js b/app/javascript/flavours/glitch/util/api.js
index c59a24518..90d8465ef 100644
--- a/app/javascript/flavours/glitch/util/api.js
+++ b/app/javascript/flavours/glitch/util/api.js
@@ -12,21 +12,35 @@ export const getLinks = response => {
   return LinkHeader.parse(value);
 };
 
-let csrfHeader = {};
+const csrfHeader = {};
 
-function setCSRFHeader() {
+const setCSRFHeader = () => {
   const csrfToken = document.querySelector('meta[name=csrf-token]');
+
   if (csrfToken) {
     csrfHeader['X-CSRF-Token'] = csrfToken.content;
   }
-}
+};
 
 ready(setCSRFHeader);
 
+const authorizationHeaderFromState = getState => {
+  const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
+
+  if (!accessToken) {
+    return {};
+  }
+
+  return {
+    'Authorization': `Bearer ${accessToken}`,
+  };
+};
+
 export default getState => axios.create({
-  headers: Object.assign(csrfHeader, getState ? {
-    'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
-  } : {}),
+  headers: {
+    ...csrfHeader,
+    ...authorizationHeaderFromState(getState),
+  },
 
   transformResponse: [function (data) {
     try {
diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js
index 0fb378cc1..2e5111a7f 100644
--- a/app/javascript/flavours/glitch/util/backend_links.js
+++ b/app/javascript/flavours/glitch/util/backend_links.js
@@ -3,7 +3,7 @@ export const profileLink = '/settings/profile';
 export const signOutLink = '/auth/sign_out';
 export const termsLink = '/terms';
 export const accountAdminLink = (id) => `/admin/accounts/${id}`;
-export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
+export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses?id=${status_id}`;
 export const filterEditLink = (id) => `/filters/${id}/edit`;
 export const relationshipsLink = '/relationships';
 export const securityLink = '/auth/edit';
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 370d982d2..7154e020b 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -25,6 +25,8 @@ export const maxChars = (initialState && initialState.max_toot_chars) || 500;
 export const pollLimits = (initialState && initialState.poll_limits);
 export const invitesEnabled = getMeta('invites_enabled');
 export const limitedFederationMode = getMeta('limited_federation_mode');
+export const repository = getMeta('repository');
+export const source_url = getMeta('source_url');
 export const version = getMeta('version');
 export const mascot = getMeta('mascot');
 export const profile_directory = getMeta('profile_directory');
diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js
index 6f2505cae..6ef563ad8 100644
--- a/app/javascript/flavours/glitch/util/numbers.js
+++ b/app/javascript/flavours/glitch/util/numbers.js
@@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
 
   return Math.trunc(sourceNumber / closestScale) * closestScale;
 }
+
+/**
+ * @param {number} num
+ * @returns {number}
+ */
+export function roundTo10(num) {
+  return Math.round(num * 0.1) / 0.1;
+}
diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml
index 74e9fb1b5..3263fd7d4 100644
--- a/app/javascript/flavours/vanilla/theme.yml
+++ b/app/javascript/flavours/vanilla/theme.yml
@@ -1,7 +1,7 @@
 #  (REQUIRED) The location of the pack files inside `pack_directory`.
 pack:
   about: about.js
-  admin: public.js
+  admin: admin.js
   auth: public.js
   common:
     filename: common.js
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index 58b636602..ce7bb6d5f 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -5,6 +5,10 @@ export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
 export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
 export const ACCOUNT_FETCH_FAIL    = 'ACCOUNT_FETCH_FAIL';
 
+export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST';
+export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS';
+export const ACCOUNT_LOOKUP_FAIL    = 'ACCOUNT_LOOKUP_FAIL';
+
 export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
 export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
 export const ACCOUNT_FOLLOW_FAIL    = 'ACCOUNT_FOLLOW_FAIL';
@@ -87,6 +91,34 @@ export function fetchAccount(id) {
   };
 };
 
+export const lookupAccount = acct => (dispatch, getState) => {
+  dispatch(lookupAccountRequest(acct));
+
+  api(getState).get('/api/v1/accounts/lookup', { params: { acct } }).then(response => {
+    dispatch(fetchRelationships([response.data.id]));
+    dispatch(importFetchedAccount(response.data));
+    dispatch(lookupAccountSuccess());
+  }).catch(error => {
+    dispatch(lookupAccountFail(acct, error));
+  });
+};
+
+export const lookupAccountRequest = (acct) => ({
+  type: ACCOUNT_LOOKUP_REQUEST,
+  acct,
+});
+
+export const lookupAccountSuccess = () => ({
+  type: ACCOUNT_LOOKUP_SUCCESS,
+});
+
+export const lookupAccountFail = (acct, error) => ({
+  type: ACCOUNT_LOOKUP_FAIL,
+  acct,
+  error,
+  skipAlert: true,
+});
+
 export function fetchAccountRequest(id) {
   return {
     type: ACCOUNT_FETCH_REQUEST,
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index a60373fd5..7c3bbcbd8 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -9,6 +9,7 @@ import { importFetchedAccounts } from './importer';
 import { updateTimeline } from './timelines';
 import { showAlertForError } from './alerts';
 import { showAlert } from './alerts';
+import { openModal } from './modal';
 import { defineMessages } from 'react-intl';
 
 let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
@@ -36,6 +37,7 @@ export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
 export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
 export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
 export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+export const COMPOSE_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE';
 export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
 
 export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
@@ -63,6 +65,11 @@ export const COMPOSE_POLL_OPTION_CHANGE   = 'COMPOSE_POLL_OPTION_CHANGE';
 export const COMPOSE_POLL_OPTION_REMOVE   = 'COMPOSE_POLL_OPTION_REMOVE';
 export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
 
+export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
+
+export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
+export const COMPOSE_CHANGE_MEDIA_FOCUS       = 'COMPOSE_CHANGE_MEDIA_FOCUS';
+
 const messages = defineMessages({
   uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
   uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@@ -72,7 +79,7 @@ const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
 
 export const ensureComposeIsVisible = (getState, routerHistory) => {
   if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
-    routerHistory.push('/statuses/new');
+    routerHistory.push('/publish');
   }
 };
 
@@ -152,7 +159,7 @@ export function submitCompose(routerHistory) {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
       },
     }).then(function (response) {
-      if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) {
+      if (routerHistory && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new') && window.history.state) {
         routerHistory.goBack();
       }
 
@@ -247,12 +254,15 @@ export function uploadCompose(files) {
           if (status === 200) {
             dispatch(uploadComposeSuccess(data, f));
           } else if (status === 202) {
+            let tryCount = 1;
             const poll = () => {
               api(getState).get(`/api/v1/media/${data.id}`).then(response => {
                 if (response.status === 200) {
                   dispatch(uploadComposeSuccess(response.data, f));
                 } else if (response.status === 206) {
-                  setTimeout(() => poll(), 1000);
+                  let retryAfter = (Math.log2(tryCount) || 1) * 1000;
+                  tryCount += 1;
+                  setTimeout(() => poll(), retryAfter);
                 }
               }).catch(error => dispatch(uploadComposeFail(error)));
             };
@@ -308,6 +318,32 @@ export const uploadThumbnailFail = error => ({
   skipLoading: true,
 });
 
+export function initMediaEditModal(id) {
+  return dispatch => {
+    dispatch({
+      type: INIT_MEDIA_EDIT_MODAL,
+      id,
+    });
+
+    dispatch(openModal('FOCAL_POINT', { id }));
+  };
+};
+
+export function onChangeMediaDescription(description) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+    description,
+  };
+};
+
+export function onChangeMediaFocus(focusX, focusY) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_FOCUS,
+    focusX,
+    focusY,
+  };
+};
+
 export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
@@ -504,13 +540,25 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
       startPosition = position;
     }
 
-    dispatch({
-      type: COMPOSE_SUGGESTION_SELECT,
-      position: startPosition,
-      token,
-      completion,
-      path,
-    });
+    // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
+    // the suggestions are dismissed and the cursor moves forward.
+    if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
+      dispatch({
+        type: COMPOSE_SUGGESTION_SELECT,
+        position: startPosition,
+        token,
+        completion,
+        path,
+      });
+    } else {
+      dispatch({
+        type: COMPOSE_SUGGESTION_IGNORE,
+        position: startPosition,
+        token,
+        completion,
+        path,
+      });
+    }
   };
 };
 
diff --git a/app/javascript/mastodon/actions/identity_proofs.js b/app/javascript/mastodon/actions/identity_proofs.js
deleted file mode 100644
index 103983956..000000000
--- a/app/javascript/mastodon/actions/identity_proofs.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import api from '../api';
-
-export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST';
-export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS';
-export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL    = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL';
-
-export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => {
-  dispatch(fetchAccountIdentityProofsRequest(accountId));
-
-  api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`)
-    .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data)))
-    .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err)));
-};
-
-export const fetchAccountIdentityProofsRequest = id => ({
-  type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
-  id,
-});
-
-export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({
-  type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
-  accountId,
-  identity_proofs,
-});
-
-export const fetchAccountIdentityProofsFail = (accountId, err) => ({
-  type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
-  accountId,
-  err,
-  skipNotFound: true,
-});
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 5002292b9..ca76e3494 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -54,9 +54,10 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
-  // Only calculate these values when status first encountered
-  // Otherwise keep the ones already in the reducer
-  if (normalOldStatus) {
+  // Only calculate these values when status first encountered and
+  // when the underlying values change. Otherwise keep the ones
+  // already in the reducer
+  if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
@@ -71,7 +72,7 @@ export function normalizeStatus(status, normalOldStatus) {
     }
 
     const spoilerText   = normalStatus.spoiler_text || '';
-    const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).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)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
     const emojiMap      = makeEmojiMap(normalStatus);
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 3464ac995..663cf21e3 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -1,6 +1,6 @@
 import api, { getLinks } from '../api';
 import IntlMessageFormat from 'intl-messageformat';
-import { fetchRelationships } from './accounts';
+import { fetchFollowRequests, fetchRelationships } from './accounts';
 import {
   importFetchedAccount,
   importFetchedAccounts,
@@ -78,6 +78,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
       filtered = regex && regex.test(searchIndex);
     }
 
+    if (['follow_request'].includes(notification.type)) {
+      dispatch(fetchFollowRequests());
+    }
+
     dispatch(submitMarkers());
 
     if (showInColumn) {
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 3fc7c0702..20d71362e 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -131,6 +131,9 @@ export function deleteStatusFail(id, error) {
   };
 };
 
+export const updateStatus = status => dispatch =>
+  dispatch(importFetchedStatus(status));
+
 export function fetchContext(id) {
   return (dispatch, getState) => {
     dispatch(fetchContextRequest(id));
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index beb5c6a4a..8fbb22271 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -10,6 +10,7 @@ import {
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
 import { updateConversations } from './conversations';
+import { updateStatus } from './statuses';
 import {
   fetchAnnouncements,
   updateAnnouncements,
@@ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'update':
           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
           break;
+        case 'status.update':
+          dispatch(updateStatus(JSON.parse(data.payload)));
+          break;
         case 'delete':
           dispatch(deleteFromTimelines(data.payload));
           break;
diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js
index 98d59de43..645ef6500 100644
--- a/app/javascript/mastodon/api.js
+++ b/app/javascript/mastodon/api.js
@@ -12,21 +12,35 @@ export const getLinks = response => {
   return LinkHeader.parse(value);
 };
 
-let csrfHeader = {};
+const csrfHeader = {};
 
-function setCSRFHeader() {
+const setCSRFHeader = () => {
   const csrfToken = document.querySelector('meta[name=csrf-token]');
+
   if (csrfToken) {
     csrfHeader['X-CSRF-Token'] = csrfToken.content;
   }
-}
+};
 
 ready(setCSRFHeader);
 
+const authorizationHeaderFromState = getState => {
+  const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
+
+  if (!accessToken) {
+    return {};
+  }
+
+  return {
+    'Authorization': `Bearer ${accessToken}`,
+  };
+};
+
 export default getState => axios.create({
-  headers: Object.assign(csrfHeader, getState ? {
-    'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
-  } : {}),
+  headers: {
+    ...csrfHeader,
+    ...authorizationHeaderFromState(getState),
+  },
 
   transformResponse: [function (data) {
     try {
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index a85d683a7..62b5843a9 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -118,7 +118,7 @@ class Account extends ImmutablePureComponent {
     return (
       <div className='account'>
         <div className='account__wrapper'>
-          <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+          <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             {mute_expires_at}
             <DisplayName account={account} />
diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js
new file mode 100644
index 000000000..047e864b2
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Counter.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedNumber } from 'react-intl';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import classNames from 'classnames';
+import Skeleton from 'mastodon/components/skeleton';
+
+const percIncrease = (a, b) => {
+  let percent;
+
+  if (b !== 0) {
+    if (a !== 0) {
+      percent = (b - a) / a;
+    } else {
+      percent = 1;
+    }
+  } else if (b === 0 && a === 0) {
+    percent = 0;
+  } else {
+    percent = - 1;
+  }
+
+  return percent;
+};
+
+export default class Counter extends React.PureComponent {
+
+  static propTypes = {
+    measure: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    label: PropTypes.string.isRequired,
+    href: PropTypes.string,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { measure, start_at, end_at, params } = this.props;
+
+    api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, href } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><Skeleton width={43} /></span>
+          <span className='sparkline__value__change'><Skeleton width={43} /></span>
+        </React.Fragment>
+      );
+    } else {
+      const measure = data[0];
+      const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
+
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
+          <span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
+        </React.Fragment>
+      );
+    }
+
+    const inner = (
+      <React.Fragment>
+        <div className='sparkline__value'>
+          {content}
+        </div>
+
+        <div className='sparkline__label'>
+          {label}
+        </div>
+
+        <div className='sparkline__graph'>
+          {!loading && (
+            <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
+              <SparklinesCurve />
+            </Sparklines>
+          )}
+        </div>
+      </React.Fragment>
+    );
+
+    if (href) {
+      return (
+        <a href={href} className='sparkline'>
+          {inner}
+        </a>
+      );
+    } else {
+      return (
+        <div className='sparkline'>
+          {inner}
+        </div>
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/mastodon/components/admin/Dimension.js b/app/javascript/mastodon/components/admin/Dimension.js
new file mode 100644
index 000000000..977c8208d
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Dimension.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedNumber } from 'react-intl';
+import { roundTo10 } from 'mastodon/utils/numbers';
+import Skeleton from 'mastodon/components/skeleton';
+
+export default class Dimension extends React.PureComponent {
+
+  static propTypes = {
+    dimension: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    limit: PropTypes.number.isRequired,
+    label: PropTypes.string.isRequired,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, dimension, limit, params } = this.props;
+
+    api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <table>
+          <tbody>
+            {Array.from(Array(limit)).map((_, i) => (
+              <tr className='dimension__item' key={i}>
+                <td className='dimension__item__key'>
+                  <Skeleton width={100} />
+                </td>
+
+                <td className='dimension__item__value'>
+                  <Skeleton width={60} />
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    } else {
+      const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
+
+      content = (
+        <table>
+          <tbody>
+            {data[0].data.map(item => (
+              <tr className='dimension__item' key={item.key}>
+                <td className='dimension__item__key'>
+                  <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
+                  <span title={item.key}>{item.human_key}</span>
+                </td>
+
+                <td className='dimension__item__value'>
+                  {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    return (
+      <div className='dimension'>
+        <h4>{label}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/admin/ReportReasonSelector.js b/app/javascript/mastodon/components/admin/ReportReasonSelector.js
new file mode 100644
index 000000000..1f91d2517
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/ReportReasonSelector.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  other: { id: 'report.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
+});
+
+class Category extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onSelect: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onSelect } = this.props;
+
+    if (!disabled) {
+      onSelect(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected, children } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
+        {selected && <input type='hidden' name='report[category]' value={id} />}
+
+        <div className='report-reason-selector__category__label'>
+          <span className={classNames('poll__input', { active: selected, disabled })} />
+          {text}
+        </div>
+
+        {(selected && children) && (
+          <div className='report-reason-selector__category__rules'>
+            {children}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+class Rule extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onToggle: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onToggle } = this.props;
+
+    if (!disabled) {
+      onToggle(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
+        <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
+        {selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
+        {text}
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class ReportReasonSelector extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    category: PropTypes.string.isRequired,
+    rule_ids: PropTypes.arrayOf(PropTypes.string),
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    category: this.props.category,
+    rule_ids: this.props.rule_ids || [],
+    rules: [],
+  };
+
+  componentDidMount() {
+    api().get('/api/v1/instance').then(res => {
+      this.setState({
+        rules: res.data.rules,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  _save = () => {
+    const { id, disabled } = this.props;
+    const { category, rule_ids } = this.state;
+
+    if (disabled) {
+      return;
+    }
+
+    api().put(`/api/v1/admin/reports/${id}`, {
+      category,
+      rule_ids,
+    }).catch(err => {
+      console.error(err);
+    });
+  };
+
+  handleSelect = id => {
+    this.setState({ category: id }, () => this._save());
+  };
+
+  handleToggle = id => {
+    const { rule_ids } = this.state;
+
+    if (rule_ids.includes(id)) {
+      this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
+    } else {
+      this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
+    }
+  };
+
+  render () {
+    const { disabled, intl } = this.props;
+    const { rules, category, rule_ids } = this.state;
+
+    return (
+      <div className='report-reason-selector'>
+        <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
+          {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
+        </Category>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/admin/Retention.js b/app/javascript/mastodon/components/admin/Retention.js
new file mode 100644
index 000000000..47c9e7151
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Retention.js
@@ -0,0 +1,151 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
+import classNames from 'classnames';
+import { roundTo10 } from 'mastodon/utils/numbers';
+
+const dateForCohort = cohort => {
+  switch(cohort.frequency) {
+  case 'day':
+    return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
+  default:
+    return <FormattedDate value={cohort.period} month='long' year='numeric' />;
+  }
+};
+
+export default class Retention extends React.PureComponent {
+
+  static propTypes = {
+    start_at: PropTypes.string,
+    end_at: PropTypes.string,
+    frequency: PropTypes.string,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, frequency } = this.props;
+
+    api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { loading, data } = this.state;
+    const { frequency } = this.props;
+
+    let content;
+
+    if (loading) {
+      content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
+    } else {
+      content = (
+        <table className='retention__table'>
+          <thead>
+            <tr>
+              <th>
+                <div className='retention__table__date retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
+                </div>
+              </th>
+
+              <th>
+                <div className='retention__table__number retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
+                </div>
+              </th>
+
+              {data[0].data.slice(1).map((retention, i) => (
+                <th key={retention.date}>
+                  <div className='retention__table__number retention__table__label'>
+                    {i + 1}
+                  </div>
+                </th>
+              ))}
+            </tr>
+
+            <tr>
+              <td>
+                <div className='retention__table__date retention__table__average'>
+                  <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
+                </div>
+              </td>
+
+              <td>
+                <div className='retention__table__size'>
+                  <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
+                </div>
+              </td>
+
+              {data[0].data.slice(1).map((retention, i) => {
+                const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
+
+                return (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
+                      <FormattedNumber value={average} style='percent' />
+                    </div>
+                  </td>
+                );
+              })}
+            </tr>
+          </thead>
+
+          <tbody>
+            {data.slice(0, -1).map(cohort => (
+              <tr key={cohort.period}>
+                <td>
+                  <div className='retention__table__date'>
+                    {dateForCohort(cohort)}
+                  </div>
+                </td>
+
+                <td>
+                  <div className='retention__table__size'>
+                    <FormattedNumber value={cohort.data[0].value} />
+                  </div>
+                </td>
+
+                {cohort.data.slice(1).map(retention => (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}>
+                      <FormattedNumber value={retention.rate} style='percent' />
+                    </div>
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    let title = null;
+    switch(frequency) {
+    case 'day':
+      title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
+      break;
+    default:
+      title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
+    };
+
+    return (
+      <div className='retention'>
+        <h4>{title}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js
new file mode 100644
index 000000000..635bdf37d
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/Trends.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Hashtag from 'mastodon/components/hashtag';
+
+export default class Trends extends React.PureComponent {
+
+  static propTypes = {
+    limit: PropTypes.number.isRequired,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { limit } = this.props;
+
+    api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <div>
+          {Array.from(Array(limit)).map((_, i) => (
+            <Hashtag key={i} />
+          ))}
+        </div>
+      );
+    } else {
+      content = (
+        <div>
+          {data.map(hashtag => (
+            <Hashtag
+              key={hashtag.name}
+              name={hashtag.name}
+              href={`/admin/tags/${hashtag.id}`}
+              people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
+              uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
+              history={hashtag.history.reverse().map(day => day.uses)}
+              className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
+            />
+          ))}
+        </div>
+      );
+    }
+
+    return (
+      <div className='trends trends--compact'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js
index ebd696583..0e23889de 100644
--- a/app/javascript/mastodon/components/attachment_list.js
+++ b/app/javascript/mastodon/components/attachment_list.js
@@ -2,6 +2,8 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 
 const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@@ -16,29 +18,13 @@ export default class AttachmentList extends ImmutablePureComponent {
   render () {
     const { media, compact } = this.props;
 
-    if (compact) {
-      return (
-        <div className='attachment-list compact'>
-          <ul className='attachment-list__list'>
-            {media.map(attachment => {
-              const displayUrl = attachment.get('remote_url') || attachment.get('url');
-
-              return (
-                <li key={attachment.get('id')}>
-                  <a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a>
-                </li>
-              );
-            })}
-          </ul>
-        </div>
-      );
-    }
-
     return (
-      <div className='attachment-list'>
-        <div className='attachment-list__icon'>
-          <Icon id='link' />
-        </div>
+      <div className={classNames('attachment-list', { compact })}>
+        {!compact && (
+          <div className='attachment-list__icon'>
+            <Icon id='link' />
+          </div>
+        )}
 
         <ul className='attachment-list__list'>
           {media.map(attachment => {
@@ -46,7 +32,11 @@ export default class AttachmentList extends ImmutablePureComponent {
 
             return (
               <li key={attachment.get('id')}>
-                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a>
+                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>
+                  {compact && <Icon id='link' />}
+                  {compact && ' ' }
+                  {displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
+                </a>
               </li>
             );
           })}
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index 236e92296..cbbc490a8 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -119,8 +119,8 @@ class ColumnHeader extends React.PureComponent {
 
       moveButtons = (
         <div key='move-buttons' className='column-header__setting-arrows'>
-          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
-          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
+          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
+          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
         </div>
       );
     } else if (multiColumn && this.props.onPin) {
@@ -141,8 +141,8 @@ class ColumnHeader extends React.PureComponent {
     ];
 
     if (multiColumn) {
-      collapsedContent.push(moveButtons);
       collapsedContent.push(pinButton);
+      collapsedContent.push(moveButtons);
     }
 
     if (children || (multiColumn && this.props.onPin)) {
diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js
index 75fcf20e3..a793a32f5 100644
--- a/app/javascript/mastodon/components/hashtag.js
+++ b/app/javascript/mastodon/components/hashtag.js
@@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
 import ShortNumber from 'mastodon/components/short_number';
+import Skeleton from 'mastodon/components/skeleton';
+import classNames from 'classnames';
 
 class SilentErrorBoundary extends React.Component {
 
@@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
   />
 );
 
-const Hashtag = ({ hashtag }) => (
-  <div className='trends__item'>
+export const ImmutableHashtag = ({ hashtag }) => (
+  <Hashtag
+    name={hashtag.get('name')}
+    href={hashtag.get('url')}
+    to={`/tags/${hashtag.get('name')}`}
+    people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+    uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
+    history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+  />
+);
+
+ImmutableHashtag.propTypes = {
+  hashtag: ImmutablePropTypes.map.isRequired,
+};
+
+const Hashtag = ({ name, href, to, people, uses, history, className }) => (
+  <div className={classNames('trends__item', className)}>
     <div className='trends__item__name'>
-      <Permalink
-        href={hashtag.get('url')}
-        to={`/timelines/tag/${hashtag.get('name')}`}
-      >
-        #<span>{hashtag.get('name')}</span>
+      <Permalink href={href} to={to}>
+        {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
       </Permalink>
 
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'accounts']) * 1 +
-          hashtag.getIn(['history', 1, 'accounts']) * 1
-        }
-        renderer={accountsCountRenderer}
-      />
+      {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
     </div>
 
     <div className='trends__item__current'>
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'uses']) * 1 +
-          hashtag.getIn(['history', 1, 'uses']) * 1
-        }
-      />
+      {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
     </div>
 
     <div className='trends__item__sparkline'>
       <SilentErrorBoundary>
-        <Sparklines
-          width={50}
-          height={28}
-          data={hashtag
-            .get('history')
-            .reverse()
-            .map((day) => day.get('uses'))
-            .toArray()}
-        >
+        <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
           <SparklinesCurve style={{ fill: 'none' }} />
         </Sparklines>
       </SilentErrorBoundary>
@@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
 );
 
 Hashtag.propTypes = {
-  hashtag: ImmutablePropTypes.map.isRequired,
+  name: PropTypes.string,
+  href: PropTypes.string,
+  to: PropTypes.string,
+  people: PropTypes.number,
+  uses: PropTypes.number,
+  history: PropTypes.arrayOf(PropTypes.number),
+  className: PropTypes.string,
 };
 
 export default Hashtag;
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
index 2d87f19b5..26f85fa40 100644
--- a/app/javascript/mastodon/components/intersection_observer_article.js
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -93,7 +93,7 @@ export default class IntersectionObserverArticle extends React.Component {
     // When the browser gets a chance, test if we're still not intersecting,
     // and if so, set our isHidden to true to trigger an unrender. The point of
     // this is to save DOM nodes and avoid using up too much memory.
-    // See: https://github.com/tootsuite/mastodon/issues/2900
+    // See: https://github.com/mastodon/mastodon/issues/2900
     this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
   }
 
diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js
index 26344528e..755c46fd6 100644
--- a/app/javascript/mastodon/components/modal_root.js
+++ b/app/javascript/mastodon/components/modal_root.js
@@ -1,10 +1,15 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import 'wicg-inert';
+import { createBrowserHistory } from 'history';
 import { multiply } from 'color-blend';
 
 export default class ModalRoot extends React.PureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
   static propTypes = {
     children: PropTypes.node,
     onClose: PropTypes.func.isRequired,
@@ -48,6 +53,7 @@ export default class ModalRoot extends React.PureComponent {
   componentDidMount () {
     window.addEventListener('keyup', this.handleKeyUp, false);
     window.addEventListener('keydown', this.handleKeyDown, false);
+    this.history = this.context.router ? this.context.router.history : createBrowserHistory();
   }
 
   componentWillReceiveProps (nextProps) {
@@ -69,6 +75,14 @@ export default class ModalRoot extends React.PureComponent {
         this.activeElement.focus({ preventScroll: true });
         this.activeElement = null;
       }).catch(console.error);
+
+      this._handleModalClose();
+    }
+    if (this.props.children && !prevProps.children) {
+      this._handleModalOpen();
+    }
+    if (this.props.children) {
+      this._ensureHistoryBuffer();
     }
   }
 
@@ -77,6 +91,32 @@ export default class ModalRoot extends React.PureComponent {
     window.removeEventListener('keydown', this.handleKeyDown);
   }
 
+  _handleModalOpen () {
+    this._modalHistoryKey = Date.now();
+    this.unlistenHistory = this.history.listen((_, action) => {
+      if (action === 'POP') {
+        this.props.onClose();
+      }
+    });
+  }
+
+  _handleModalClose () {
+    if (this.unlistenHistory) {
+      this.unlistenHistory();
+    }
+    const { state } = this.history.location;
+    if (state && state.mastodonModalKey === this._modalHistoryKey) {
+      this.history.goBack();
+    }
+  }
+
+  _ensureHistoryBuffer () {
+    const { pathname, state } = this.history.location;
+    if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
+      this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
+    }
+  }
+
   getSiblings = () => {
     return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
   }
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index 477f56e13..85aa28816 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -12,8 +12,18 @@ import RelativeTimestamp from './relative_timestamp';
 import Icon from 'mastodon/components/icon';
 
 const messages = defineMessages({
-  closed: { id: 'poll.closed', defaultMessage: 'Closed' },
-  voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
+  closed: {
+    id: 'poll.closed',
+    defaultMessage: 'Closed',
+  },
+  voted: {
+    id: 'poll.voted',
+    defaultMessage: 'You voted for this answer',
+  },
+  votes: {
+    id: 'poll.votes',
+    defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
+  },
 });
 
 const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
@@ -148,9 +158,16 @@ class Poll extends ImmutablePureComponent {
               data-index={optionIndex}
             />
           )}
-          {showResults && <span className='poll__number'>
-            {Math.round(percent)}%
-          </span>}
+          {showResults && (
+            <span
+              className='poll__number'
+              title={intl.formatMessage(messages.votes, {
+                votes: option.get('votes_count'),
+              })}
+            >
+              {Math.round(percent)}%
+            </span>
+          )}
 
           <span
             className='poll__option__text translate'
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index 2689b18ef..68a178512 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -1,5 +1,5 @@
 import React, { PureComponent } from 'react';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'mastodon/containers/scroll_container';
 import PropTypes from 'prop-types';
 import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
 import LoadMore from './load_more';
@@ -34,7 +34,6 @@ class ScrollableList extends PureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     showLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
@@ -290,7 +289,7 @@ class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -356,7 +355,7 @@ class ScrollableList extends PureComponent {
 
     if (trackScroll) {
       return (
-        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+        <ScrollContainer scrollKey={scrollKey}>
           {scrollableArea}
         </ScrollContainer>
       );
diff --git a/app/javascript/mastodon/components/skeleton.js b/app/javascript/mastodon/components/skeleton.js
new file mode 100644
index 000000000..09093e99c
--- /dev/null
+++ b/app/javascript/mastodon/components/skeleton.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
+
+Skeleton.propTypes = {
+  width: PropTypes.number,
+  height: PropTypes.number,
+};
+
+export default Skeleton;
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 295e83f58..fb370ca71 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -57,6 +57,7 @@ const messages = defineMessages({
   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+  edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
 });
 
 export default @injectIntl
@@ -134,42 +135,32 @@ class Status extends ImmutablePureComponent {
     this.setState({ showMedia: !this.state.showMedia });
   }
 
-  handleClick = () => {
-    if (this.props.onClick) {
-      this.props.onClick();
+  handleClick = e => {
+    if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
       return;
     }
 
-    if (!this.context.router) {
-      return;
+    if (e) {
+      e.preventDefault();
     }
 
-    const { status } = this.props;
-    this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
+    this.handleHotkeyOpen();
   }
 
-  handleExpandClick = (e) => {
-    if (this.props.onClick) {
-      this.props.onClick();
-      return;
-    }
-
-    if (e.button === 0) {
-      if (!this.context.router) {
-        return;
-      }
+  handlePrependAccountClick = e => {
+    this.handleAccountClick(e, false);
+  }
 
-      const { status } = this.props;
-      this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
+  handleAccountClick = (e, proper = true) => {
+    if (e && (e.button !== 0 || e.ctrlKey || e.metaKey))  {
+      return;
     }
-  }
 
-  handleAccountClick = (e) => {
-    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      const id = e.currentTarget.getAttribute('data-id');
+    if (e) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${id}`);
     }
+
+    this._openProfile(proper);
   }
 
   handleExpandedToggle = () => {
@@ -242,11 +233,34 @@ class Status extends ImmutablePureComponent {
   }
 
   handleHotkeyOpen = () => {
-    this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
+    if (this.props.onClick) {
+      this.props.onClick();
+      return;
+    }
+
+    const { router } = this.context;
+    const status = this._properStatus();
+
+    if (!router) {
+      return;
+    }
+
+    router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
   }
 
   handleHotkeyOpenProfile = () => {
-    this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
+    this._openProfile();
+  }
+
+  _openProfile = (proper = true) => {
+    const { router } = this.context;
+    const status = proper ? this._properStatus() : this.props.status;
+
+    if (!router) {
+      return;
+    }
+
+    router.history.push(`/@${status.getIn(['account', 'acct'])}`);
   }
 
   handleHotkeyMoveUp = e => {
@@ -309,8 +323,8 @@ class Status extends ImmutablePureComponent {
       return (
         <HotKeys handlers={handlers}>
           <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
-            {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
-            {status.get('content')}
+            <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
+            <span>{status.get('content')}</span>
           </div>
         </HotKeys>
       );
@@ -344,7 +358,7 @@ class Status extends ImmutablePureComponent {
       prepend = (
         <div className='status__prepend'>
           <div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
-          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
+          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
         </div>
       );
 
@@ -465,14 +479,15 @@ class Status extends ImmutablePureComponent {
           {prepend}
 
           <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
-            <div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
+            <div className='status__expand' onClick={this.handleClick} role='presentation' />
+
             <div className='status__info'>
-              <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
+              <a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
                 <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
-                <RelativeTimestamp timestamp={status.get('created_at')} />
+                <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
               </a>
 
-              <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
+              <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
                 <div className='status__avatar'>
                   {statusAvatar}
                 </div>
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 9981f2449..4e19cc0e4 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -186,7 +186,7 @@ class StatusActionBar extends ImmutablePureComponent {
   }
 
   handleOpen = () => {
-    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
   }
 
   handleEmbed = () => {
@@ -225,6 +225,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
     const anonymousAccess    = !me;
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
     const mutingConversation = status.get('muted');
     const account            = status.get('account');
     const writtenByMe        = status.getIn(['account', 'id']) === me;
@@ -242,7 +243,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
     menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
 
-    if (writtenByMe && publicStatus) {
+    if (writtenByMe && pinnableStatus) {
       menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
     }
 
@@ -290,7 +291,7 @@ class StatusActionBar extends ImmutablePureComponent {
       if (isStaff) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
-        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
       }
     }
 
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index bf21a9fd6..d01365afb 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -112,7 +112,7 @@ export default class StatusContent extends React.PureComponent {
   onMentionClick = (mention, e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+      this.context.router.history.push(`/@${mention.get('acct')}`);
     }
   }
 
@@ -121,7 +121,7 @@ export default class StatusContent extends React.PureComponent {
 
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+      this.context.router.history.push(`/tags/${hashtag}`);
     }
   }
 
@@ -198,7 +198,7 @@ export default class StatusContent extends React.PureComponent {
       let mentionsPlaceholder = '';
 
       const mentionLinks = status.get('mentions').map(item => (
-        <Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
+        <Permalink to={`/@${item.get('acct')}`} href={item.get('url')} key={item.get('id')} className='mention'>
           @<span>{item.get('username')}</span>
         </Permalink>
       )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 25411c127..eaaffcc3a 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -18,7 +18,6 @@ export default class StatusList extends ImmutablePureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     isPartial: PropTypes.bool,
     hasMore: PropTypes.bool,
@@ -77,7 +76,7 @@ export default class StatusList extends ImmutablePureComponent {
   }
 
   render () {
-    const { statusIds, featuredStatusIds, shouldUpdateScroll, onLoadMore, timelineId, ...other }  = this.props;
+    const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other }  = this.props;
     const { isLoading, isPartial } = other;
 
     if (isPartial) {
@@ -120,7 +119,7 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     return (
-      <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} shouldUpdateScroll={shouldUpdateScroll} ref={this.setRef}>
+      <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
         {scrollableContent}
       </ScrollableList>
     );
diff --git a/app/javascript/mastodon/containers/admin_component.js b/app/javascript/mastodon/containers/admin_component.js
new file mode 100644
index 000000000..816b44bd1
--- /dev/null
+++ b/app/javascript/mastodon/containers/admin_component.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class AdminComponent extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    children: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { locale, children } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        {children}
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 513b59908..0c3f6afa8 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -10,8 +10,6 @@ import { hydrateStore } from '../actions/store';
 import { connectUserStream } from '../actions/streaming';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
-import { previewState as previewMediaState } from 'mastodon/features/ui/components/media_modal';
-import { previewState as previewVideoState } from 'mastodon/features/ui/components/video_modal';
 import initialState from '../initial_state';
 import ErrorBoundary from '../components/error_boundary';
 
@@ -24,14 +22,38 @@ const hydrateAction = hydrateStore(initialState);
 store.dispatch(hydrateAction);
 store.dispatch(fetchCustomEmojis());
 
+const createIdentityContext = state => ({
+  signedIn: !!state.meta.me,
+  accountId: state.meta.me,
+  accessToken: state.meta.access_token,
+});
+
 export default class Mastodon extends React.PureComponent {
 
   static propTypes = {
     locale: PropTypes.string.isRequired,
   };
 
+  static childContextTypes = {
+    identity: PropTypes.shape({
+      signedIn: PropTypes.bool.isRequired,
+      accountId: PropTypes.string,
+      accessToken: PropTypes.string,
+    }).isRequired,
+  };
+
+  identity = createIdentityContext(initialState);
+
+  getChildContext() {
+    return {
+      identity: this.identity,
+    };
+  }
+
   componentDidMount() {
-    this.disconnect = store.dispatch(connectUserStream());
+    if (this.identity.signedIn) {
+      this.disconnect = store.dispatch(connectUserStream());
+    }
   }
 
   componentWillUnmount () {
@@ -41,8 +63,8 @@ export default class Mastodon extends React.PureComponent {
     }
   }
 
-  shouldUpdateScroll (_, { location }) {
-    return location.state !== previewMediaState && location.state !== previewVideoState;
+  shouldUpdateScroll (prevRouterProps, { location }) {
+    return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
   }
 
   render () {
diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js
index 52fdc9294..2f42a084f 100644
--- a/app/javascript/mastodon/containers/media_container.js
+++ b/app/javascript/mastodon/containers/media_container.js
@@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
 import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
 import MediaGallery from 'mastodon/components/media_gallery';
 import Poll from 'mastodon/components/poll';
-import Hashtag from 'mastodon/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
 import ModalRoot from 'mastodon/components/modal_root';
 import MediaModal from 'mastodon/features/ui/components/media_modal';
 import Video from 'mastodon/features/video';
diff --git a/app/javascript/mastodon/containers/scroll_container.js b/app/javascript/mastodon/containers/scroll_container.js
new file mode 100644
index 000000000..d21ff6368
--- /dev/null
+++ b/app/javascript/mastodon/containers/scroll_container.js
@@ -0,0 +1,18 @@
+import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
+
+// ScrollContainer is used to automatically scroll to the top when pushing a
+// new history state and remembering the scroll position when going back.
+// There are a few things we need to do differently, though.
+const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
+  // If the change is caused by opening a modal, do not scroll to top
+  return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
+};
+
+export default
+class ScrollContainer extends OriginalScrollContainer {
+
+  static defaultProps = {
+    shouldUpdateScroll: defaultShouldUpdateScroll,
+  };
+
+}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 20641121f..48ec49d81 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -123,7 +123,7 @@ class Header extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl, domain, identity_proofs } = this.props;
+    const { account, intl, domain } = this.props;
 
     if (!account) {
       return null;
@@ -297,20 +297,8 @@ class Header extends ImmutablePureComponent {
 
           <div className='account__header__extra'>
             <div className='account__header__bio'>
-              {(fields.size > 0 || identity_proofs.size > 0) && (
+              {fields.size > 0 && (
                 <div className='account__header__fields'>
-                  {identity_proofs.map((proof, i) => (
-                    <dl key={i}>
-                      <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
-
-                      <dd className='verified'>
-                        <a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
-                          <Icon id='check' className='verified__mark' />
-                        </span></a>
-                        <a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
-                      </dd>
-                    </dl>
-                  ))}
                   {fields.map((pair, i) => (
                     <dl key={i}>
                       <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
@@ -332,21 +320,21 @@ class Header extends ImmutablePureComponent {
 
             {!suspended && (
               <div className='account__header__extra__links'>
-                <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
+                <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
                   <ShortNumber
                     value={account.get('statuses_count')}
                     renderer={counterRenderer('statuses')}
                   />
                 </NavLink>
 
-                <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
+                <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
                   <ShortNumber
                     value={account.get('following_count')}
                     renderer={counterRenderer('following')}
                   />
                 </NavLink>
 
-                <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
+                <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
                   <ShortNumber
                     value={account.get('followers_count')}
                     renderer={counterRenderer('followers')}
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index 015a6a6d7..cc0bfa9ba 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { fetchAccount } from 'mastodon/actions/accounts';
+import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
 import { expandAccountMediaTimeline } from '../../actions/timelines';
 import LoadingIndicator from 'mastodon/components/loading_indicator';
 import Column from '../ui/components/column';
@@ -11,25 +11,35 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { getAccountGallery } from 'mastodon/selectors';
 import MediaItem from './components/media_item';
 import HeaderContainer from '../account_timeline/containers/header_container';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'mastodon/containers/scroll_container';
 import LoadMore from 'mastodon/components/load_more';
 import MissingIndicator from 'mastodon/components/missing_indicator';
 import { openModal } from 'mastodon/actions/modal';
 import { FormattedMessage } from 'react-intl';
 
-const mapStateToProps = (state, props) => ({
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  attachments: getAccountGallery(state, props.params.accountId),
-  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
-  hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
-  suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false),
-  blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    isAccount: !!state.getIn(['accounts', accountId]),
+    attachments: getAccountGallery(state, accountId),
+    isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
+    hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+    blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+  };
+};
 
 class LoadMoreMedia extends ImmutablePureComponent {
 
   static propTypes = {
-    shouldUpdateScroll: PropTypes.func,
     maxId: PropTypes.string,
     onLoadMore: PropTypes.func.isRequired,
   };
@@ -53,7 +63,11 @@ export default @connect(mapStateToProps)
 class AccountGallery extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     attachments: ImmutablePropTypes.list.isRequired,
     isLoading: PropTypes.bool,
@@ -68,15 +82,30 @@ class AccountGallery extends ImmutablePureComponent {
     width: 323,
   };
 
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(expandAccountMediaTimeline(accountId));
+  }
+
   componentDidMount () {
-    this.props.dispatch(fetchAccount(this.props.params.accountId));
-    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
   }
 
@@ -96,7 +125,7 @@ class AccountGallery extends ImmutablePureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
+    this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
   };
 
   handleLoadOlder = e => {
@@ -127,7 +156,7 @@ class AccountGallery extends ImmutablePureComponent {
   }
 
   render () {
-    const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
+    const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
     const { width } = this.state;
 
     if (!isAccount) {
@@ -164,9 +193,9 @@ class AccountGallery extends ImmutablePureComponent {
       <Column>
         <ColumnBackButton multiColumn={multiColumn} />
 
-        <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={shouldUpdateScroll}>
+        <ScrollContainer scrollKey='account_gallery'>
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
-            <HeaderContainer accountId={this.props.params.accountId} />
+            <HeaderContainer accountId={this.props.accountId} />
 
             {(suspended || blockedBy) ? (
               <div className='empty-column-indicator'>
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 6b52defe4..33bea4c17 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -11,7 +11,6 @@ export default class Header extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map,
-    identity_proofs: ImmutablePropTypes.list,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
@@ -92,7 +91,7 @@ export default class Header extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, hideTabs, identity_proofs } = this.props;
+    const { account, hideTabs } = this.props;
 
     if (account === null) {
       return null;
@@ -104,7 +103,6 @@ export default class Header extends ImmutablePureComponent {
 
         <InnerHeader
           account={account}
-          identity_proofs={identity_proofs}
           onFollow={this.handleFollow}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
@@ -123,9 +121,9 @@ 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 and replies' /></NavLink>
-            <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots and replies' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
           </div>
         )}
       </div>
diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.js b/app/javascript/mastodon/features/account_timeline/components/moved_note.js
index 3e090bb5f..2e32d660f 100644
--- a/app/javascript/mastodon/features/account_timeline/components/moved_note.js
+++ b/app/javascript/mastodon/features/account_timeline/components/moved_note.js
@@ -21,7 +21,7 @@ export default class MovedNote extends ImmutablePureComponent {
   handleAccountClick = e => {
     if (e.button === 0) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${this.props.to.get('id')}`);
+      this.context.router.history.push(`/@${this.props.to.get('acct')}`);
     }
 
     e.stopPropagation();
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index e12019547..b3f8521cb 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -21,7 +21,6 @@ import { openModal } from '../../../actions/modal';
 import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { unfollowModal } from '../../../initial_state';
-import { List as ImmutableList } from 'immutable';
 
 const messages = defineMessages({
   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@@ -34,7 +33,6 @@ const makeMapStateToProps = () => {
   const mapStateToProps = (state, { accountId }) => ({
     account: getAccount(state, accountId),
     domain: state.getIn(['meta', 'domain']),
-    identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()),
   });
 
   return mapStateToProps;
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index fa4239d6f..37df2818b 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { fetchAccount } from '../../actions/accounts';
+import { lookupAccount, fetchAccount } from '../../actions/accounts';
 import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
 import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
@@ -12,7 +12,6 @@ import ColumnBackButton from '../../components/column_back_button';
 import { List as ImmutableList } from 'immutable';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { FormattedMessage } from 'react-intl';
-import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
 import MissingIndicator from 'mastodon/components/missing_indicator';
 import TimelineHint from 'mastodon/components/timeline_hint';
 import { me } from 'mastodon/initial_state';
@@ -20,10 +19,19 @@ import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines'
 
 const emptyList = ImmutableList();
 
-const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
+const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
   const path = withReplies ? `${accountId}:with_replies` : accountId;
 
   return {
+    accountId,
     remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
     remoteUrl: state.getIn(['accounts', accountId, 'url']),
     isAccount: !!state.getIn(['accounts', accountId]),
@@ -48,9 +56,12 @@ export default @connect(mapStateToProps)
 class AccountTimeline extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     statusIds: ImmutablePropTypes.list,
     featuredStatusIds: ImmutablePropTypes.list,
     isLoading: PropTypes.bool,
@@ -64,11 +75,10 @@ class AccountTimeline extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    const { params: { accountId }, withReplies, dispatch } = this.props;
+  _load () {
+    const { accountId, withReplies, dispatch } = this.props;
 
     dispatch(fetchAccount(accountId));
-    dispatch(fetchAccountIdentityProofs(accountId));
 
     if (!withReplies) {
       dispatch(expandAccountFeaturedTimeline(accountId));
@@ -81,29 +91,32 @@ class AccountTimeline extends ImmutablePureComponent {
     }
   }
 
-  componentWillReceiveProps (nextProps) {
-    const { dispatch } = this.props;
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
 
-    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
-      dispatch(fetchAccount(nextProps.params.accountId));
-      dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
+  }
 
-      if (!nextProps.withReplies) {
-        dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
-      }
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
 
-      dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
 
-    if (nextProps.params.accountId === me && this.props.params.accountId !== me) {
-      dispatch(connectTimeline(`account:${me}`));
-    } else if (this.props.params.accountId === me && nextProps.params.accountId !== me) {
+    if (prevProps.accountId === me && accountId !== me) {
       dispatch(disconnectTimeline(`account:${me}`));
     }
   }
 
   componentWillUnmount () {
-    const { dispatch, params: { accountId } } = this.props;
+    const { dispatch, accountId } = this.props;
 
     if (accountId === me) {
       dispatch(disconnectTimeline(`account:${me}`));
@@ -111,11 +124,11 @@ 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.accountId, { maxId, withReplies: this.props.withReplies }));
   }
 
   render () {
-    const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
+    const { statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -153,7 +166,7 @@ class AccountTimeline extends ImmutablePureComponent {
         <ColumnBackButton multiColumn={multiColumn} />
 
         <StatusList
-          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          prepend={<HeaderContainer accountId={this.props.accountId} />}
           alwaysPrepend
           append={remoteMessage}
           scrollKey='account_timeline'
@@ -162,7 +175,6 @@ class AccountTimeline extends ImmutablePureComponent {
           isLoading={isLoading}
           hasMore={hasMore}
           onLoadMore={this.handleLoadMore}
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
           timelineId='account'
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
index 107deb841..7ec177434 100644
--- a/app/javascript/mastodon/features/blocks/index.js
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -29,7 +29,6 @@ class Blocks extends ImmutablePureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     accountIds: ImmutablePropTypes.list,
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
@@ -46,7 +45,7 @@ class Blocks extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn, isLoading } = this.props;
+    const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
 
     if (!accountIds) {
       return (
@@ -66,7 +65,6 @@ class Blocks extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           hasMore={hasMore}
           isLoading={isLoading}
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.js b/app/javascript/mastodon/features/bookmarked_statuses/index.js
index c37cb9176..cf067d954 100644
--- a/app/javascript/mastodon/features/bookmarked_statuses/index.js
+++ b/app/javascript/mastodon/features/bookmarked_statuses/index.js
@@ -27,7 +27,6 @@ class Bookmarks extends ImmutablePureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     statusIds: ImmutablePropTypes.list.isRequired,
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
@@ -68,7 +67,7 @@ class Bookmarks extends ImmutablePureComponent {
   }, 300, { leading: true })
 
   render () {
-    const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+    const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
     const pinned = !!columnId;
 
     const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />;
@@ -93,7 +92,6 @@ class Bookmarks extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         />
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index b3cd39685..30f776048 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -41,7 +41,6 @@ class CommunityTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
     hasUnread: PropTypes.bool,
@@ -103,7 +102,7 @@ class CommunityTimeline extends React.PureComponent {
   }
 
   render () {
-    const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
+    const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
     const pinned = !!columnId;
 
     return (
@@ -127,7 +126,6 @@ class CommunityTimeline extends React.PureComponent {
           timelineId={`community${onlyMedia ? ':media' : ''}`}
           onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
-          shouldUpdateScroll={shouldUpdateScroll}
           bindToDocument={!multiColumn}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 9d8732a8c..647d0fba2 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -61,6 +61,7 @@ class ComposeForm extends ImmutablePureComponent {
     onPickEmoji: PropTypes.func.isRequired,
     showSearch: PropTypes.bool,
     anyMedia: PropTypes.bool,
+    isInReply: PropTypes.bool,
     singleColumn: PropTypes.bool,
   };
 
@@ -150,7 +151,7 @@ class ComposeForm extends ImmutablePureComponent {
     if (this.props.focusDate !== prevProps.focusDate) {
       let selectionEnd, selectionStart;
 
-      if (this.props.preselectDate !== prevProps.preselectDate) {
+      if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) {
         selectionEnd   = this.props.text.length;
         selectionStart = this.props.text.search(/\s/) + 1;
       } else if (typeof this.props.caretPosition === 'number') {
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
index 840d0a3da..e6ba7d8b7 100644
--- a/app/javascript/mastodon/features/compose/components/navigation_bar.js
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -19,13 +19,13 @@ export default class NavigationBar extends ImmutablePureComponent {
   render () {
     return (
       <div className='navigation-bar'>
-        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+        <Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
           <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
           <Avatar account={this.props.account} size={48} />
         </Permalink>
 
         <div className='navigation-bar__profile'>
-          <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
             <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
           </Permalink>
 
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
index a1d5c420c..863defb76 100644
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.js
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -32,7 +32,7 @@ class ReplyIndicator extends ImmutablePureComponent {
   handleAccountClick = (e) => {
     if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
     }
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index 958a65286..9b3d01cfd 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import AccountContainer from '../../../containers/account_container';
 import StatusContainer from '../../../containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import Hashtag from '../../../components/hashtag';
+import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
 import Icon from 'mastodon/components/icon';
 import { searchEnabled } from '../../../initial_state';
 import LoadMore from 'mastodon/components/load_more';
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index 37a0e8845..c44850294 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -25,6 +25,7 @@ const mapStateToProps = state => ({
   isUploading: state.getIn(['compose', 'is_uploading']),
   showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
   anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
+  isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
 });
 
 const mapDispatchToProps = (dispatch) => ({
diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js
index 8606a642e..654c14df9 100644
--- a/app/javascript/mastodon/features/compose/containers/navigation_container.js
+++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js
@@ -21,6 +21,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.logoutMessage),
       confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
       onConfirm: () => logOut(),
     }));
   },
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
index 342b0c2a9..05cd2ecc1 100644
--- a/app/javascript/mastodon/features/compose/containers/upload_container.js
+++ b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -1,7 +1,6 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
-import { undoUploadCompose } from '../../../actions/compose';
-import { openModal } from '../../../actions/modal';
+import { undoUploadCompose, initMediaEditModal } from '../../../actions/compose';
 import { submitCompose } from '../../../actions/compose';
 
 const mapStateToProps = (state, { id }) => ({
@@ -15,7 +14,7 @@ const mapDispatchToProps = dispatch => ({
   },
 
   onOpenFocalPoint: id => {
-    dispatch(openModal('FOCAL_POINT', { id }));
+    dispatch(initMediaEditModal(id));
   },
 
   onSubmit (router) {
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index e2de8b0e6..663dd324f 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -74,6 +74,7 @@ class Compose extends React.PureComponent {
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.logoutMessage),
       confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
       onConfirm: () => logOut(),
     }));
 
@@ -99,16 +100,16 @@ class Compose extends React.PureComponent {
         <nav className='drawer__header'>
           <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' fixedWidth /></Link>
           {!columns.some(column => column.get('id') === 'HOME') && (
-            <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' fixedWidth /></Link>
+            <Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' fixedWidth /></Link>
           )}
           {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
             <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' fixedWidth /></Link>
           )}
           {!columns.some(column => column.get('id') === 'COMMUNITY') && (
-            <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' fixedWidth /></Link>
+            <Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' fixedWidth /></Link>
           )}
           {!columns.some(column => column.get('id') === 'PUBLIC') && (
-            <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
+            <Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
           )}
           <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
           <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
index 43e1d77b9..77ff2ce7b 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
@@ -81,7 +81,7 @@ class Conversation extends ImmutablePureComponent {
       markRead();
     }
 
-    this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
+    this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
   }
 
   handleMarkAsRead = () => {
@@ -133,7 +133,7 @@ class Conversation extends ImmutablePureComponent {
 
     menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
 
-    const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
+    const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
 
     const handlers = {
       reply: this.handleReply,
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
index 4ee8e5212..fd1df7256 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
@@ -14,7 +14,6 @@ export default class ConversationsList extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
     onLoadMore: PropTypes.func,
-    shouldUpdateScroll: PropTypes.func,
   };
 
   getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)
diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js
index 5ce795760..68523666c 100644
--- a/app/javascript/mastodon/features/direct_timeline/index.js
+++ b/app/javascript/mastodon/features/direct_timeline/index.js
@@ -19,7 +19,6 @@ class DirectTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
     hasUnread: PropTypes.bool,
@@ -71,7 +70,7 @@ class DirectTimeline extends React.PureComponent {
   }
 
   render () {
-    const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll } = this.props;
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
     const pinned = !!columnId;
 
     return (
@@ -93,7 +92,6 @@ class DirectTimeline extends React.PureComponent {
           timelineId='direct'
           onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
-          shouldUpdateScroll={shouldUpdateScroll}
         />
       </Column>
     );
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
index 8f0e8db4b..03e13f28e 100644
--- a/app/javascript/mastodon/features/directory/components/account_card.js
+++ b/app/javascript/mastodon/features/directory/components/account_card.js
@@ -213,7 +213,7 @@ class AccountCard extends ImmutablePureComponent {
           <Permalink
             className='directory__card__bar__name'
             href={account.get('url')}
-            to={`/accounts/${account.get('id')}`}
+            to={`/@${account.get('acct')}`}
           >
             <Avatar account={account} size={48} />
             <DisplayName account={account} />
diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js
index 2f91e759b..88f20d330 100644
--- a/app/javascript/mastodon/features/directory/index.js
+++ b/app/javascript/mastodon/features/directory/index.js
@@ -12,7 +12,7 @@ import AccountCard from './components/account_card';
 import RadioButton from 'mastodon/components/radio_button';
 import classNames from 'classnames';
 import LoadMore from 'mastodon/components/load_more';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'mastodon/containers/scroll_container';
 
 const messages = defineMessages({
   title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@@ -40,7 +40,6 @@ class Directory extends React.PureComponent {
     isLoading: PropTypes.bool,
     accountIds: ImmutablePropTypes.list.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
     multiColumn: PropTypes.bool,
@@ -125,7 +124,7 @@ class Directory extends React.PureComponent {
   }
 
   render () {
-    const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
+    const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
     const { order, local }  = this.getParams(this.props, this.state);
     const pinned = !!columnId;
 
@@ -163,7 +162,7 @@ class Directory extends React.PureComponent {
           multiColumn={multiColumn}
         />
 
-        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
+        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
       </Column>
     );
   }
diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js
index a6d988912..edb80aef4 100644
--- a/app/javascript/mastodon/features/domain_blocks/index.js
+++ b/app/javascript/mastodon/features/domain_blocks/index.js
@@ -29,7 +29,6 @@ class Blocks extends ImmutablePureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     hasMore: PropTypes.bool,
     domains: ImmutablePropTypes.orderedSet,
     intl: PropTypes.object.isRequired,
@@ -45,7 +44,7 @@ class Blocks extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { intl, domains, shouldUpdateScroll, hasMore, multiColumn } = this.props;
+    const { intl, domains, hasMore, multiColumn } = this.props;
 
     if (!domains) {
       return (
@@ -64,7 +63,6 @@ class Blocks extends ImmutablePureComponent {
           scrollKey='domain_blocks'
           onLoadMore={this.handleLoadMore}
           hasMore={hasMore}
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
index db8a3f815..9606a144c 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -27,7 +27,6 @@ class Favourites extends ImmutablePureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     statusIds: ImmutablePropTypes.list.isRequired,
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
@@ -68,7 +67,7 @@ class Favourites extends ImmutablePureComponent {
   }, 300, { leading: true })
 
   render () {
-    const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+    const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
     const pinned = !!columnId;
 
     const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />;
@@ -93,7 +92,6 @@ class Favourites extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         />
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index 75cb00c0e..ac94ae18a 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -27,7 +27,6 @@ class Favourites extends ImmutablePureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     accountIds: ImmutablePropTypes.list,
     multiColumn: PropTypes.bool,
     intl: PropTypes.object.isRequired,
@@ -50,7 +49,7 @@ class Favourites extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, shouldUpdateScroll, accountIds, multiColumn } = this.props;
+    const { intl, accountIds, multiColumn } = this.props;
 
     if (!accountIds) {
       return (
@@ -74,7 +73,6 @@ class Favourites extends ImmutablePureComponent {
 
         <ScrollableList
           scrollKey='favourites'
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/mastodon/features/follow_recommendations/components/account.js b/app/javascript/mastodon/features/follow_recommendations/components/account.js
index bd855aab0..ffc0ab00c 100644
--- a/app/javascript/mastodon/features/follow_recommendations/components/account.js
+++ b/app/javascript/mastodon/features/follow_recommendations/components/account.js
@@ -66,7 +66,7 @@ class Account extends ImmutablePureComponent {
     return (
       <div className='account follow-recommendations-account'>
         <div className='account__wrapper'>
-          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
 
             <DisplayName account={account} />
diff --git a/app/javascript/mastodon/features/follow_recommendations/index.js b/app/javascript/mastodon/features/follow_recommendations/index.js
index 26c8b2471..b5a71aef5 100644
--- a/app/javascript/mastodon/features/follow_recommendations/index.js
+++ b/app/javascript/mastodon/features/follow_recommendations/index.js
@@ -68,7 +68,7 @@ class FollowRecommendations extends ImmutablePureComponent {
       }
     }));
 
-    router.history.push('/timelines/home');
+    router.history.push('/home');
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
index 8269f5ae4..263a7ae16 100644
--- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
@@ -30,7 +30,7 @@ class AccountAuthorize extends ImmutablePureComponent {
     return (
       <div className='account-authorize__wrapper'>
         <div className='account-authorize'>
-          <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
+          <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
             <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
             <DisplayName account={account} />
           </Permalink>
diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
index 18df9d25c..1f9b635bb 100644
--- a/app/javascript/mastodon/features/follow_requests/index.js
+++ b/app/javascript/mastodon/features/follow_requests/index.js
@@ -32,7 +32,6 @@ class FollowRequests extends ImmutablePureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
     accountIds: ImmutablePropTypes.list,
@@ -51,7 +50,7 @@ class FollowRequests extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { intl, shouldUpdateScroll, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
+    const { intl, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
 
     if (!accountIds) {
       return (
@@ -80,7 +79,6 @@ class FollowRequests extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           hasMore={hasMore}
           isLoading={isLoading}
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
           prepend={unlockedPrependMessage}
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index ae00d13d3..224e74b3d 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { debounce } from 'lodash';
 import LoadingIndicator from '../../components/loading_indicator';
 import {
+  lookupAccount,
   fetchAccount,
   fetchFollowers,
   expandFollowers,
@@ -19,15 +20,26 @@ import ScrollableList from '../../components/scrollable_list';
 import MissingIndicator from 'mastodon/components/missing_indicator';
 import TimelineHint from 'mastodon/components/timeline_hint';
 
-const mapStateToProps = (state, props) => ({
-  remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
-  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
-  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
-  isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
-  blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
+    blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+  };
+};
 
 const RemoteHint = ({ url }) => (
   <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
@@ -41,9 +53,12 @@ export default @connect(mapStateToProps)
 class Followers extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     accountIds: ImmutablePropTypes.list,
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
@@ -54,26 +69,39 @@ class Followers extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    if (!this.props.accountIds) {
-      this.props.dispatch(fetchAccount(this.props.params.accountId));
-      this.props.dispatch(fetchFollowers(this.props.params.accountId));
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowers(accountId));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
     }
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
   }
 
   handleLoadMore = debounce(() => {
-    this.props.dispatch(expandFollowers(this.props.params.accountId));
+    this.props.dispatch(expandFollowers(this.props.accountId));
   }, 300, { leading: true });
 
   render () {
-    const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
+    const { accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -112,8 +140,7 @@ class Followers extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          shouldUpdateScroll={shouldUpdateScroll}
-          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index 666ec7a7f..aadce1644 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { debounce } from 'lodash';
 import LoadingIndicator from '../../components/loading_indicator';
 import {
+  lookupAccount,
   fetchAccount,
   fetchFollowing,
   expandFollowing,
@@ -19,15 +20,26 @@ import ScrollableList from '../../components/scrollable_list';
 import MissingIndicator from 'mastodon/components/missing_indicator';
 import TimelineHint from 'mastodon/components/timeline_hint';
 
-const mapStateToProps = (state, props) => ({
-  remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
-  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
-  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
-  isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
-  blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
+    blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+  };
+};
 
 const RemoteHint = ({ url }) => (
   <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
@@ -41,9 +53,12 @@ export default @connect(mapStateToProps)
 class Following extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     accountIds: ImmutablePropTypes.list,
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
@@ -54,26 +69,39 @@ class Following extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    if (!this.props.accountIds) {
-      this.props.dispatch(fetchAccount(this.props.params.accountId));
-      this.props.dispatch(fetchFollowing(this.props.params.accountId));
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowing(accountId));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
     }
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
   }
 
   handleLoadMore = debounce(() => {
-    this.props.dispatch(expandFollowing(this.props.params.accountId));
+    this.props.dispatch(expandFollowing(this.props.accountId));
   }, 300, { leading: true });
 
   render () {
-    const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
+    const { accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -112,8 +140,7 @@ class Following extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          shouldUpdateScroll={shouldUpdateScroll}
-          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
index ff1566e05..24db8cede 100644
--- a/app/javascript/mastodon/features/getting_started/components/announcements.js
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -87,7 +87,7 @@ class Content extends ImmutablePureComponent {
   onMentionClick = (mention, e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+      this.context.router.history.push(`/@${mention.get('acct')}`);
     }
   }
 
@@ -96,14 +96,14 @@ class Content extends ImmutablePureComponent {
 
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+      this.context.router.history.push(`/tags/${hashtag}`);
     }
   }
 
   onStatusClick = (status, e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/statuses/${status.get('id')}`);
+      this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
     }
   }
 
diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js
index 3b9a3075f..71c7c458d 100644
--- a/app/javascript/mastodon/features/getting_started/components/trends.js
+++ b/app/javascript/mastodon/features/getting_started/components/trends.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Hashtag from 'mastodon/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
 import { FormattedMessage } from 'react-intl';
 
 export default class Trends extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 1b9994612..5508adb80 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -82,7 +82,7 @@ class GettingStarted extends ImmutablePureComponent {
     const { fetchFollowRequests, multiColumn } = this.props;
 
     if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
-      this.context.router.history.replace('/timelines/home');
+      this.context.router.history.replace('/home');
       return;
     }
 
@@ -98,8 +98,8 @@ class GettingStarted extends ImmutablePureComponent {
     if (multiColumn) {
       navItems.push(
         <ColumnSubheading key='header-discover' text={intl.formatMessage(messages.discover)} />,
-        <ColumnLink key='community_timeline' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />,
-        <ColumnLink key='public_timeline' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />,
+        <ColumnLink key='community_timeline' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/public/local' />,
+        <ColumnLink key='public_timeline' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/public' />,
       );
 
       height += 34 + 48*2;
@@ -127,13 +127,13 @@ class GettingStarted extends ImmutablePureComponent {
 
     if (multiColumn && !columns.find(item => item.get('id') === 'HOME')) {
       navItems.push(
-        <ColumnLink key='home' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />,
+        <ColumnLink key='home' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/home' />,
       );
       height += 48;
     }
 
     navItems.push(
-      <ColumnLink key='direct' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
+      <ColumnLink key='direct' icon='envelope' text={intl.formatMessage(messages.direct)} to='/conversations' />,
       <ColumnLink key='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
       <ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
       <ColumnLink key='lists' icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
index de1127b0d..142118cef 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
@@ -33,8 +33,8 @@ class ColumnSettings extends React.PureComponent {
   tags (mode) {
     let tags = this.props.settings.getIn(['tags', mode]) || [];
 
-    if (tags.toJSON) {
-      return tags.toJSON();
+    if (tags.toJS) {
+      return tags.toJS();
     } else {
       return tags;
     }
diff --git a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
index 5914bbeaf..a4f71f8a3 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
@@ -11,21 +11,22 @@ const mapStateToProps = (state, { columnId }) => {
     return {};
   }
 
-  return { settings: columns.get(index).get('params') };
+  return {
+    settings: columns.get(index).get('params'),
+    onLoad (value) {
+      return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
+        return (response.data.hashtags || []).map((tag) => {
+          return { value: tag.name, label: `#${tag.name}` };
+        });
+      });
+    },
+  };
 };
 
 const mapDispatchToProps = (dispatch, { columnId }) => ({
   onChange (key, value) {
     dispatch(changeColumnParams(columnId, key, value));
   },
-
-  onLoad (value) {
-    return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
-      return (response.data.hashtags || []).map((tag) => {
-        return { value: tag.name, label: `#${tag.name}` };
-      });
-    });
-  },
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index 5ccd9f8ea..6a808eb30 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -24,7 +24,6 @@ class HashtagTimeline extends React.PureComponent {
     params: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
   };
@@ -130,7 +129,7 @@ class HashtagTimeline extends React.PureComponent {
   }
 
   render () {
-    const { shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
+    const { hasUnread, columnId, multiColumn } = this.props;
     const { id, local } = this.props.params;
     const pinned = !!columnId;
 
@@ -156,7 +155,6 @@ class HashtagTimeline extends React.PureComponent {
           timelineId={`hashtag:${id}${local ? ':local' : ''}`}
           onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
-          shouldUpdateScroll={shouldUpdateScroll}
           bindToDocument={!multiColumn}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
index b85c69af7..dc440f2fe 100644
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -34,7 +34,6 @@ class HomeTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     intl: PropTypes.object.isRequired,
     hasUnread: PropTypes.bool,
     isPartial: PropTypes.bool,
@@ -112,7 +111,7 @@ class HomeTimeline extends React.PureComponent {
   }
 
   render () {
-    const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
+    const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
     const pinned = !!columnId;
 
     let announcementsButton = null;
@@ -154,7 +153,6 @@ class HomeTimeline extends React.PureComponent {
           onLoadMore={this.handleLoadMore}
           timelineId='home'
           emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
-          shouldUpdateScroll={shouldUpdateScroll}
           bindToDocument={!multiColumn}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js
index 8eb645630..8010274f8 100644
--- a/app/javascript/mastodon/features/list_timeline/index.js
+++ b/app/javascript/mastodon/features/list_timeline/index.js
@@ -41,7 +41,6 @@ class ListTimeline extends React.PureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     columnId: PropTypes.string,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
@@ -142,7 +141,7 @@ class ListTimeline extends React.PureComponent {
   }
 
   render () {
-    const { shouldUpdateScroll, hasUnread, columnId, multiColumn, list, intl } = this.props;
+    const { hasUnread, columnId, multiColumn, list, intl } = this.props;
     const { id } = this.props.params;
     const pinned = !!columnId;
     const title  = list ? list.get('title') : id;
@@ -207,7 +206,6 @@ class ListTimeline extends React.PureComponent {
           timelineId={`list:${id}`}
           onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet. When members of this list post new statuses, they will appear here.' />}
-          shouldUpdateScroll={shouldUpdateScroll}
           bindToDocument={!multiColumn}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js
index ca1fa1f5e..809d79d99 100644
--- a/app/javascript/mastodon/features/lists/index.js
+++ b/app/javascript/mastodon/features/lists/index.js
@@ -48,7 +48,7 @@ class Lists extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, shouldUpdateScroll, lists, multiColumn } = this.props;
+    const { intl, lists, multiColumn } = this.props;
 
     if (!lists) {
       return (
@@ -68,13 +68,12 @@ class Lists extends ImmutablePureComponent {
 
         <ScrollableList
           scrollKey='lists'
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
           bindToDocument={!multiColumn}
         >
           {lists.map(list =>
-            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
+            <ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
index 17ff5c762..c1d50d194 100644
--- a/app/javascript/mastodon/features/mutes/index.js
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -29,7 +29,6 @@ class Mutes extends ImmutablePureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
     accountIds: ImmutablePropTypes.list,
@@ -46,7 +45,7 @@ class Mutes extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { intl, shouldUpdateScroll, hasMore, accountIds, multiColumn, isLoading } = this.props;
+    const { intl, hasMore, accountIds, multiColumn, isLoading } = this.props;
 
     if (!accountIds) {
       return (
@@ -66,7 +65,6 @@ class Mutes extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           hasMore={hasMore}
           isLoading={isLoading}
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 0c24c3294..005f5afda 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -26,11 +26,12 @@ export default class ColumnSettings extends React.PureComponent {
   render () {
     const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
 
-    const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
+    const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
+    const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
-    const alertStr  = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
-    const showStr   = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
-    const soundStr  = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
+    const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
+    const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
 
     const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
     const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
@@ -57,11 +58,11 @@ export default class ColumnSettings extends React.PureComponent {
 
         <div role='group' aria-labelledby='notifications-unread-markers'>
           <span id='notifications-unread-markers' className='column-settings__section'>
-            <FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' />
+            <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
           </span>
 
           <div className='column-settings__row'>
-            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} />
+            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} />
           </div>
         </div>
 
@@ -71,7 +72,7 @@ export default class ColumnSettings extends React.PureComponent {
           </span>
 
           <div className='column-settings__row'>
-            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
+            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
           </div>
         </div>
diff --git a/app/javascript/mastodon/features/notifications/components/follow_request.js b/app/javascript/mastodon/features/notifications/components/follow_request.js
index a80cfb2fa..9ef3fde7e 100644
--- a/app/javascript/mastodon/features/notifications/components/follow_request.js
+++ b/app/javascript/mastodon/features/notifications/components/follow_request.js
@@ -42,7 +42,7 @@ class FollowRequest extends ImmutablePureComponent {
     return (
       <div className='account'>
         <div className='account__wrapper'>
-          <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+          <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             <DisplayName account={account} />
           </Permalink>
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 94fdbd6f4..f9f8a87f2 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -68,7 +68,7 @@ class Notification extends ImmutablePureComponent {
     const { notification } = this.props;
 
     if (notification.get('status')) {
-      this.context.router.history.push(`/statuses/${notification.get('status')}`);
+      this.context.router.history.push(`/@${notification.getIn(['status', 'account', 'acct'])}/${notification.get('status')}`);
     } else {
       this.handleOpenProfile();
     }
@@ -76,7 +76,7 @@ class Notification extends ImmutablePureComponent {
 
   handleOpenProfile = () => {
     const { notification } = this.props;
-    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+    this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
   }
 
   handleMention = e => {
@@ -315,7 +315,7 @@ class Notification extends ImmutablePureComponent {
     const { notification } = this.props;
     const account          = notification.get('account');
     const displayNameHtml  = { __html: account.get('display_name_html') };
-    const link             = <bdi><Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
+    const link             = <bdi><Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
 
     switch(notification.get('type')) {
     case 'follow':
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 1a621eca9..a6a277d7e 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -74,7 +74,6 @@ class Notifications extends React.PureComponent {
     notifications: ImmutablePropTypes.list.isRequired,
     showFilterBar: PropTypes.bool.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     intl: PropTypes.object.isRequired,
     isLoading: PropTypes.bool,
     isUnread: PropTypes.bool,
@@ -176,7 +175,7 @@ class Notifications extends React.PureComponent {
   };
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
+    const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const pinned = !!columnId;
     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
 
@@ -227,7 +226,6 @@ class Notifications extends React.PureComponent {
         onLoadPending={this.handleLoadPending}
         onScrollToTop={this.handleScrollToTop}
         onScroll={this.handleScroll}
-        shouldUpdateScroll={shouldUpdateScroll}
         bindToDocument={!multiColumn}
       >
         {scrollableContent}
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
index 1ecb18bf8..0de562ee1 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -114,9 +114,13 @@ class Footer extends ImmutablePureComponent {
       return;
     }
 
-    const { status } = this.props;
+    const { status, onClose } = this.props;
 
-    router.history.push(`/statuses/${status.get('id')}`);
+    if (onClose) {
+      onClose();
+    }
+
+    router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.js b/app/javascript/mastodon/features/picture_in_picture/components/header.js
index 7dd199b75..e05d8c62e 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/header.js
+++ b/app/javascript/mastodon/features/picture_in_picture/components/header.js
@@ -34,7 +34,7 @@ class Header extends ImmutablePureComponent {
 
     return (
       <div className='picture-in-picture__header'>
-        <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'>
+        <Link to={`/@${account.get('acct')}/${statusId}`} className='picture-in-picture__header__account'>
           <Avatar account={account} size={36} />
           <DisplayName account={account} />
         </Link>
diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js
index ad5c9cafc..f32bd6d23 100644
--- a/app/javascript/mastodon/features/pinned_statuses/index.js
+++ b/app/javascript/mastodon/features/pinned_statuses/index.js
@@ -24,7 +24,6 @@ class PinnedStatuses extends ImmutablePureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     statusIds: ImmutablePropTypes.list.isRequired,
     intl: PropTypes.object.isRequired,
     hasMore: PropTypes.bool.isRequired,
@@ -44,7 +43,7 @@ class PinnedStatuses extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, shouldUpdateScroll, statusIds, hasMore, multiColumn } = this.props;
+    const { intl, statusIds, hasMore, multiColumn } = this.props;
 
     return (
       <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
@@ -53,7 +52,6 @@ class PinnedStatuses extends ImmutablePureComponent {
           statusIds={statusIds}
           scrollKey='pinned_statuses'
           hasMore={hasMore}
-          shouldUpdateScroll={shouldUpdateScroll}
           bindToDocument={!multiColumn}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index 988b1b070..b1d5518af 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -43,7 +43,6 @@ class PublicTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     multiColumn: PropTypes.bool,
@@ -106,7 +105,7 @@ class PublicTimeline extends React.PureComponent {
   }
 
   render () {
-    const { intl, shouldUpdateScroll, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote } = this.props;
+    const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote } = this.props;
     const pinned = !!columnId;
 
     return (
@@ -130,7 +129,6 @@ class PublicTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`public_timeline-${columnId}`}
           emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
-          shouldUpdateScroll={shouldUpdateScroll}
           bindToDocument={!multiColumn}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
index 4becb5fb7..0fbd09415 100644
--- a/app/javascript/mastodon/features/reblogs/index.js
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -27,7 +27,6 @@ class Reblogs extends ImmutablePureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     accountIds: ImmutablePropTypes.list,
     multiColumn: PropTypes.bool,
     intl: PropTypes.object.isRequired,
@@ -50,7 +49,7 @@ class Reblogs extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, shouldUpdateScroll, accountIds, multiColumn } = this.props;
+    const { intl, accountIds, multiColumn } = this.props;
 
     if (!accountIds) {
       return (
@@ -74,7 +73,6 @@ class Reblogs extends ImmutablePureComponent {
 
         <ScrollableList
           scrollKey='reblogs'
-          shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index ffa2510c0..a15a4d567 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -188,6 +188,7 @@ class ActionBar extends React.PureComponent {
     const { status, relationship, intl } = this.props;
 
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
     const mutingConversation = status.get('muted');
     const account            = status.get('account');
     const writtenByMe        = status.getIn(['account', 'id']) === me;
@@ -201,7 +202,7 @@ class ActionBar extends React.PureComponent {
     }
 
     if (writtenByMe) {
-      if (publicStatus) {
+      if (pinnableStatus) {
         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
         menu.push(null);
       }
@@ -244,7 +245,7 @@ class ActionBar extends React.PureComponent {
       if (isStaff) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
-        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
       }
     }
 
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 043a749ed..ee4a6b989 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
 import StatusContent from '../../../components/status_content';
 import MediaGallery from '../../../components/media_gallery';
 import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
+import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from '../../video';
@@ -55,7 +55,7 @@ class DetailedStatus extends ImmutablePureComponent {
   handleAccountClick = (e) => {
     if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
     }
 
     e.stopPropagation();
@@ -116,6 +116,7 @@ class DetailedStatus extends ImmutablePureComponent {
     let reblogLink = '';
     let reblogIcon = 'retweet';
     let favouriteLink = '';
+    let edited = '';
 
     if (this.props.measureHeight) {
       outerStyle.height = `${this.state.height}px`;
@@ -195,7 +196,7 @@ class DetailedStatus extends ImmutablePureComponent {
       reblogLink = (
         <React.Fragment>
           <React.Fragment> · </React.Fragment>
-          <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+          <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
             <Icon id={reblogIcon} />
             <span className='detailed-status__reblogs'>
               <AnimatedNumber value={status.get('reblogs_count')} />
@@ -219,7 +220,7 @@ class DetailedStatus extends ImmutablePureComponent {
 
     if (this.context.router) {
       favouriteLink = (
-        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+        <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
           <Icon id='star' />
           <span className='detailed-status__favorites'>
             <AnimatedNumber value={status.get('favourites_count')} />
@@ -237,6 +238,15 @@ class DetailedStatus extends ImmutablePureComponent {
       );
     }
 
+    if (status.get('edited_at')) {
+      edited = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
+        </React.Fragment>
+      );
+    }
+
     return (
       <div style={outerStyle}>
         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
@@ -252,7 +262,7 @@ class DetailedStatus extends ImmutablePureComponent {
           <div className='detailed-status__meta'>
             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-            </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+            </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index df8362a1b..f342a3641 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -45,7 +45,7 @@ import { initBlockModal } from '../../actions/blocks';
 import { initBoostModal } from '../../actions/boosts';
 import { initReport } from '../../actions/reports';
 import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'mastodon/containers/scroll_container';
 import ColumnBackButton from '../../components/column_back_button';
 import ColumnHeader from '../../components/column_header';
 import StatusContainer from '../../containers/status_container';
@@ -83,7 +83,7 @@ const makeMapStateToProps = () => {
     ancestorsIds = ancestorsIds.withMutations(mutable => {
       let id = statusId;
 
-      while (id) {
+      while (id && !mutable.includes(id)) {
         mutable.unshift(id);
         id = inReplyTos.get(id);
       }
@@ -101,7 +101,7 @@ const makeMapStateToProps = () => {
     const ids = [statusId];
 
     while (ids.length > 0) {
-      let id        = ids.shift();
+      let id        = ids.pop();
       const replies = contextReplies.get(id);
 
       if (statusId !== id) {
@@ -110,7 +110,7 @@ const makeMapStateToProps = () => {
 
       if (replies) {
         replies.reverse().forEach(reply => {
-          ids.unshift(reply);
+          if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
         });
       }
     }
@@ -396,7 +396,7 @@ class Status extends ImmutablePureComponent {
   }
 
   handleHotkeyOpenProfile = () => {
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
   }
 
   handleHotkeyToggleHidden = () => {
@@ -498,7 +498,7 @@ class Status extends ImmutablePureComponent {
 
   render () {
     let ancestors, descendants;
-    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
+    const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
     const { fullscreen } = this.state;
 
     if (status === null) {
@@ -541,7 +541,7 @@ class Status extends ImmutablePureComponent {
           )}
         />
 
-        <ScrollContainer scrollKey='thread' shouldUpdateScroll={shouldUpdateScroll}>
+        <ScrollContainer scrollKey='thread'>
           <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
             {ancestors}
 
diff --git a/app/javascript/mastodon/features/ui/components/audio_modal.js b/app/javascript/mastodon/features/ui/components/audio_modal.js
index 0676bd9cf..c46fefce8 100644
--- a/app/javascript/mastodon/features/ui/components/audio_modal.js
+++ b/app/javascript/mastodon/features/ui/components/audio_modal.js
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import Audio from 'mastodon/features/audio';
 import { connect } from 'react-redux';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { previewState } from './video_modal';
 import Footer from 'mastodon/features/picture_in_picture/components/footer';
 
 const mapStateToProps = (state, { statusId }) => ({
@@ -25,32 +24,6 @@ class AudioModal extends ImmutablePureComponent {
     onChangeBackgroundColor: PropTypes.func.isRequired,
   };
 
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
-  componentDidMount () {
-    if (this.context.router) {
-      const history = this.context.router.history;
-
-      history.push(history.location.pathname, previewState);
-
-      this.unlistenHistory = history.listen(() => {
-        this.props.onClose();
-      });
-    }
-  }
-
-  componentWillUnmount () {
-    if (this.context.router) {
-      this.unlistenHistory();
-
-      if (this.context.router.history.location.state === previewState) {
-        this.context.router.history.goBack();
-      }
-    }
-  }
-
   render () {
     const { media, accountStaticAvatar, statusId, onClose } = this.props;
     const options = this.props.options || {};
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
index 83229833b..f8a344690 100644
--- a/app/javascript/mastodon/features/ui/components/boost_modal.js
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -68,7 +68,7 @@ class BoostModal extends ImmutablePureComponent {
     if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       this.props.onClose();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
     }
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 039abe432..193637113 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -53,7 +53,7 @@ const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
 });
 
-const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started|^\/start/);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/search|^\/getting-started|^\/start/);
 
 export default @(component => injectIntl(component, { withRef: true }))
 class ColumnsArea extends ImmutablePureComponent {
@@ -216,7 +216,7 @@ class ColumnsArea extends ImmutablePureComponent {
     const columnIndex = getIndex(this.context.router.history.location.pathname);
 
     if (singleColumn) {
-      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
+      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
 
       const content = columnIndex !== -1 ? (
         <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
index 1227fa453..65d97ca16 100644
--- a/app/javascript/mastodon/features/ui/components/confirmation_modal.js
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
@@ -13,15 +13,22 @@ class ConfirmationModal extends React.PureComponent {
     onConfirm: PropTypes.func.isRequired,
     secondary: PropTypes.string,
     onSecondary: PropTypes.func,
+    closeWhenConfirm: PropTypes.bool,
     intl: PropTypes.object.isRequired,
   };
 
+  static defaultProps = {
+    closeWhenConfirm: true,
+  };
+
   componentDidMount() {
     this.button.focus();
   }
 
   handleClick = () => {
-    this.props.onClose();
+    if (this.props.closeWhenConfirm) {
+      this.props.onClose();
+    }
     this.props.onConfirm();
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index edeb281e9..a2e6b3d16 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { connect } from 'react-redux';
 import classNames from 'classnames';
-import { changeUploadCompose, uploadThumbnail } from '../../../actions/compose';
+import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose';
 import { getPointerPosition } from '../../video';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import IconButton from 'mastodon/components/icon_button';
@@ -27,14 +27,22 @@ import { assetHost } from 'mastodon/utils/config';
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
   apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+  applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
   placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
   chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
+  discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
+  discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
 });
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
   account: state.getIn(['accounts', me]),
   isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
+  description: state.getIn(['compose', 'media_modal', 'description']),
+  focusX: state.getIn(['compose', 'media_modal', 'focusX']),
+  focusY: state.getIn(['compose', 'media_modal', 'focusY']),
+  dirty: state.getIn(['compose', 'media_modal', 'dirty']),
+  is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
 });
 
 const mapDispatchToProps = (dispatch, { id }) => ({
@@ -43,6 +51,14 @@ const mapDispatchToProps = (dispatch, { id }) => ({
     dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
   },
 
+  onChangeDescription: (description) => {
+    dispatch(onChangeMediaDescription(description));
+  },
+
+  onChangeFocus: (focusX, focusY) => {
+    dispatch(onChangeMediaFocus(focusX, focusY));
+  },
+
   onSelectThumbnail: files => {
     dispatch(uploadThumbnail(id, files[0]));
   },
@@ -83,8 +99,8 @@ class ImageLoader extends React.PureComponent {
 
 }
 
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
+export default @connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })
+@(component => injectIntl(component, { withRef: true }))
 class FocalPointModal extends ImmutablePureComponent {
 
   static propTypes = {
@@ -92,34 +108,21 @@ class FocalPointModal extends ImmutablePureComponent {
     account: ImmutablePropTypes.map.isRequired,
     isUploadingThumbnail: PropTypes.bool,
     onSave: PropTypes.func.isRequired,
+    onChangeDescription: PropTypes.func.isRequired,
+    onChangeFocus: PropTypes.func.isRequired,
     onSelectThumbnail: PropTypes.func.isRequired,
     onClose: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
   state = {
-    x: 0,
-    y: 0,
-    focusX: 0,
-    focusY: 0,
     dragging: false,
-    description: '',
     dirty: false,
     progress: 0,
     loading: true,
     ocrStatus: '',
   };
 
-  componentWillMount () {
-    this.updatePositionFromMedia(this.props.media);
-  }
-
-  componentWillReceiveProps (nextProps) {
-    if (this.props.media.get('id') !== nextProps.media.get('id')) {
-      this.updatePositionFromMedia(nextProps.media);
-    }
-  }
-
   componentWillUnmount () {
     document.removeEventListener('mousemove', this.handleMouseMove);
     document.removeEventListener('mouseup', this.handleMouseUp);
@@ -164,54 +167,37 @@ class FocalPointModal extends ImmutablePureComponent {
     const focusX   = (x - .5) *  2;
     const focusY   = (y - .5) * -2;
 
-    this.setState({ x, y, focusX, focusY, dirty: true });
-  }
-
-  updatePositionFromMedia = media => {
-    const focusX      = media.getIn(['meta', 'focus', 'x']);
-    const focusY      = media.getIn(['meta', 'focus', 'y']);
-    const description = media.get('description') || '';
-
-    if (focusX && focusY) {
-      const x = (focusX /  2) + .5;
-      const y = (focusY / -2) + .5;
-
-      this.setState({
-        x,
-        y,
-        focusX,
-        focusY,
-        description,
-        dirty: false,
-      });
-    } else {
-      this.setState({
-        x: 0.5,
-        y: 0.5,
-        focusX: 0,
-        focusY: 0,
-        description,
-        dirty: false,
-      });
-    }
+    this.props.onChangeFocus(focusX, focusY);
   }
 
   handleChange = e => {
-    this.setState({ description: e.target.value, dirty: true });
+    this.props.onChangeDescription(e.target.value);
   }
 
   handleKeyDown = (e) => {
     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       e.stopPropagation();
-      this.setState({ description: e.target.value, dirty: true });
+      this.props.onChangeDescription(e.target.value);
       this.handleSubmit();
     }
   }
 
   handleSubmit = () => {
-    this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
-    this.props.onClose();
+    this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
+  }
+
+  getCloseConfirmationMessage = () => {
+    const { intl, dirty } = this.props;
+
+    if (dirty) {
+      return {
+        message: intl.formatMessage(messages.discardMessage),
+        confirm: intl.formatMessage(messages.discardConfirm),
+      };
+    } else {
+      return null;
+    }
   }
 
   setRef = c => {
@@ -257,7 +243,8 @@ class FocalPointModal extends ImmutablePureComponent {
         await worker.loadLanguage('eng');
         await worker.initialize('eng');
         const { data: { text } } = await worker.recognize(media_url);
-        this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false });
+        this.setState({ detecting: false });
+        this.props.onChangeDescription(removeExtraLineBreaks(text));
         await worker.terminate();
       })().catch((e) => {
         if (refreshCache) {
@@ -274,7 +261,6 @@ class FocalPointModal extends ImmutablePureComponent {
 
   handleThumbnailChange = e => {
     if (e.target.files.length > 0) {
-      this.setState({ dirty: true });
       this.props.onSelectThumbnail(e.target.files);
     }
   }
@@ -288,8 +274,10 @@ class FocalPointModal extends ImmutablePureComponent {
   }
 
   render () {
-    const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
-    const { x, y, dragging, description, dirty, detecting, progress, ocrStatus } = this.state;
+    const { media, intl, account, onClose, isUploadingThumbnail, description, focusX, focusY, dirty, is_changing_upload } = this.props;
+    const { dragging, detecting, progress, ocrStatus } = this.state;
+    const x = (focusX /  2) + .5;
+    const y = (focusY / -2) + .5;
 
     const width  = media.getIn(['meta', 'original', 'width']) || null;
     const height = media.getIn(['meta', 'original', 'height']) || null;
@@ -344,7 +332,7 @@ class FocalPointModal extends ImmutablePureComponent {
                     accept='image/png,image/jpeg'
                     onChange={this.handleThumbnailChange}
                     style={{ display: 'none' }}
-                    disabled={isUploadingThumbnail}
+                    disabled={isUploadingThumbnail || is_changing_upload}
                   />
                 </label>
 
@@ -363,7 +351,7 @@ class FocalPointModal extends ImmutablePureComponent {
                 value={detecting ? '…' : description}
                 onChange={this.handleChange}
                 onKeyDown={this.handleKeyDown}
-                disabled={detecting}
+                disabled={detecting || is_changing_upload}
                 autoFocus
               />
 
@@ -373,11 +361,11 @@ class FocalPointModal extends ImmutablePureComponent {
             </div>
 
             <div className='setting-text__toolbar'>
-              <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
+              <button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
               <CharacterCounter max={1500} text={detecting ? '' : description} />
             </div>
 
-            <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
+            <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} />
           </div>
 
           <div className='focal-point-modal__content'>
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js
index 43c03a0e7..4a9243c9e 100644
--- a/app/javascript/mastodon/features/ui/components/link_footer.js
+++ b/app/javascript/mastodon/features/ui/components/link_footer.js
@@ -17,6 +17,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.logoutMessage),
       confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
       onConfirm: () => logOut(),
     }));
   },
diff --git a/app/javascript/mastodon/features/ui/components/list_panel.js b/app/javascript/mastodon/features/ui/components/list_panel.js
index 1f7ec683a..411f62508 100644
--- a/app/javascript/mastodon/features/ui/components/list_panel.js
+++ b/app/javascript/mastodon/features/ui/components/list_panel.js
@@ -46,7 +46,7 @@ class ListPanel extends ImmutablePureComponent {
         <hr />
 
         {lists.map(list => (
-          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
+          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/lists/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
         ))}
       </div>
     );
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index 08da10330..ae937d1cd 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -20,8 +20,6 @@ const messages = defineMessages({
   next: { id: 'lightbox.next', defaultMessage: 'Next' },
 });
 
-export const previewState = 'previewMediaModal';
-
 export default @injectIntl
 class MediaModal extends ImmutablePureComponent {
 
@@ -37,10 +35,6 @@ class MediaModal extends ImmutablePureComponent {
     volume: PropTypes.number,
   };
 
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
   state = {
     index: null,
     navigationHidden: false,
@@ -98,16 +92,6 @@ class MediaModal extends ImmutablePureComponent {
   componentDidMount () {
     window.addEventListener('keydown', this.handleKeyDown, false);
 
-    if (this.context.router) {
-      const history = this.context.router.history;
-
-      history.push(history.location.pathname, previewState);
-
-      this.unlistenHistory = history.listen(() => {
-        this.props.onClose();
-      });
-    }
-
     this._sendBackgroundColor();
   }
 
@@ -131,14 +115,6 @@ class MediaModal extends ImmutablePureComponent {
   componentWillUnmount () {
     window.removeEventListener('keydown', this.handleKeyDown);
 
-    if (this.context.router) {
-      this.unlistenHistory();
-
-      if (this.context.router.history.location.state === previewState) {
-        this.context.router.history.goBack();
-      }
-    }
-
     this.props.onChangeBackgroundColor(null);
   }
 
@@ -152,13 +128,6 @@ class MediaModal extends ImmutablePureComponent {
     }));
   };
 
-  handleStatusClick = e => {
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      e.preventDefault();
-      this.context.router.history.push(`/statuses/${this.props.statusId}`);
-    }
-  }
-
   render () {
     const { media, statusId, intl, onClose } = this.props;
     const { navigationHidden } = this.state;
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 3403830e4..377cccda5 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -77,16 +77,33 @@ export default class ModalRoot extends React.PureComponent {
     return <BundleModalError {...props} onClose={onClose} />;
   }
 
+  handleClose = () => {
+    const { onClose } = this.props;
+    let message = null;
+    try {
+      message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.();
+    } catch (_) {
+      // injectIntl defines `getWrappedInstance` but errors out if `withRef`
+      // isn't set.
+      // This would be much smoother with react-intl 3+ and `forwardRef`.
+    }
+    onClose(message);
+  }
+
+  setModalRef = (c) => {
+    this._modal = c;
+  }
+
   render () {
-    const { type, props, onClose } = this.props;
+    const { type, props } = this.props;
     const { backgroundColor } = this.state;
     const visible = !!type;
 
     return (
-      <Base backgroundColor={backgroundColor} onClose={onClose}>
+      <Base backgroundColor={backgroundColor} onClose={this.handleClose}>
         {visible && (
           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-            {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={onClose} />}
+            {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
           </BundleContainer>
         )}
       </Base>
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js
index 0c12852f5..901dbdfcb 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.js
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js
@@ -10,12 +10,12 @@ import TrendsContainer from 'mastodon/features/getting_started/containers/trends
 
 const NavigationPanel = () => (
   <div className='navigation-panel'>
-    <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
     <FollowRequestsNavLink />
-    <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
-    <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
-    <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
+    <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index 1911da8ba..a023bcf34 100644
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -8,10 +8,10 @@ import Icon from 'mastodon/components/icon';
 import NotificationsCounterIcon from './notifications_counter_icon';
 
 export const links = [
-  <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
   <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
-  <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
   <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
   <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
 ];
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
index 2f13a175a..f3ee89dfd 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -6,8 +6,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import Footer from 'mastodon/features/picture_in_picture/components/footer';
 import { getAverageFromBlurhash } from 'mastodon/blurhash';
 
-export const previewState = 'previewVideoModal';
-
 export default class VideoModal extends ImmutablePureComponent {
 
   static propTypes = {
@@ -22,18 +20,8 @@ export default class VideoModal extends ImmutablePureComponent {
     onChangeBackgroundColor: PropTypes.func.isRequired,
   };
 
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
   componentDidMount () {
-    const { router } = this.context;
-    const { media, onChangeBackgroundColor, onClose } = this.props;
-
-    if (router) {
-      router.history.push(router.history.location.pathname, previewState);
-      this.unlistenHistory = router.history.listen(() => onClose());
-    }
+    const { media, onChangeBackgroundColor } = this.props;
 
     const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
 
@@ -42,18 +30,6 @@ export default class VideoModal extends ImmutablePureComponent {
     }
   }
 
-  componentWillUnmount () {
-    const { router } = this.context;
-
-    if (router) {
-      this.unlistenHistory();
-
-      if (router.history.location.state === previewState) {
-        router.history.goBack();
-      }
-    }
-  }
-
   render () {
     const { media, statusId, onClose } = this.props;
     const options = this.props.options || {};
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
index 2d27180f7..34fec8206 100644
--- a/app/javascript/mastodon/features/ui/containers/modal_container.js
+++ b/app/javascript/mastodon/features/ui/containers/modal_container.js
@@ -1,15 +1,25 @@
 import { connect } from 'react-redux';
-import { closeModal } from '../../../actions/modal';
+import { openModal, closeModal } from '../../../actions/modal';
 import ModalRoot from '../components/modal_root';
 
 const mapStateToProps = state => ({
-  type: state.get('modal').modalType,
-  props: state.get('modal').modalProps,
+  type: state.getIn(['modal', 0, 'modalType'], null),
+  props: state.getIn(['modal', 0, 'modalProps'], {}),
 });
 
 const mapDispatchToProps = dispatch => ({
-  onClose () {
-    dispatch(closeModal());
+  onClose (confirmationMessage) {
+    if (confirmationMessage) {
+      dispatch(
+        openModal('CONFIRM', {
+          message: confirmationMessage.message,
+          confirm: confirmationMessage.confirm,
+          onConfirm: () => dispatch(closeModal()),
+        }),
+      );
+    } else {
+      dispatch(closeModal());
+    }
   },
 });
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index c1c6ac739..3feffa656 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -54,8 +54,6 @@ import {
   FollowRecommendations,
 } from './util/async-components';
 import { me } from '../../initial_state';
-import { previewState as previewMediaState } from './components/media_modal';
-import { previewState as previewVideoState } from './components/video_modal';
 import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
@@ -74,6 +72,7 @@ const mapStateToProps = state => ({
   canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
   dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
   firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
+  username: state.getIn(['accounts', me, 'username']),
 });
 
 const keyMap = {
@@ -138,10 +137,6 @@ class SwitchingColumnsArea extends React.PureComponent {
     }
   }
 
-  shouldUpdateScroll (_, { location }) {
-    return location.state !== previewMediaState && location.state !== previewVideoState;
-  }
-
   setRef = c => {
     if (c) {
       this.node = c.getWrappedInstance();
@@ -150,7 +145,7 @@ class SwitchingColumnsArea extends React.PureComponent {
 
   render () {
     const { children, mobile } = this.props;
-    const redirect = mobile ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
+    const redirect = mobile ? <Redirect from='/' to='/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
 
     return (
       <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
@@ -158,38 +153,45 @@ class SwitchingColumnsArea extends React.PureComponent {
           {redirect}
           <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
           <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
-          <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-
-          <WrappedRoute path='/notifications' component={Notifications} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
+
+          <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
+          <WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
+          <WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
+          <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
+          <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
+          <WrappedRoute path='/notifications' component={Notifications} content={children} />
+          <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+
           <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
-          <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
+          <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
           <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
           <WrappedRoute path='/search' component={Search} content={children} />
-          <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-
-          <WrappedRoute path='/statuses/new' component={Compose} content={children} />
-          <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-
-          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll, withReplies: true }} />
-          <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-
-          <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/blocks' component={Blocks} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/mutes' component={Mutes} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-          <WrappedRoute path='/lists' component={Lists} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
+          <WrappedRoute path='/directory' component={Directory} content={children} />
+          <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
+
+          <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
+          <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <WrappedRoute path={['/@:acct/followers', '/accounts/:id/followers']} component={Followers} content={children} />
+          <WrappedRoute path={['/@:acct/following', '/accounts/:id/following']} component={Following} content={children} />
+          <WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
+          <WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
+
+          {/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
+          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
+          <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
+          <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
+          <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
+
+          <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
+          <WrappedRoute path='/blocks' component={Blocks} content={children} />
+          <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/mutes' component={Mutes} content={children} />
+          <WrappedRoute path='/lists' component={Lists} content={children} />
 
           <WrappedRoute component={GenericNotFound} content={children} />
         </WrappedSwitch>
@@ -220,6 +222,7 @@ class UI extends React.PureComponent {
     dropdownMenuIsOpen: PropTypes.bool,
     layout: PropTypes.string.isRequired,
     firstLaunch: PropTypes.bool,
+    username: PropTypes.string,
   };
 
   state = {
@@ -457,7 +460,7 @@ class UI extends React.PureComponent {
   }
 
   handleHotkeyGoToHome = () => {
-    this.context.router.history.push('/timelines/home');
+    this.context.router.history.push('/home');
   }
 
   handleHotkeyGoToNotifications = () => {
@@ -465,15 +468,15 @@ class UI extends React.PureComponent {
   }
 
   handleHotkeyGoToLocal = () => {
-    this.context.router.history.push('/timelines/public/local');
+    this.context.router.history.push('/public/local');
   }
 
   handleHotkeyGoToFederated = () => {
-    this.context.router.history.push('/timelines/public');
+    this.context.router.history.push('/public');
   }
 
   handleHotkeyGoToDirect = () => {
-    this.context.router.history.push('/timelines/direct');
+    this.context.router.history.push('/conversations');
   }
 
   handleHotkeyGoToStart = () => {
@@ -489,7 +492,7 @@ class UI extends React.PureComponent {
   }
 
   handleHotkeyGoToProfile = () => {
-    this.context.router.history.push(`/accounts/${me}`);
+    this.context.router.history.push(`/@${this.props.username}`);
   }
 
   handleHotkeyGoToBlocked = () => {
diff --git a/app/javascript/mastodon/locales/af.json b/app/javascript/mastodon/locales/af.json
index c1eadb5a3..eca4765c4 100644
--- a/app/javascript/mastodon/locales/af.json
+++ b/app/javascript/mastodon/locales/af.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 63cb17dd3..0992394a2 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -8,7 +8,7 @@
   "account.blocked": "محظور",
   "account.browse_more_on_origin_server": "تصفح المزيد في الملف الشخصي الأصلي",
   "account.cancel_follow_request": "إلغاء طلب المتابَعة",
-  "account.direct": "مراسلة @{name} بشكلة مباشر",
+  "account.direct": "مراسلة @{name} بشكل مباشر",
   "account.disable_notifications": "توقف عن إشعاري عندما ينشر @{name}",
   "account.domain_blocked": "اسم النِّطاق محظور",
   "account.edit_profile": "تحرير الملف الشخصي",
@@ -22,7 +22,7 @@
   "account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
   "account.follows_you": "يُتابِعُك",
   "account.hide_reblogs": "إخفاء تعزيزات @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "انضم في {date}",
   "account.last_status": "آخر نشاط",
   "account.link_verified_on": "تمَّ التَّحقق مِن مِلْكيّة هذا الرابط بتاريخ {date}",
   "account.locked_info": "تمَّ تعيين حالة خصوصية هذا الحساب إلى مُقفَل. يُراجع المالك يدويًا من يمكنه متابعته.",
@@ -47,11 +47,16 @@
   "account.unmute": "إلغاء الكَتم عن @{name}",
   "account.unmute_notifications": "إلغاء كَتم الإشعارات عن @{name}",
   "account_note.placeholder": "اِنقُر لإضافة مُلاحظة",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "يُرجى إعادة المحاولة بعد {retry_time, time, medium}.",
   "alert.rate_limited.title": "المُعَدَّل مَحدود",
   "alert.unexpected.message": "لقد طرأ خطأ غير متوقّع.",
   "alert.unexpected.title": "المعذرة!",
   "announcement.announcement": "إعلان",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} في الأسبوع",
   "boost_modal.combo": "يُمكنك الضّغط على {combo} لتخطي هذا في المرة المُقبِلَة",
   "bundle_column_error.body": "لقد حدث خطأ ما أثناء تحميل هذا العنصر.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "هل أنتَ مُتأكدٌ أنك تُريدُ حَذفَ هذا المنشور؟",
   "confirmations.delete_list.confirm": "حذف",
   "confirmations.delete_list.message": "هل أنتَ مُتأكدٌ أنكَ تُريدُ حَذفَ هذِهِ القائمةَ بشكلٍ دائم؟",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "حظر اِسم النِّطاق بشكلٍ كامل",
   "confirmations.domain_block.message": "متأكد من أنك تود حظر اسم النطاق {domain} بالكامل ؟ في غالب الأحيان يُستَحسَن كتم أو حظر بعض الحسابات بدلا من حظر نطاق بالكامل.\nلن تتمكن مِن رؤية محتوى هذا النطاق لا على خيوطك العمومية و لا في إشعاراتك. سوف يتم كذلك إزالة كافة متابعيك المنتمين إلى هذا النطاق.",
   "confirmations.logout.confirm": "خروج",
@@ -160,11 +167,11 @@
   "empty_column.domain_blocks": "ليس هناك نطاقات مخفية بعد.",
   "empty_column.favourited_statuses": "ليس لديك أية تبويقات مفضلة بعد. عندما ستقوم بالإعجاب بواحد، سيظهر هنا.",
   "empty_column.favourites": "لم يقم أي أحد بالإعجاب بهذا التبويق بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.follow_recommendations": "يبدو أنه لا يمكن إنشاء أي اقتراحات لك. يمكنك البحث عن أشخاص قد تعرفهم أو استكشاف الوسوم الرائجة.",
   "empty_column.follow_requests": "ليس عندك أي طلب للمتابعة بعد. سوف تظهر طلباتك هنا إن قمت بتلقي البعض منها.",
   "empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
   "empty_column.home": "إنّ الخيط الزمني لصفحتك الرئيسية فارغ. قم بزيارة {public} أو استخدم حقل البحث لكي تكتشف مستخدمين آخرين.",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home.suggestions": "شاهد بعض الاقتراحات",
   "empty_column.list": "هذه القائمة فارغة مؤقتا و لكن سوف تمتلئ تدريجيا عندما يبدأ الأعضاء المُنتَمين إليها بنشر تبويقات.",
   "empty_column.lists": "ليس عندك أية قائمة بعد. سوف تظهر قائمتك هنا إن قمت بإنشاء واحدة.",
   "empty_column.mutes": "لم تقم بكتم أي مستخدم بعد.",
@@ -176,9 +183,9 @@
   "error.unexpected_crash.next_steps_addons": "حاول تعطيلهم وإنعاش الصفحة. إن لم ينجح ذلك، يمكنك دائمًا استخدام ماستدون عبر متصفح آخر أو تطبيق أصلي.",
   "errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة",
   "errors.unexpected_crash.report_issue": "الإبلاغ عن خلل",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "follow_recommendations.done": "تم",
+  "follow_recommendations.heading": "تابع الأشخاص الذين ترغب في رؤية منشوراتهم! إليك بعض الاقتراحات.",
+  "follow_recommendations.lead": "ستظهر المنشورات من الأشخاص الذين تُتابعتهم بترتيب تسلسلي زمني على صفحتك الرئيسية. لا تخف إذا ارتكبت أي أخطاء، تستطيع إلغاء متابعة أي شخص في أي وقت تريد!",
   "follow_request.authorize": "ترخيص",
   "follow_request.reject": "رفض",
   "follow_requests.unlocked_explanation": "على الرغم من أن حسابك غير مقفل، فإن موظفين الـ{domain} ظنوا أنك قد ترغب في مراجعة طلبات المتابعة من هذه الحسابات يدوياً.",
@@ -315,7 +322,7 @@
   "notifications.column_settings.show": "اعرِضها في عمود",
   "notifications.column_settings.sound": "أصدر صوتا",
   "notifications.column_settings.status": "تبويقات جديدة:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.unread_markers.category": "علامات إشعار غير مقروءة",
   "notifications.filter.all": "الكل",
   "notifications.filter.boosts": "الترقيات",
   "notifications.filter.favourites": "المفضلة",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# صوت} other {# أصوات}}",
   "poll.vote": "صَوّت",
   "poll.voted": "لقد صوّتت على هذه الإجابة",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "إضافة استطلاع للرأي",
   "poll_button.remove_poll": "إزالة استطلاع الرأي",
   "privacy.change": "اضبط خصوصية المنشور",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "وصف للمعاقين بصريا أو لِذي قِصر السمع",
   "upload_modal.analyzing_picture": "جارٍ فحص الصورة…",
   "upload_modal.apply": "طبّق",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "اختر صورة",
   "upload_modal.description_placeholder": "نصٌّ حكيمٌ لهُ سِرٌّ قاطِعٌ وَذُو شَأنٍ عَظيمٍ مكتوبٌ على ثوبٍ أخضرَ ومُغلفٌ بجلدٍ أزرق",
   "upload_modal.detect_text": "اكتشف النص مِن الصورة",
diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json
index 9d3c16d78..89a13246c 100644
--- a/app/javascript/mastodon/locales/ast.json
+++ b/app/javascript/mastodon/locales/ast.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Asocedió un fallu inesperáu.",
   "alert.unexpected.title": "¡Meca!",
   "announcement.announcement": "Anunciu",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per selmana",
   "boost_modal.combo": "Pues primir {combo} pa saltar esto la próxima vegada",
   "bundle_column_error.body": "Asocedió daqué malo mentanto se cargaba esti componente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "¿De xuru que quies desaniciar esti estáu?",
   "confirmations.delete_list.confirm": "Desaniciar",
   "confirmations.delete_list.message": "¿De xuru que quies desaniciar dafechu esta llista?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Anubrir tol dominiu",
   "confirmations.domain_block.message": "¿De xuru xurísimu que quies bloquiar el dominiu {domain} enteru? Na mayoría de casos bloquiar o silenciar dalguna cuenta ye abondo y preferible. Nun vas ver el conteníu d'esi dominiu en nenguna llinia temporal pública o nos avisos, y van desanciase los tos siguidores d'esi dominiu.",
   "confirmations.logout.confirm": "Zarrar sesión",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# votu} other {# votos}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Amestar una encuesta",
   "poll_button.remove_poll": "Desaniciar la encuesta",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Descripción pa persones con perda auditiva o discapacidá visual",
   "upload_modal.analyzing_picture": "Analizando la semeya…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Deteutar el testu de la semeya",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index d5f66eeb4..8e3848f54 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -47,11 +47,16 @@
   "account.unmute": "Раззаглушаване на @{name}",
   "account.unmute_notifications": "Раззаглушаване на известия от @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Моля, опитайте отново след {retry_time, time, medium}.",
   "alert.rate_limited.title": "Скоростта е ограничена",
   "alert.unexpected.message": "Възникна неочаквана грешка.",
   "alert.unexpected.title": "Опаа!",
   "announcement.announcement": "Оповестяване",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} на седмица",
   "boost_modal.combo": "Можете да натиснете {combo}, за да пропуснете това следващия път",
   "bundle_column_error.body": "Нещо се обърка при зареждането на този компонент.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Изтриване",
   "confirmations.delete_list.message": "Сигурни ли сте, че искате да изтриете окончателно този списък?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
   "confirmations.logout.confirm": "Излизане",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# глас} other {# гласа}}",
   "poll.vote": "Гласуване",
   "poll.voted": "Вие гласувахте за този отговор",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Добавяне на анкета",
   "poll_button.remove_poll": "Премахване на анкета",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Опишете за хора със загуба на слуха или зрително увреждане",
   "upload_modal.analyzing_picture": "Анализ на снимка…",
   "upload_modal.apply": "Прилагане",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Избор на изображение",
   "upload_modal.description_placeholder": "Ах, чудна българска земьо, полюшвай цъфтящи жита",
   "upload_modal.detect_text": "Откриване на текст от картина",
diff --git a/app/javascript/mastodon/locales/bn.json b/app/javascript/mastodon/locales/bn.json
index dc8984be3..f28e8b110 100644
--- a/app/javascript/mastodon/locales/bn.json
+++ b/app/javascript/mastodon/locales/bn.json
@@ -1,26 +1,26 @@
 {
-  "account.account_note_header": "নোট",
-  "account.add_or_remove_from_list": "তালিকাতে যুক্ত বা অপসারণ করুন",
+  "account.account_note_header": "বিজ্ঞপ্তি",
+  "account.add_or_remove_from_list": "তালিকাতে যোগ বা অপসারণ করো",
   "account.badges.bot": "বট",
-  "account.badges.group": "গ্রুপ",
-  "account.block": "@{name} কে ব্লক করুন",
-  "account.block_domain": "{domain} থেকে সব আড়াল করুন",
+  "account.badges.group": "দল",
+  "account.block": "@{name} কে ব্লক করো",
+  "account.block_domain": "{domain} থেকে সব লুকাও",
   "account.blocked": "অবরুদ্ধ",
   "account.browse_more_on_origin_server": "মূল প্রোফাইলটিতে আরও ব্রাউজ করুন",
-  "account.cancel_follow_request": "অনুসরণ অনুরোধ বাতিল করুন",
+  "account.cancel_follow_request": "অনুসরণ অনুরোধ বাতিল করো",
   "account.direct": "@{name} কে সরাসরি বার্তা",
   "account.disable_notifications": "Stop notifying me when @{name} posts",
   "account.domain_blocked": "ডোমেন গোপন করুন",
   "account.edit_profile": "প্রোফাইল পরিবর্তন করুন",
   "account.enable_notifications": "Notify me when @{name} posts",
   "account.endorse": "নিজের পাতায় দেখান",
-  "account.follow": "অনুসরণ করুন",
+  "account.follow": "অনুসরণ",
   "account.followers": "অনুসরণকারী",
-  "account.followers.empty": "এই সদস্যকে এখনো কেউ অনুসরণ করে না।.",
+  "account.followers.empty": "এই ব্যক্তিকে এখনো কেউ অনুসরণ করে না।",
   "account.followers_counter": "{count, plural,one {{counter} জন অনুসরণকারী } other {{counter} জন অনুসরণকারী}}",
   "account.following_counter": "{count, plural,one {{counter} জনকে অনুসরণ} other {{counter} জনকে অনুসরণ}}",
   "account.follows.empty": "এই সদস্য কাওকে এখনো অনুসরণ করেন না.",
-  "account.follows_you": "আপনাকে অনুসরণ করে",
+  "account.follows_you": "তোমাকে অনুসরণ করে",
   "account.hide_reblogs": "@{name}'র সমর্থনগুলি লুকিয়ে ফেলুন",
   "account.joined": "Joined {date}",
   "account.last_status": "শেষ সক্রিয় ছিল",
@@ -43,15 +43,20 @@
   "account.unblock": "@{name} র কার্যকলাপ দেখুন",
   "account.unblock_domain": "{domain} কে আবার দেখুন",
   "account.unendorse": "আপনার নিজের পাতায় এটা দেখবেন না",
-  "account.unfollow": "অনুসরণ না করতে",
+  "account.unfollow": "অনুসরণ করো না",
   "account.unmute": "@{name} র কার্যকলাপ আবার দেখুন",
   "account.unmute_notifications": "@{name} র প্রজ্ঞাপন দেখুন",
   "account_note.placeholder": "নোট যোগ করতে ক্লিক করুন",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "{retry_time, time, medium} -এর পরে আবার প্রচেষ্টা করুন।",
   "alert.rate_limited.title": "হার সীমিত",
   "alert.unexpected.message": "সমস্যা অপ্রত্যাশিত.",
   "alert.unexpected.title": "ওহো!",
   "announcement.announcement": "ঘোষণা",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "প্রতি সপ্তাহে {count}",
   "boost_modal.combo": "পরেরবার আপনি {combo} টিপলে এটি আর আসবে না",
   "bundle_column_error.body": "এই অংশটি দেখতে যেয়ে কোনো সমস্যা হয়েছে।.",
@@ -67,7 +72,7 @@
   "column.directory": "প্রোফাইল ব্রাউজ করুন",
   "column.domain_blocks": "লুকোনো ডোমেনগুলি",
   "column.favourites": "পছন্দের গুলো",
-  "column.follow_requests": "অনুসরণের অনুমতি চেয়েছে যারা",
+  "column.follow_requests": "অনুসরণের অনুমতি অনুরোধকারী",
   "column.home": "বাড়ি",
   "column.lists": "তালিকাগুলো",
   "column.mutes": "যাদের কার্যক্রম দেখা বন্ধ আছে",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "আপনি কি নিশ্চিত যে এই লেখাটি মুছে ফেলতে চান ?",
   "confirmations.delete_list.confirm": "মুছে ফেলুন",
   "confirmations.delete_list.message": "আপনি কি নিশ্চিত যে আপনি এই তালিকাটি স্থায়িভাবে মুছে ফেলতে চান ?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "এই ডোমেন থেকে সব লুকান",
   "confirmations.domain_block.message": "আপনি কি সত্যিই সত্যই নিশ্চিত যে আপনি পুরো {domain}'টি ব্লক করতে চান? বেশিরভাগ ক্ষেত্রে কয়েকটি লক্ষ্যযুক্ত ব্লক বা নীরবতা যথেষ্ট এবং পছন্দসই। আপনি কোনও পাবলিক টাইমলাইন বা আপনার বিজ্ঞপ্তিগুলিতে সেই ডোমেন থেকে সামগ্রী দেখতে পাবেন না। সেই ডোমেন থেকে আপনার অনুসরণকারীদের সরানো হবে।",
   "confirmations.logout.confirm": "প্রস্থান",
@@ -124,8 +131,8 @@
   "confirmations.redraft.message": "আপনি কি নিশ্চিত এটি মুছে ফেলে  এবং আবার সম্পাদন করতে চান ? এটাতে যা পছন্দিত, সমর্থন বা মতামত আছে সেগুলো নতুন লেখার সাথে যুক্ত থাকবে না।",
   "confirmations.reply.confirm": "মতামত",
   "confirmations.reply.message": "এখন মতামত লিখতে গেলে আপনার এখন যেটা লিখছেন সেটা মুছে যাবে। আপনি নি নিশ্চিত এটা করতে চান ?",
-  "confirmations.unfollow.confirm": "অনুসরণ করা বাতিল করতে",
-  "confirmations.unfollow.message": "আপনি কি নিশ্চিত {name} কে আর অনুসরণ করতে চান না ?",
+  "confirmations.unfollow.confirm": "অনুসরণ বন্ধ করো",
+  "confirmations.unfollow.message": "তুমি কি নিশ্চিত {name} কে আর অনুসরণ করতে চাও না?",
   "conversation.delete": "কথোপকথন মুছে ফেলুন",
   "conversation.mark_as_read": "পঠিত হিসেবে চিহ্নিত করুন",
   "conversation.open": "কথপোকথন দেখান",
@@ -161,7 +168,7 @@
   "empty_column.favourited_statuses": "আপনার পছন্দের কোনো টুট এখনো নেই। আপনি কোনো লেখা পছন্দের হিসেবে চিহ্নিত করলে এখানে পাওয়া যাবে।",
   "empty_column.favourites": "কেও এখনো এটাকে পছন্দের টুট হিসেবে চিহ্নিত করেনি। যদি করে, তখন তাদের এখানে পাওয়া যাবে।",
   "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
-  "empty_column.follow_requests": "আপনার এখনো কোনো অনুসরণের আবেদন পাঠানো নেই। যদি পাঠায়, এখানে পাওয়া যাবে।",
+  "empty_column.follow_requests": "তোমার এখনো কোনো অনুসরণের আবেদন পাওনি। যদি কেউ পাঠায়, এখানে পাওয়া যাবে।",
   "empty_column.hashtag": "এই হেসটাগে এখনো কিছু নেই।",
   "empty_column.home": "আপনার বাড়ির সময়রেখা এখনো খালি!  {public} এ ঘুরে আসুন অথবা অনুসন্ধান বেবহার করে শুরু করতে পারেন এবং অন্য ব্যবহারকারীদের সাথে সাক্ষাৎ করতে পারেন।",
   "empty_column.home.suggestions": "See some suggestions",
@@ -176,7 +183,7 @@
   "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
   "errors.unexpected_crash.copy_stacktrace": "স্টেকট্রেস ক্লিপবোর্ডে কপি করুন",
   "errors.unexpected_crash.report_issue": "সমস্যার প্রতিবেদন করুন",
-  "follow_recommendations.done": "Done",
+  "follow_recommendations.done": "সম্পন্ন",
   "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
   "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
   "follow_request.authorize": "অনুমতি দিন",
@@ -256,7 +263,7 @@
   "lists.new.title_placeholder": "তালিকার নতুন শিরোনাম দিতে",
   "lists.replies_policy.followed": "Any followed user",
   "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
+  "lists.replies_policy.none": "কেউ না",
   "lists.replies_policy.title": "Show replies to:",
   "lists.search": "যাদের অনুসরণ করেন তাদের ভেতরে খুঁজুন",
   "lists.subheading": "আপনার তালিকা",
@@ -265,7 +272,7 @@
   "media_gallery.toggle_visible": "দৃশ্যতার অবস্থা বদলান",
   "missing_indicator.label": "খুঁজে পাওয়া যায়নি",
   "missing_indicator.sublabel": "জিনিসটা খুঁজে পাওয়া যায়নি",
-  "mute_modal.duration": "Duration",
+  "mute_modal.duration": "সময়কাল",
   "mute_modal.hide_notifications": "এই ব্যবহারকারীর প্রজ্ঞাপন বন্ধ করবেন ?",
   "mute_modal.indefinite": "Indefinite",
   "navigation_bar.apps": "মোবাইলের আপ্প",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# ভোট} other {# ভোট}}",
   "poll.vote": "ভোট",
   "poll.voted": "আপনি এই উত্তরের পক্ষে ভোট দিয়েছেন",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "একটা নির্বাচন যোগ করতে",
   "poll_button.remove_poll": "নির্বাচন বাদ দিতে",
   "privacy.change": "লেখার গোপনীয়তা অবস্থা ঠিক করতে",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "শ্রবণশক্তি হ্রাস বা চাক্ষুষ প্রতিবন্ধী ব্যক্তিদের জন্য বর্ণনা করুন",
   "upload_modal.analyzing_picture": "চিত্র বিশ্লেষণ করা হচ্ছে…",
   "upload_modal.apply": "প্রয়োগ করুন",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "ছবি নির্বাচন করুন",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "ছবি থেকে পাঠ্য সনাক্ত করুন",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index b6a2a9292..06fc32095 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -9,10 +9,10 @@
   "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Nullañ ar bedadenn heuliañ",
   "account.direct": "Kas ur gemennadenn da @{name}",
-  "account.disable_notifications": "Stop notifying me when @{name} posts",
+  "account.disable_notifications": "Paouez d'am c'hemenn pa vez toudet gant @{name}",
   "account.domain_blocked": "Domani berzet",
   "account.edit_profile": "Aozañ ar profil",
-  "account.enable_notifications": "Notify me when @{name} posts",
+  "account.enable_notifications": "Ma c'hemenn pa vez toudet gant @{name}",
   "account.endorse": "Lakaat war-wel war ar profil",
   "account.follow": "Heuliañ",
   "account.followers": "Heulier·ezed·ien",
@@ -22,7 +22,7 @@
   "account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
   "account.follows_you": "Ho heul",
   "account.hide_reblogs": "Kuzh toudoù rannet gant @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Amañ abaoe {date}",
   "account.last_status": "Oberiantiz zivezhañ",
   "account.link_verified_on": "Gwiriet eo bet perc'hennidigezh al liamm d'an deiziad-mañ : {date}",
   "account.locked_info": "Prennet eo ar gon-mañ. Dibab a ra ar perc'henn ar re a c'hall heuliañ anezhi pe anezhañ.",
@@ -47,11 +47,16 @@
   "account.unmute": "Diguzhat @{name}",
   "account.unmute_notifications": "Diguzhat kemennoù a @{name}",
   "account_note.placeholder": "Klikit evit ouzhpenniñ un notenn",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Klaskit en-dro a-benn {retry_time, time, medium}.",
   "alert.rate_limited.title": "Feur bevennet",
   "alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
   "alert.unexpected.title": "Hopala!",
   "announcement.announcement": "Kemenn",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} bep sizhun",
   "boost_modal.combo": "Ar wezh kentañ e c'halliot gwaskañ war {combo} evit tremen hebiou",
   "bundle_column_error.body": "Degouezhet ez eus bet ur fazi en ur gargañ an elfenn-mañ.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Ha sur oc'h e fell deoc'h dilemel an toud-mañ ?",
   "confirmations.delete_list.confirm": "Dilemel",
   "confirmations.delete_list.message": "Ha sur eo hoc'h eus c'hoant da zilemel ar roll-mañ da vat ?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Berzañ an domani a-bezh",
   "confirmations.domain_block.message": "Ha sur oc'h e fell deoc'h berzañ an {domain} a-bezh? Peurvuiañ eo trawalc'h berzañ pe mudañ un nebeud implijer·ezed·ien. Ne welot danvez ebet o tont eus an domani-mañ. Dilamet e vo ar c'houmanantoù war an domani-mañ.",
   "confirmations.logout.confirm": "Digevreañ",
@@ -176,7 +183,7 @@
   "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
   "errors.unexpected_crash.copy_stacktrace": "Eilañ ar roudoù diveugañ er golver",
   "errors.unexpected_crash.report_issue": "Danevellañ ur fazi",
-  "follow_recommendations.done": "Done",
+  "follow_recommendations.done": "Graet",
   "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
   "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
   "follow_request.authorize": "Aotren",
@@ -255,18 +262,18 @@
   "lists.new.create": "Ouzhpennañ ul listenn",
   "lists.new.title_placeholder": "Titl nevez al listenn",
   "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
-  "lists.replies_policy.title": "Show replies to:",
-  "lists.search": "Search among people you follow",
+  "lists.replies_policy.list": "Izili ar roll",
+  "lists.replies_policy.none": "Den ebet",
+  "lists.replies_policy.title": "Diskouez ar respontoù:",
+  "lists.search": "Klask e-touez tud heuliet ganeoc'h",
   "lists.subheading": "Ho listennoù",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "O kargañ...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Digavet",
   "missing_indicator.sublabel": "This resource could not be found",
-  "mute_modal.duration": "Duration",
-  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "mute_modal.duration": "Padelezh",
+  "mute_modal.hide_notifications": "Kuzhat kemenadennoù eus an implijer-se ?",
   "mute_modal.indefinite": "Indefinite",
   "navigation_bar.apps": "Arloadoù pellgomz",
   "navigation_bar.blocks": "Implijer·ezed·ien berzet",
@@ -294,9 +301,9 @@
   "notification.favourite": "{name} favourited your status",
   "notification.follow": "heuliañ a ra {name} ac'hanoc'h",
   "notification.follow_request": "{name} has requested to follow you",
-  "notification.mention": "{name} mentioned you",
-  "notification.own_poll": "Your poll has ended",
-  "notification.poll": "A poll you have voted in has ended",
+  "notification.mention": "{name} en/he deus meneget ac'hanoc'h",
+  "notification.own_poll": "Echu eo ho sontadeg",
+  "notification.poll": "Ur sontadeg ho deus mouezhet warnañ a zo echuet",
   "notification.reblog": "{name} boosted your status",
   "notification.status": "{name} just posted",
   "notifications.clear": "Skarzhañ ar c'hemennoù",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Mouezhiañ",
   "poll.voted": "Mouezhiet ho peus evit ar respont-mañ",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Ouzhpennañ ur sontadeg",
   "poll_button.remove_poll": "Dilemel ar sontadeg",
   "privacy.change": "Kemmañ gwelidigezh ar statud",
@@ -415,30 +423,30 @@
   "status.show_less": "Diskouez nebeutoc'h",
   "status.show_less_all": "Show less for all",
   "status.show_more": "Diskouez muioc'h",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "Diskouez miuoc'h evit an holl",
   "status.show_thread": "Diskouez ar gaozeadenn",
   "status.uncached_media_warning": "Dihegerz",
   "status.unmute_conversation": "Diguzhat ar gaozeadenn",
   "status.unpin": "Dispilhennañ eus ar profil",
   "suggestions.dismiss": "Dismiss suggestion",
-  "suggestions.header": "You might be interested in…",
+  "suggestions.header": "Marteze e vefec'h dedenet gant…",
   "tabs_bar.federated_timeline": "Kevredet",
   "tabs_bar.home": "Degemer",
   "tabs_bar.local_timeline": "Lec'hel",
   "tabs_bar.notifications": "Kemennoù",
   "tabs_bar.search": "Klask",
-  "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
-  "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
-  "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
+  "time_remaining.days": "{number, plural,one {# devezh} other {# a zevezhioù}} a chom",
+  "time_remaining.hours": "{number, plural, one {# eurvezh} other{# eurvezh}} a chom",
+  "time_remaining.minutes": "{number, plural, one {# munut} other{# a vunutoù}} a chom",
   "time_remaining.moments": "Moments remaining",
-  "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
-  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "time_remaining.seconds": "{number, plural, one {# eilenn} other{# eilenn}} a chom",
+  "timeline_hint.remote_resource_not_displayed": "{resource} eus servijerien all n'int ket diskouezet.",
   "timeline_hint.resources.followers": "Heulier·ezed·ien",
   "timeline_hint.resources.follows": "Heuliañ",
   "timeline_hint.resources.statuses": "Toudoù koshoc'h",
-  "trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
+  "trends.counter_by_accounts": "{count, plural, one {{counter} den} other {{counter} a zud}} a zo o komz",
   "trends.trending_now": "Luskad ar mare",
-  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
+  "ui.beforeunload": "Kollet e vo ho prell ma kuitit Mastodon.",
   "units.short.billion": "{count}B",
   "units.short.million": "{count}M",
   "units.short.thousand": "{count}K",
@@ -446,14 +454,15 @@
   "upload_button.label": "Ouzhpennañ ur media",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
-  "upload_form.audio_description": "Describe for people with hearing loss",
-  "upload_form.description": "Describe for the visually impaired",
+  "upload_form.audio_description": "Diskrivañ evit tud a zo kollet o c'hlev",
+  "upload_form.description": "Diskrivañ evit tud a zo kollet o gweled",
   "upload_form.edit": "Aozañ",
   "upload_form.thumbnail": "Kemmañ ar velvenn",
   "upload_form.undo": "Dilemel",
-  "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
+  "upload_form.video_description": "Diskrivañ evit tud a zo kollet o gweled pe o c'hlev",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Arloañ",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Dibab ur skeudenn",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Dinoiñ testenn diouzh ar skeudenn",
@@ -468,8 +477,8 @@
   "video.expand": "Expand video",
   "video.fullscreen": "Skramm a-bezh",
   "video.hide": "Kuzhat ar video",
-  "video.mute": "Mute sound",
+  "video.mute": "Paouez gant ar son",
   "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "video.play": "Lenn",
+  "video.unmute": "Lakaat ar son en-dro"
 }
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 7aac7caef..9434dbcc3 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -46,12 +46,17 @@
   "account.unfollow": "Deixa de seguir",
   "account.unmute": "Treure silenci de @{name}",
   "account.unmute_notifications": "Activar notificacions de @{name}",
-  "account_note.placeholder": "Sense comentaris",
+  "account_note.placeholder": "Fes clic per afegir una nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Si us plau torna-ho a provar després de {retry_time, time, medium}.",
   "alert.rate_limited.title": "Límit de freqüència",
   "alert.unexpected.message": "S'ha produït un error inesperat.",
   "alert.unexpected.title": "Vaja!",
   "announcement.announcement": "Anunci",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per setmana",
   "boost_modal.combo": "Pots prémer {combo} per saltar-te això el proper cop",
   "bundle_column_error.body": "S'ha produït un error en carregar aquest component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Estàs segur que vols suprimir aquest tut?",
   "confirmations.delete_list.confirm": "Suprimeix",
   "confirmations.delete_list.message": "Estàs segur que vols suprimir permanentment aquesta llista?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Amaga tot el domini",
   "confirmations.domain_block.message": "Estàs segur, realment segur que vols bloquejar totalment {domain}? En la majoria dels casos bloquejar o silenciar uns pocs objectius és suficient i preferible. No veuràs contingut d’aquest domini en cap de les línies de temps ni en les notificacions. Els teus seguidors d’aquest domini seran eliminats.",
   "confirmations.logout.confirm": "Tancar sessió",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vot} other {# vots}}",
   "poll.vote": "Vota",
   "poll.voted": "Vas votar per aquesta resposta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Afegeix una enquesta",
   "poll_button.remove_poll": "Elimina l'enquesta",
   "privacy.change": "Ajusta l'estat de privacitat",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Descriu per a les persones amb pèrdua auditiva o deficiència visual",
   "upload_modal.analyzing_picture": "Analitzant imatge…",
   "upload_modal.apply": "Aplica",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Tria imatge",
   "upload_modal.description_placeholder": "Jove xef, porti whisky amb quinze glaçons d’hidrogen, coi!",
   "upload_modal.detect_text": "Detecta el text de l'imatge",
diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json
index 03df085ca..b8ac9281d 100644
--- a/app/javascript/mastodon/locales/co.json
+++ b/app/javascript/mastodon/locales/co.json
@@ -47,11 +47,16 @@
   "account.unmute": "Ùn piattà più @{name}",
   "account.unmute_notifications": "Ùn piattà più nutificazione da @{name}",
   "account_note.placeholder": "Senza cummentariu",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Pruvate ancu dop'à {retry_time, time, medium}.",
   "alert.rate_limited.title": "Ghjettu limitatu",
   "alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
   "alert.unexpected.title": "Uups!",
   "announcement.announcement": "Annunziu",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per settimana",
   "boost_modal.combo": "Pudete appughjà nant'à {combo} per saltà quessa a prussima volta",
   "bundle_column_error.body": "C'hè statu un prublemu caricandu st'elementu.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Site sicuru·a che vulete sguassà stu statutu?",
   "confirmations.delete_list.confirm": "Toglie",
   "confirmations.delete_list.message": "Site sicuru·a che vulete toglie sta lista?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Piattà tuttu u duminiu",
   "confirmations.domain_block.message": "Site veramente sicuru·a che vulete piattà tuttu à {domain}? Saria forse abbastanza di bluccà ò piattà alcuni conti da quallà. Ùn viderete più nunda da quallà indè e linee pubbliche o e nutificazione. I vostri abbunati da stu duminiu saranu tolti.",
   "confirmations.logout.confirm": "Scunnettassi",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# votu} other {# voti}}",
   "poll.vote": "Vutà",
   "poll.voted": "Avete vutatu per sta risposta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Aghjunghje",
   "poll_button.remove_poll": "Toglie u scandagliu",
   "privacy.change": "Mudificà a cunfidenzialità di u statutu",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Discrizzione per i ciochi o cechi",
   "upload_modal.analyzing_picture": "Analisi di u ritrattu…",
   "upload_modal.apply": "Affettà",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Cambià ritrattu",
   "upload_modal.description_placeholder": "Chì tempi brevi ziu, quandu solfeghji",
   "upload_modal.detect_text": "Ditettà testu da u ritrattu",
diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json
index ca0a34b33..636fbd4ea 100644
--- a/app/javascript/mastodon/locales/cs.json
+++ b/app/javascript/mastodon/locales/cs.json
@@ -17,7 +17,7 @@
   "account.follow": "Sledovat",
   "account.followers": "Sledující",
   "account.followers.empty": "Tohoto uživatele ještě nikdo nesleduje.",
-  "account.followers_counter": "{count, plural, one {{counter} sledující} few {{counter} sledující} many {{counter} sledujících} other {{counter} sledujících}}",
+  "account.followers_counter": "{count, plural, one {{counter} Sledující} few {{counter} Sledující} many {{counter} Sledujících} other {{counter} Sledujících}}",
   "account.following_counter": "{count, plural, one {{counter} Sledovaný} few {{counter} Sledovaní} many {{counter} Sledovaných} other {{counter} Sledovaných}}",
   "account.follows.empty": "Tento uživatel ještě nikoho nesleduje.",
   "account.follows_you": "Sleduje vás",
@@ -47,11 +47,16 @@
   "account.unmute": "Zrušit skrytí @{name}",
   "account.unmute_notifications": "Zrušit skrytí oznámení od @{name}",
   "account_note.placeholder": "Klikněte pro přidání poznámky",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Zkuste to prosím znovu za {retry_time, time, medium}.",
-  "alert.rate_limited.title": "Rychlost omezena",
+  "alert.rate_limited.title": "Spojení omezena",
   "alert.unexpected.message": "Objevila se neočekávaná chyba.",
   "alert.unexpected.title": "Jejda!",
   "announcement.announcement": "Oznámení",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} za týden",
   "boost_modal.combo": "Příště můžete pro přeskočení stisknout {combo}",
   "bundle_column_error.body": "Při načítání této komponenty se něco pokazilo.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Opravdu chcete smazat tento příspěvek?",
   "confirmations.delete_list.confirm": "Smazat",
   "confirmations.delete_list.message": "Opravdu chcete tento seznam navždy smazat?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Blokovat celou doménu",
   "confirmations.domain_block.message": "Opravdu chcete blokovat celou doménu {domain}? Ve většině případů stačí zablokovat nebo skrýt pár konkrétních uživatelů, což také doporučujeme. Z této domény neuvidíte obsah v žádné veřejné časové ose ani v oznámeních. Vaši sledující z této domény budou odstraněni.",
   "confirmations.logout.confirm": "Odhlásit",
@@ -142,7 +149,7 @@
   "emoji_button.food": "Jídla a nápoje",
   "emoji_button.label": "Vložit emoji",
   "emoji_button.nature": "Příroda",
-  "emoji_button.not_found": "Žádné emoji! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Nenalezeny žádné odpovídající emoji",
   "emoji_button.objects": "Předměty",
   "emoji_button.people": "Lidé",
   "emoji_button.recent": "Často používané",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# hlas} few {# hlasy} many {# hlasů} other {# hlasů}}",
   "poll.vote": "Hlasovat",
   "poll.voted": "Pro tuto odpověď jste hlasovali",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Přidat anketu",
   "poll_button.remove_poll": "Odstranit anketu",
   "privacy.change": "Změnit soukromí příspěvku",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Popis pro sluchově či zrakově postižené",
   "upload_modal.analyzing_picture": "Analyzuji obrázek…",
   "upload_modal.apply": "Použít",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Vybrat obrázek",
   "upload_modal.description_placeholder": "Příliš žluťoučký kůň úpěl ďábelské ódy",
   "upload_modal.detect_text": "Detekovat text z obrázku",
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index 45820b132..2f8c09014 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -47,11 +47,16 @@
   "account.unmute": "Dad-dawelu @{name}",
   "account.unmute_notifications": "Dad-dawelu hysbysiadau o @{name}",
   "account_note.placeholder": "Clicio i ychwanegu nodyn",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Ceisiwch eto ar ôl {retry_time, time, medium}.",
   "alert.rate_limited.title": "Cyfradd gyfyngedig",
   "alert.unexpected.message": "Digwyddodd gwall annisgwyl.",
   "alert.unexpected.title": "Wps!",
   "announcement.announcement": "Cyhoeddiad",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} yr wythnos",
   "boost_modal.combo": "Mae modd gwasgu {combo} er mwyn sgipio hyn tro nesa",
   "bundle_column_error.body": "Aeth rhywbeth o'i le tra'n llwytho'r elfen hon.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y tŵt hwn?",
   "confirmations.delete_list.confirm": "Dileu",
   "confirmations.delete_list.message": "Ydych chi'n sicr eich bod eisiau dileu y rhestr hwn am byth?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Cuddio parth cyfan",
   "confirmations.domain_block.message": "A ydych yn hollol, hollol sicr eich bod am flocio y {domain} cyfan? Yn y nifer helaeth o achosion mae blocio neu tawelu ambell gyfrif yn ddigonol ac yn well. Ni fyddwch yn gweld cynnwys o'r parth hwnnw mewn unrhyw ffrydiau cyhoeddus na chwaith yn eich hysbysiadau. Bydd hyn yn cael gwared o'ch dilynwyr o'r parth hwnnw.",
   "confirmations.logout.confirm": "Allgofnodi",
@@ -256,7 +263,7 @@
   "lists.new.title_placeholder": "Teitl rhestr newydd",
   "lists.replies_policy.followed": "Any followed user",
   "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
+  "lists.replies_policy.none": "Neb",
   "lists.replies_policy.title": "Show replies to:",
   "lists.search": "Chwilio ymysg pobl yr ydych yn ei ddilyn",
   "lists.subheading": "Eich rhestrau",
@@ -265,9 +272,9 @@
   "media_gallery.toggle_visible": "Toglo gwelededd",
   "missing_indicator.label": "Heb ei ganfod",
   "missing_indicator.sublabel": "Ni ellid canfod yr adnodd hwn",
-  "mute_modal.duration": "Duration",
+  "mute_modal.duration": "Hyd",
   "mute_modal.hide_notifications": "Cuddio hysbysiadau rhag y defnyddiwr hwn?",
-  "mute_modal.indefinite": "Indefinite",
+  "mute_modal.indefinite": "Amhenodol",
   "navigation_bar.apps": "Apiau symudol",
   "navigation_bar.blocks": "Defnyddwyr wedi eu blocio",
   "navigation_bar.bookmarks": "Tudalnodau",
@@ -329,7 +336,7 @@
   "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
   "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
   "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
-  "notifications_permission_banner.enable": "Enable desktop notifications",
+  "notifications_permission_banner.enable": "Galluogi hysbysiadau bwrdd gwaith",
   "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
   "notifications_permission_banner.title": "Never miss a thing",
   "picture_in_picture.restore": "Put it back",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# bleidlais} other {# o bleidleisiau}}",
   "poll.vote": "Pleidleisio",
   "poll.voted": "Pleidleisioch chi am yr ateb hon",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Ychwanegu pleidlais",
   "poll_button.remove_poll": "Tynnu pleidlais",
   "privacy.change": "Addasu preifatrwdd y tŵt",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Disgrifio ar gyfer pobl sydd â cholled clyw neu amhariad golwg",
   "upload_modal.analyzing_picture": "Dadansoddi llun…",
   "upload_modal.apply": "Gweithredu",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Dewis delwedd",
   "upload_modal.description_placeholder": "Mae ei phen bach llawn jocs, 'run peth a fy nghot golff, rhai dyddiau",
   "upload_modal.detect_text": "Canfod testun o'r llun",
diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json
index 6042840e2..9db5da3eb 100644
--- a/app/javascript/mastodon/locales/da.json
+++ b/app/javascript/mastodon/locales/da.json
@@ -47,11 +47,16 @@
   "account.unmute": "Fjern tavsgjort for @{name}",
   "account.unmute_notifications": "Fjern tavsgjort for notifikationer fra @{name}",
   "account_note.placeholder": "Klik for at tilføje notat",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Forsøg igen efter {retry_time, time, medium}.",
   "alert.rate_limited.title": "Gradsbegrænset",
   "alert.unexpected.message": "En uventet fejl opstod.",
   "alert.unexpected.title": "Ups!",
   "announcement.announcement": "Bekendtgørelse",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} pr. uge",
   "boost_modal.combo": "Du kan trykke på {combo} for at overspringe dette næste gang",
   "bundle_column_error.body": "Noget gik galt under indlæsningen af denne komponent.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Sikker på, at du vil slette dette trut?",
   "confirmations.delete_list.confirm": "Slet",
   "confirmations.delete_list.message": "Sikker på, at du vil slette denne liste permanent?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Skjul hele domænet",
   "confirmations.domain_block.message": "Helt sikker på, at du vil blokere hele {domain}-domænet? Oftest vil få, specifikke blokeringer eller tavsgørelser være nok og at fortrække. Du vil ikke se indhold fra domænet på offentlige tidslinjer eller i dine notifikationer. Dine følgere fra domænet fjernes.",
   "confirmations.logout.confirm": "Log ud",
@@ -246,7 +253,7 @@
   "lightbox.compress": "Komprimér billedvisningsfelt",
   "lightbox.expand": "Udvid billedevisningsfelt",
   "lightbox.next": "Næste",
-  "lightbox.previous": "Foregående",
+  "lightbox.previous": "Forrige",
   "lists.account.add": "Føj til liste",
   "lists.account.remove": "Fjern fra liste",
   "lists.delete": "Slet liste",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# stemme} other {# stemmer}}",
   "poll.vote": "Stem",
   "poll.voted": "Du stemte for dette svar",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Tilføj en afstemning",
   "poll_button.remove_poll": "Fjern afstemning",
   "privacy.change": "Justér trutfortrolighed",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Beskrivelse for hørehæmmede eller synshandicappede personer",
   "upload_modal.analyzing_picture": "Analyserer billede…",
   "upload_modal.apply": "Anvend",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Vælg billede",
   "upload_modal.description_placeholder": "En hurtig brun ræv hopper over den dovne hund",
   "upload_modal.detect_text": "Detektér tekst i billede",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index c98e3230d..4e98b7e41 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -15,7 +15,7 @@
   "account.enable_notifications": "Benachrichtige mich wenn @{name} etwas postet",
   "account.endorse": "Auf Profil hervorheben",
   "account.follow": "Folgen",
-  "account.followers": "Folgende",
+  "account.followers": "Follower",
   "account.followers.empty": "Diesem Profil folgt noch niemand.",
   "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
   "account.following_counter": "{count, plural, one {{counter} Folgender} other {{counter} Folgende}}",
@@ -30,7 +30,7 @@
   "account.mention": "@{name} erwähnen",
   "account.moved_to": "{name} ist umgezogen auf:",
   "account.mute": "@{name} stummschalten",
-  "account.mute_notifications": "Benachrichtigungen von @{name} verbergen",
+  "account.mute_notifications": "Benachrichtigungen von @{name} stummschalten",
   "account.muted": "Stummgeschaltet",
   "account.never_active": "Nie",
   "account.posts": "Beiträge",
@@ -47,11 +47,16 @@
   "account.unmute": "@{name} nicht mehr stummschalten",
   "account.unmute_notifications": "Benachrichtigungen von @{name} einschalten",
   "account_note.placeholder": "Notiz durch Klicken hinzufügen",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Bitte versuche es nach {retry_time, time, medium}.",
   "alert.rate_limited.title": "Anfragelimit überschritten",
   "alert.unexpected.message": "Ein unerwarteter Fehler ist aufgetreten.",
   "alert.unexpected.title": "Hoppla!",
   "announcement.announcement": "Ankündigung",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} pro Woche",
   "boost_modal.combo": "Drücke {combo}, um dieses Fenster zu überspringen",
   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
@@ -113,7 +118,9 @@
   "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
   "confirmations.delete_list.confirm": "Löschen",
   "confirmations.delete_list.message": "Bist du dir sicher, dass du diese Liste permanent löschen möchtest?",
-  "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
+  "confirmations.domain_block.confirm": "Die ganze Domain blockieren",
   "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} blockieren willst? In den meisten Fällen reichen ein paar gezielte Blockierungen oder Stummschaltungen aus. Du wirst den Inhalt von dieser Domain nicht in irgendwelchen öffentlichen Timelines oder den Benachrichtigungen finden. Deine Folgenden von dieser Domain werden entfernt.",
   "confirmations.logout.confirm": "Abmelden",
   "confirmations.logout.message": "Bist du sicher, dass du dich abmelden möchtest?",
@@ -339,12 +346,13 @@
   "poll.total_votes": "{count, plural, one {# Stimme} other {# Stimmen}}",
   "poll.vote": "Abstimmen",
   "poll.voted": "Du hast dafür gestimmt",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Eine Umfrage erstellen",
   "poll_button.remove_poll": "Umfrage entfernen",
   "privacy.change": "Sichtbarkeit des Beitrags anpassen",
   "privacy.direct.long": "Wird an erwähnte Profile gesendet",
   "privacy.direct.short": "Direktnachricht",
-  "privacy.private.long": "Beitrag nur an Folgende",
+  "privacy.private.long": "Nur für Folgende sichtbar",
   "privacy.private.short": "Nur für Folgende",
   "privacy.public.long": "Wird in öffentlichen Zeitleisten erscheinen",
   "privacy.public.short": "Öffentlich",
@@ -435,7 +443,7 @@
   "timeline_hint.remote_resource_not_displayed": "{resource} von anderen Servern werden nicht angezeigt.",
   "timeline_hint.resources.followers": "Follower",
   "timeline_hint.resources.follows": "Folgt",
-  "timeline_hint.resources.statuses": "Ältere Toots",
+  "timeline_hint.resources.statuses": "Ältere Beiträge",
   "trends.counter_by_accounts": "{count, plural, one {{counter} Person redet darüber} other {{counter} Personen reden darüber}}",
   "trends.trending_now": "In den Trends",
   "ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Beschreibe das Video für Menschen mit einer Hör- oder Sehbehinderung",
   "upload_modal.analyzing_picture": "Analysiere Bild…",
   "upload_modal.apply": "Übernehmen",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Bild auswählen",
   "upload_modal.description_placeholder": "Die heiße Zypernsonne quälte Max und Victoria ja böse auf dem Weg bis zur Küste",
   "upload_modal.detect_text": "Text aus Bild erkennen",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index f0841b160..950d73a46 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -82,6 +82,49 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Loading...",
+        "id": "loading_indicator.label"
+      },
+      {
+        "defaultMessage": "Sign-up month",
+        "id": "admin.dashboard.retention.cohort"
+      },
+      {
+        "defaultMessage": "New users",
+        "id": "admin.dashboard.retention.cohort_size"
+      },
+      {
+        "defaultMessage": "Average",
+        "id": "admin.dashboard.retention.average"
+      },
+      {
+        "defaultMessage": "Retention",
+        "id": "admin.dashboard.retention"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/admin/Retention.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Trending now",
+        "id": "trends.trending_now"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/admin/Trends.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "(unprocessed)",
+        "id": "attachments_list.unprocessed"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/attachment_list.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "{count} per week",
         "id": "autosuggest_hashtag.per_week"
       }
@@ -290,10 +333,13 @@
       },
       {
         "defaultMessage": "You voted for this answer",
-        "description": "Tooltip of the \"voted\" checkmark in polls",
         "id": "poll.voted"
       },
       {
+        "defaultMessage": "{votes, plural, one {# vote} other {# votes}}",
+        "id": "poll.votes"
+      },
+      {
         "defaultMessage": "{count, plural, one {# person} other {# people}}",
         "id": "poll.total_people"
       },
@@ -2218,8 +2264,12 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Show",
-        "id": "notifications.column_settings.filter_bar.show"
+        "defaultMessage": "Highlight unread notifications",
+        "id": "notifications.column_settings.unread_notifications.highlight"
+      },
+      {
+        "defaultMessage": "Show filter bar",
+        "id": "notifications.column_settings.filter_bar.show_bar"
       },
       {
         "defaultMessage": "Display all categories",
@@ -2250,8 +2300,8 @@
         "id": "notifications.permission_required"
       },
       {
-        "defaultMessage": "Unread notification markers",
-        "id": "notifications.column_settings.unread_markers.category"
+        "defaultMessage": "Unread notifications",
+        "id": "notifications.column_settings.unread_notifications.category"
       },
       {
         "defaultMessage": "Quick filter bar",
@@ -2909,6 +2959,10 @@
         "id": "upload_modal.apply"
       },
       {
+        "defaultMessage": "Applying…",
+        "id": "upload_modal.applying"
+      },
+      {
         "defaultMessage": "A quick brown fox jumps over the lazy dog",
         "id": "upload_modal.description_placeholder"
       },
@@ -2917,6 +2971,14 @@
         "id": "upload_modal.choose_image"
       },
       {
+        "defaultMessage": "You have unsaved changes to the media description or preview, discard them anyway?",
+        "id": "confirmations.discard_edit_media.message"
+      },
+      {
+        "defaultMessage": "Discard",
+        "id": "confirmations.discard_edit_media.confirm"
+      },
+      {
         "defaultMessage": "Describe for people with hearing loss",
         "id": "upload_form.audio_description"
       },
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
index 7e6fc095a..10e08cf97 100644
--- a/app/javascript/mastodon/locales/el.json
+++ b/app/javascript/mastodon/locales/el.json
@@ -47,11 +47,16 @@
   "account.unmute": "Διακοπή αποσιώπησης @{name}",
   "account.unmute_notifications": "Διακοπή αποσιώπησης ειδοποιήσεων του/της @{name}",
   "account_note.placeholder": "Κλικ για να βάλεις σημείωση",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Παρακαλούμε δοκίμασε ξανά αφού περάσει η {retry_time, time, medium}.",
   "alert.rate_limited.title": "Περιορισμός συχνότητας",
   "alert.unexpected.message": "Προέκυψε απροσδόκητο σφάλμα.",
   "alert.unexpected.title": "Εεπ!",
   "announcement.announcement": "Ανακοίνωση",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} ανα εβδομάδα",
   "boost_modal.combo": "Μπορείς να πατήσεις {combo} για να το προσπεράσεις αυτό την επόμενη φορά",
   "bundle_column_error.body": "Κάτι πήγε στραβά ενώ φορτωνόταν αυτό το στοιχείο.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Σίγουρα θες να διαγράψεις αυτή τη δημοσίευση;",
   "confirmations.delete_list.confirm": "Διέγραψε",
   "confirmations.delete_list.message": "Σίγουρα θες να διαγράψεις οριστικά αυτή τη λίστα;",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Απόκρυψη ολόκληρου του τομέα",
   "confirmations.domain_block.message": "Σίγουρα θες να μπλοκάρεις ολόκληρο το {domain}; Συνήθως μερικά εστιασμένα μπλοκ ή αποσιωπήσεις επαρκούν και προτιμούνται. Δεν θα βλέπεις περιεχόμενο από αυτό τον κόμβο σε καμία δημόσια ροή, ούτε στις ειδοποιήσεις σου. Όσους ακόλουθους έχεις αυτό αυτό τον κόμβο θα αφαιρεθούν.",
   "confirmations.logout.confirm": "Αποσύνδεση",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# ψήφος} other {# ψήφοι}}",
   "poll.vote": "Ψήφισε",
   "poll.voted": "Ψηφίσατε αυτήν την απάντηση",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Προσθήκη δημοσκόπησης",
   "poll_button.remove_poll": "Αφαίρεση δημοσκόπησης",
   "privacy.change": "Προσαρμογή ιδιωτικότητας δημοσίευσης",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Περιγραφή για άτομα με προβλήματα ακοής ή όρασης",
   "upload_modal.analyzing_picture": "Ανάλυση εικόνας…",
   "upload_modal.apply": "Εφαρμογή",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Επιλογή εικόνας",
   "upload_modal.description_placeholder": "Λύκος μαύρος και ισχνός του πατέρα του καημός",
   "upload_modal.detect_text": "Αναγνώριση κειμένου από την εικόνα",
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 03079cb60..ac7d4b53a 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -117,6 +122,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this post?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Block entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -310,7 +317,7 @@
   "notifications.column_settings.favourite": "Favourites:",
   "notifications.column_settings.filter_bar.advanced": "Display all categories",
   "notifications.column_settings.filter_bar.category": "Quick filter bar",
-  "notifications.column_settings.filter_bar.show": "Show",
+  "notifications.column_settings.filter_bar.show_bar": "Show filter bar",
   "notifications.column_settings.follow": "New followers:",
   "notifications.column_settings.follow_request": "New follow requests:",
   "notifications.column_settings.mention": "Mentions:",
@@ -320,7 +327,8 @@
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
   "notifications.column_settings.status": "New posts:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.unread_notifications.category": "Unread notifications",
+  "notifications.column_settings.unread_notifications.highlight": "Highlight unread notifications",
   "notifications.filter.all": "All",
   "notifications.filter.boosts": "Boosts",
   "notifications.filter.favourites": "Favourites",
@@ -344,6 +352,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Change post privacy",
@@ -459,6 +468,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 2d7af6627..ac1f195b4 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -22,11 +22,11 @@
   "account.follows.empty": "Tiu uzanto ankoraŭ ne sekvas iun.",
   "account.follows_you": "Sekvas vin",
   "account.hide_reblogs": "Kaŝi diskonigojn de @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Kuniĝis {date}",
   "account.last_status": "Laste aktiva",
   "account.link_verified_on": "La posedanto de tiu ligilo estis kontrolita je {date}",
   "account.locked_info": "La privateco de tiu konto estas elektita kiel fermita. La posedanto povas mane akcepti tiun, kiu povas sekvi rin.",
-  "account.media": "Aŭdovidaĵoj",
+  "account.media": "Amaskomunikiloj",
   "account.mention": "Mencii @{name}",
   "account.moved_to": "{name} moviĝis al:",
   "account.mute": "Silentigi @{name}",
@@ -47,11 +47,16 @@
   "account.unmute": "Malsilentigi @{name}",
   "account.unmute_notifications": "Malsilentigi sciigojn de @{name}",
   "account_note.placeholder": "Alklaku por aldoni noton",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Bonvolu reprovi post {retry_time, time, medium}.",
   "alert.rate_limited.title": "Mesaĝkvante limigita",
   "alert.unexpected.message": "Neatendita eraro okazis.",
   "alert.unexpected.title": "Ups!",
   "announcement.announcement": "Anonco",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} semajne",
   "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje",
   "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.",
@@ -62,7 +67,7 @@
   "bundle_modal_error.retry": "Bonvolu reprovi",
   "column.blocks": "Blokitaj uzantoj",
   "column.bookmarks": "Legosignoj",
-  "column.community": "Loka tempolinio",
+  "column.community": "Loka templinio",
   "column.direct": "Rektaj mesaĝoj",
   "column.directory": "Trarigardi profilojn",
   "column.domain_blocks": "Blokitaj domajnoj",
@@ -73,7 +78,7 @@
   "column.mutes": "Silentigitaj uzantoj",
   "column.notifications": "Sciigoj",
   "column.pins": "Alpinglitaj mesaĝoj",
-  "column.public": "Fratara tempolinio",
+  "column.public": "Fratara templinio",
   "column_back_button.label": "Reveni",
   "column_header.hide_settings": "Kaŝi agordojn",
   "column_header.moveLeft_settings": "Movi kolumnon maldekstren",
@@ -113,8 +118,10 @@
   "confirmations.delete.message": "Ĉu vi certas, ke vi volas forigi ĉi tiun mesaĝon?",
   "confirmations.delete_list.confirm": "Forigi",
   "confirmations.delete_list.message": "Ĉu vi certas, ke vi volas porĉiame forigi ĉi tiun liston?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Bloki la tutan domajnon",
-  "confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas tute bloki {domain}? Plej ofte, trafa blokado kaj silentigado sufiĉas kaj preferindas. Vi ne vidos enhavon de tiu domajno en publika tempolinio aŭ en viaj sciigoj. Viaj sekvantoj de tiu domajno estos forigitaj.",
+  "confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas tute bloki {domain}? Plej ofte, trafa blokado kaj silentigado sufiĉas kaj preferindas. Vi ne vidos enhavon de tiu domajno en publika templinio aŭ en viaj sciigoj. Viaj sekvantoj de tiu domajno estos forigitaj.",
   "confirmations.logout.confirm": "Elsaluti",
   "confirmations.logout.message": "Ĉu vi certas ke vi volas elsaluti?",
   "confirmations.mute.confirm": "Silentigi",
@@ -155,7 +162,7 @@
   "empty_column.account_unavailable": "Profilo ne disponebla",
   "empty_column.blocks": "Vi ankoraŭ ne blokis uzanton.",
   "empty_column.bookmarked_statuses": "Vi ankoraŭ ne aldonis mesaĝon al viaj legosignoj. Kiam vi aldonos iun, tiu aperos ĉi tie.",
-  "empty_column.community": "La loka tempolinio estas malplena. Skribu ion por plenigi ĝin!",
+  "empty_column.community": "La loka templinio estas malplena. Skribu ion por plenigi ĝin!",
   "empty_column.direct": "Vi ankoraŭ ne havas rektan mesaĝon. Kiam vi sendos aŭ ricevos iun, ĝi aperos ĉi tie.",
   "empty_column.domain_blocks": "Ankoraŭ neniu domajno estas blokita.",
   "empty_column.favourited_statuses": "Vi ankoraŭ ne stelumis mesaĝon. Kiam vi stelumos iun, tiu aperos ĉi tie.",
@@ -219,12 +226,12 @@
   "keyboard_shortcuts.enter": "malfermi mesaĝon",
   "keyboard_shortcuts.favourite": "stelumi",
   "keyboard_shortcuts.favourites": "malfermi la liston de stelumoj",
-  "keyboard_shortcuts.federated": "malfermi la frataran tempolinion",
+  "keyboard_shortcuts.federated": "Malfermi la frataran templinion",
   "keyboard_shortcuts.heading": "Klavaraj mallongigoj",
-  "keyboard_shortcuts.home": "malfermi la hejman tempolinion",
+  "keyboard_shortcuts.home": "Malfermi la hejman templinion",
   "keyboard_shortcuts.hotkey": "Rapidklavo",
   "keyboard_shortcuts.legend": "montri ĉi tiun noton",
-  "keyboard_shortcuts.local": "malfermi la lokan tempolinion",
+  "keyboard_shortcuts.local": "Malfermi la lokan templinion",
   "keyboard_shortcuts.mention": "mencii la aŭtoron",
   "keyboard_shortcuts.muted": "malfermi la liston de silentigitaj uzantoj",
   "keyboard_shortcuts.my_profile": "malfermi vian profilon",
@@ -271,7 +278,7 @@
   "navigation_bar.apps": "Telefonaj aplikaĵoj",
   "navigation_bar.blocks": "Blokitaj uzantoj",
   "navigation_bar.bookmarks": "Legosignoj",
-  "navigation_bar.community_timeline": "Loka tempolinio",
+  "navigation_bar.community_timeline": "Loka templinio",
   "navigation_bar.compose": "Skribi novan mesaĝon",
   "navigation_bar.direct": "Rektaj mesaĝoj",
   "navigation_bar.discover": "Esplori",
@@ -289,7 +296,7 @@
   "navigation_bar.personal": "Persone",
   "navigation_bar.pins": "Alpinglitaj mesaĝoj",
   "navigation_bar.preferences": "Preferoj",
-  "navigation_bar.public_timeline": "Fratara tempolinio",
+  "navigation_bar.public_timeline": "Fratara templinio",
   "navigation_bar.security": "Sekureco",
   "notification.favourite": "{name} stelumis vian mesaĝon",
   "notification.follow": "{name} eksekvis vin",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# voĉdono} other {# voĉdonoj}}",
   "poll.vote": "Voĉdoni",
   "poll.voted": "Vi elektis por ĉi tiu respondo",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Aldoni balotenketon",
   "poll_button.remove_poll": "Forigi balotenketon",
   "privacy.change": "Agordi mesaĝan privatecon",
@@ -346,9 +354,9 @@
   "privacy.direct.short": "Rekta",
   "privacy.private.long": "Videbla nur al viaj sekvantoj",
   "privacy.private.short": "Nur al sekvantoj",
-  "privacy.public.long": "Videbla al ĉiuj, afiŝita en publikaj tempolinioj",
+  "privacy.public.long": "Videbla al ĉiuj, afiŝita en publikaj templinioj",
   "privacy.public.short": "Publika",
-  "privacy.unlisted.long": "Videbla al ĉiuj, sed ne en publikaj tempolinioj",
+  "privacy.unlisted.long": "Videbla al ĉiuj, sed ne en publikaj templinioj",
   "privacy.unlisted.short": "Nelistigita",
   "refresh": "Refreŝigu",
   "regeneration_indicator.label": "Ŝargado…",
@@ -422,9 +430,9 @@
   "status.unpin": "Depingli de profilo",
   "suggestions.dismiss": "Forigi la proponon",
   "suggestions.header": "Vi povus interesiĝi pri…",
-  "tabs_bar.federated_timeline": "Fratara tempolinio",
+  "tabs_bar.federated_timeline": "Fratara templinio",
   "tabs_bar.home": "Hejmo",
-  "tabs_bar.local_timeline": "Loka tempolinio",
+  "tabs_bar.local_timeline": "Loka templinio",
   "tabs_bar.notifications": "Sciigoj",
   "tabs_bar.search": "Serĉi",
   "time_remaining.days": "{number, plural, one {# tago} other {# tagoj}} restas",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Priskribi por homoj kiuj malfacile aŭdi aŭ vidi",
   "upload_modal.analyzing_picture": "Bilda analizado…",
   "upload_modal.apply": "Apliki",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Elekti bildon",
   "upload_modal.description_placeholder": "Laŭ Ludoviko Zamenhof bongustas freŝa ĉeĥa manĝaĵo kun spicoj",
   "upload_modal.detect_text": "Detekti tekston de la bildo",
diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json
index 71ada86ac..08e9564da 100644
--- a/app/javascript/mastodon/locales/es-AR.json
+++ b/app/javascript/mastodon/locales/es-AR.json
@@ -47,11 +47,16 @@
   "account.unmute": "Dejar de silenciar a @{name}",
   "account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}",
   "account_note.placeholder": "Hacé clic par agregar una nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Por favor, reintentá después de las {retry_time, time, medium}.",
   "alert.rate_limited.title": "Acción limitada",
   "alert.unexpected.message": "Ocurrió un error.",
   "alert.unexpected.title": "¡Epa!",
   "announcement.announcement": "Anuncio",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Podés hacer clic en {combo} para saltar esto la próxima vez",
   "bundle_column_error.body": "Algo salió mal al cargar este componente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "¿Estás seguro que querés eliminar este mensaje?",
   "confirmations.delete_list.confirm": "Eliminar",
   "confirmations.delete_list.message": "¿Estás seguro que querés eliminar permanentemente esta lista?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Bloquear dominio entero",
   "confirmations.domain_block.message": "¿Estás completamente seguro que querés bloquear el {domain} entero? En la mayoría de los casos, unos cuantos bloqueos y silenciados puntuales son suficientes y preferibles. No vas a ver contenido de ese dominio en ninguna de tus líneas temporales o en tus notificaciones. Tus seguidores de ese dominio serán quitados.",
   "confirmations.logout.confirm": "Cerrar sesión",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# voto} other {# votos}}",
   "poll.vote": "Votar",
   "poll.voted": "Votaste esta opción",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Agregar encuesta",
   "poll_button.remove_poll": "Quitar encuesta",
   "privacy.change": "Configurar privacidad del mensaje",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Agregá una descripción para personas con dificultades auditivas o visuales",
   "upload_modal.analyzing_picture": "Analizando imagen…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Elegir imagen",
   "upload_modal.description_placeholder": "El veloz murciélago hindú comía feliz cardillo y kiwi. La cigüeña tocaba el saxofón detrás del palenque de paja.",
   "upload_modal.detect_text": "Detectar texto de la imagen",
diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json
index f0fbee142..ffd159c02 100644
--- a/app/javascript/mastodon/locales/es-MX.json
+++ b/app/javascript/mastodon/locales/es-MX.json
@@ -47,11 +47,16 @@
   "account.unmute": "Dejar de silenciar a @{name}",
   "account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}",
   "account_note.placeholder": "Clic para añadir nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Por favor reintente después de {retry_time, time, medium}.",
   "alert.rate_limited.title": "Tarifa limitada",
   "alert.unexpected.message": "Hubo un error inesperado.",
   "alert.unexpected.title": "¡Ups!",
   "announcement.announcement": "Anuncio",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Puedes hacer clic en {combo} para saltar este aviso la próxima vez",
   "bundle_column_error.body": "Algo salió mal al cargar este componente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "¿Estás seguro de que quieres borrar este toot?",
   "confirmations.delete_list.confirm": "Eliminar",
   "confirmations.delete_list.message": "¿Seguro que quieres borrar esta lista permanentemente?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Ocultar dominio entero",
   "confirmations.domain_block.message": "¿Seguro de que quieres bloquear al dominio {domain} entero? En general unos cuantos bloqueos y silenciados concretos es suficiente y preferible.",
   "confirmations.logout.confirm": "Cerrar sesión",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# voto} other {# votos}}",
   "poll.vote": "Votar",
   "poll.voted": "Has votado a favor de esta respuesta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Añadir una encuesta",
   "poll_button.remove_poll": "Eliminar encuesta",
   "privacy.change": "Ajustar privacidad",
@@ -424,7 +432,7 @@
   "suggestions.header": "Es posible que te interese…",
   "tabs_bar.federated_timeline": "Federado",
   "tabs_bar.home": "Inicio",
-  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.local_timeline": "Reciente",
   "tabs_bar.notifications": "Notificaciones",
   "tabs_bar.search": "Buscar",
   "time_remaining.days": "{number, plural, one {# día restante} other {# días restantes}}",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describir para personas con problemas auditivos o visuales",
   "upload_modal.analyzing_picture": "Analizando imagen…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Elegir imagen",
   "upload_modal.description_placeholder": "Un rápido zorro marrón salta sobre el perro perezoso",
   "upload_modal.detect_text": "Detectar texto de la imagen",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 3a81c0727..2b7fb0b6c 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -47,11 +47,16 @@
   "account.unmute": "Dejar de silenciar a @{name}",
   "account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}",
   "account_note.placeholder": "Clic para añadir nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Por favor reintente después de {retry_time, time, medium}.",
   "alert.rate_limited.title": "Tarifa limitada",
   "alert.unexpected.message": "Hubo un error inesperado.",
   "alert.unexpected.title": "¡Ups!",
   "announcement.announcement": "Anuncio",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Puedes hacer clic en {combo} para saltar este aviso la próxima vez",
   "bundle_column_error.body": "Algo salió mal al cargar este componente.",
@@ -86,7 +91,7 @@
   "community.column_settings.media_only": "Solo media",
   "community.column_settings.remote_only": "Solo remoto",
   "compose_form.direct_message_warning": "Esta nueva publicación solo será enviada a los usuarios mencionados.",
-  "compose_form.direct_message_warning_learn_more": "Aprender mas",
+  "compose_form.direct_message_warning_learn_more": "Aprender más",
   "compose_form.hashtag_warning": "Esta publicación no se mostrará bajo ningún hashtag porque no está listada. Sólo las publicaciones públicas se pueden buscar por hashtag.",
   "compose_form.lock_disclaimer": "Tu cuenta no está {locked}. Todos pueden seguirte para ver tus publicaciones solo para seguidores.",
   "compose_form.lock_disclaimer.lock": "bloqueado",
@@ -98,10 +103,10 @@
   "compose_form.poll.switch_to_multiple": "Modificar encuesta para permitir múltiples opciones",
   "compose_form.poll.switch_to_single": "Modificar encuesta para permitir una única opción",
   "compose_form.publish": "Tootear",
-  "compose_form.publish_loud": "¡{publish}!",
-  "compose_form.sensitive.hide": "Marcar multimedia como sensible",
-  "compose_form.sensitive.marked": "Material marcado como sensible",
-  "compose_form.sensitive.unmarked": "Material no marcado como sensible",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "{count, plural, one {Marcar material como sensible} other {Marcar material como sensible}}",
+  "compose_form.sensitive.marked": "{count, plural, one {Material marcado como sensible} other {Material marcado como sensible}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {Material no marcado como sensible} other {Material no marcado como sensible}}",
   "compose_form.spoiler.marked": "Texto oculto tras la advertencia",
   "compose_form.spoiler.unmarked": "Texto no oculto",
   "compose_form.spoiler_placeholder": "Advertencia de contenido",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "¿Estás seguro de que quieres borrar esta publicación?",
   "confirmations.delete_list.confirm": "Eliminar",
   "confirmations.delete_list.message": "¿Seguro que quieres borrar esta lista permanentemente?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Ocultar dominio entero",
   "confirmations.domain_block.message": "¿Seguro de que quieres bloquear al dominio {domain} entero? En general unos cuantos bloqueos y silenciados concretos es suficiente y preferible.",
   "confirmations.logout.confirm": "Cerrar sesión",
@@ -146,7 +153,7 @@
   "emoji_button.objects": "Objetos",
   "emoji_button.people": "Gente",
   "emoji_button.recent": "Usados frecuentemente",
-  "emoji_button.search": "Buscar…",
+  "emoji_button.search": "Buscar...",
   "emoji_button.search_results": "Resultados de búsqueda",
   "emoji_button.symbols": "Símbolos",
   "emoji_button.travel": "Viajes y lugares",
@@ -163,7 +170,7 @@
   "empty_column.follow_recommendations": "Parece que no se ha podido generar ninguna sugerencia para ti. Puedes probar a buscar a gente que quizá conozcas o explorar los hashtags que están en tendencia.",
   "empty_column.follow_requests": "No tienes ninguna petición de seguidor. Cuando recibas una, se mostrará aquí.",
   "empty_column.hashtag": "No hay nada en este hashtag aún.",
-  "empty_column.home": "No estás siguiendo a nadie aún. Visita {public} o haz búsquedas para empezar y conocer gente nueva.",
+  "empty_column.home": "¡Tu línea temporal está vacía! Sigue a más personas para rellenarla. {suggestions}",
   "empty_column.home.suggestions": "Ver algunas sugerencias",
   "empty_column.list": "No hay nada en esta lista aún. Cuando miembros de esta lista publiquen nuevos estatus, estos aparecerán qui.",
   "empty_column.lists": "No tienes ninguna lista. cuando crees una, se mostrará aquí.",
@@ -210,7 +217,7 @@
   "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}",
   "keyboard_shortcuts.back": "volver atrás",
   "keyboard_shortcuts.blocked": "abrir una lista de usuarios bloqueados",
-  "keyboard_shortcuts.boost": "retootear",
+  "keyboard_shortcuts.boost": "Retootear",
   "keyboard_shortcuts.column": "enfocar un estado en una de las columnas",
   "keyboard_shortcuts.compose": "enfocar el área de texto de redacción",
   "keyboard_shortcuts.description": "Descripción",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# voto} other {# votos}}",
   "poll.vote": "Votar",
   "poll.voted": "Has votado a favor de esta respuesta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Añadir una encuesta",
   "poll_button.remove_poll": "Eliminar encuesta",
   "privacy.change": "Ajustar privacidad",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describir para personas con problemas auditivos o visuales",
   "upload_modal.analyzing_picture": "Analizando imagen…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Elegir imagen",
   "upload_modal.description_placeholder": "Un rápido zorro marrón salta sobre el perro perezoso",
   "upload_modal.detect_text": "Detectar texto de la imagen",
diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json
index dba0e3b20..e71e56dee 100644
--- a/app/javascript/mastodon/locales/et.json
+++ b/app/javascript/mastodon/locales/et.json
@@ -47,11 +47,16 @@
   "account.unmute": "Ära vaigista @{name}",
   "account.unmute_notifications": "Ära vaigista teateid kasutajalt @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Palun proovi uuesti pärast {retry_time, time, medium}.",
   "alert.rate_limited.title": "Piiratud",
   "alert.unexpected.message": "Tekkis ootamatu viga.",
   "alert.unexpected.title": "Oih!",
   "announcement.announcement": "Teadaanne",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} nädalas",
   "boost_modal.combo": "Võite vajutada {combo}, et see järgmine kord vahele jätta",
   "bundle_column_error.body": "Midagi läks valesti selle komponendi laadimisel.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Olete kindel, et soovite selle staatuse kustutada?",
   "confirmations.delete_list.confirm": "Kustuta",
   "confirmations.delete_list.message": "Olete kindel, et soovite selle nimekirja püsivalt kustutada?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Peida terve domeen",
   "confirmations.domain_block.message": "Olete ikka päris kindel, et soovite blokeerida terve {domain}? Enamikul juhtudel piisab mõnest sihitud blokist või vaigistusest, mis on eelistatav. Te ei näe selle domeeni sisu üheski avalikus ajajoones või teadetes. Teie jälgijad sellest domeenist eemaldatakse.",
   "confirmations.logout.confirm": "Välju",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# hääl} other {# hääli}}",
   "poll.vote": "Hääleta",
   "poll.voted": "Teie hääletasite selle poolt",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Lisa küsitlus",
   "poll_button.remove_poll": "Eemalda küsitlus",
   "privacy.change": "Muuda staatuse privaatsust",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Kirjelda kuulmis- või nägemispuudega inimeste jaoks",
   "upload_modal.analyzing_picture": "Analüüsime pilti…",
   "upload_modal.apply": "Rakenda",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "Kiire pruun rebane hüppab üle laisa koera",
   "upload_modal.detect_text": "Tuvasta teksti pildilt",
diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json
index 8970c7940..b457b7d45 100644
--- a/app/javascript/mastodon/locales/eu.json
+++ b/app/javascript/mastodon/locales/eu.json
@@ -47,11 +47,16 @@
   "account.unmute": "Desmututu @{name}",
   "account.unmute_notifications": "Desmututu @{name}(r)en jakinarazpenak",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Saiatu {retry_time, time, medium} barru.",
   "alert.rate_limited.title": "Abiadura mugatua",
   "alert.unexpected.message": "Ustekabeko errore bat gertatu da.",
   "alert.unexpected.title": "Ene!",
   "announcement.announcement": "Iragarpena",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} asteko",
   "boost_modal.combo": "{combo} sakatu dezakezu hurrengoan hau saltatzeko",
   "bundle_column_error.body": "Zerbait okerra gertatu da osagai hau kargatzean.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Ziur bidalketa hau ezabatu nahi duzula?",
   "confirmations.delete_list.confirm": "Ezabatu",
   "confirmations.delete_list.message": "Ziur behin betiko ezabatu nahi duzula zerrenda hau?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Ezkutatu domeinu osoa",
   "confirmations.domain_block.message": "Ziur, erabat ziur, {domain} domeinu osoa blokeatu nahi duzula? Gehienetan gutxi batzuk blokeatu edo mututzearekin nahikoa da. Ez duzu domeinu horretako edukirik ikusiko denbora lerroetan edo jakinarazpenetan. Domeinu horretako zure jarraitzaileak kenduko dira ere.",
   "confirmations.logout.confirm": "Amaitu saioa",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {boto #} other {# boto}}",
   "poll.vote": "Bozkatu",
   "poll.voted": "Erantzun honi eman diozu botoa",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Gehitu inkesta bat",
   "poll_button.remove_poll": "Kendu inkesta",
   "privacy.change": "Aldatu bidalketaren pribatutasuna",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Deskribatu entzumen galera edo ikusmen urritasuna duten pertsonentzat",
   "upload_modal.analyzing_picture": "Irudia aztertzen…",
   "upload_modal.apply": "Aplikatu",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Aukeratu irudia",
   "upload_modal.description_placeholder": "Vaudeville itxurako filmean yogi ñaño bat jipoitzen dute Quebec-en whiski truk",
   "upload_modal.detect_text": "Antzeman testua iruditik",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 59b512591..c0582da74 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -3,93 +3,98 @@
   "account.add_or_remove_from_list": "افزودن یا برداشتن از فهرست‌ها",
   "account.badges.bot": "ربات",
   "account.badges.group": "گروه",
-  "account.block": "مسدودسازی @{name}",
-  "account.block_domain": "بستن دامنه {domain}",
-  "account.blocked": "مسدود",
+  "account.block": "مسدود کردن @{name}",
+  "account.block_domain": "مسدود کردن دامنهٔ {domain}",
+  "account.blocked": "مسدود شده",
   "account.browse_more_on_origin_server": "مرور بیش‌تر روی نمایهٔ اصلی",
-  "account.cancel_follow_request": "لغو درخواست پیگیری",
-  "account.direct": "پیام خصوصی به @{name}",
+  "account.cancel_follow_request": "لغو درخواست پی‌گیری",
+  "account.direct": "پیام مستقیم به @{name}",
   "account.disable_notifications": "آگاهی به من هنگام فرستادن‌های @{name} پایان یابد",
-  "account.domain_blocked": "دامنه بسته شد",
+  "account.domain_blocked": "دامنه مسدود شد",
   "account.edit_profile": "ویرایش نمایه",
-  "account.enable_notifications": "آگاهی هنگام ارسال‌های @{name}",
+  "account.enable_notifications": "هنگام فرسته‌های @{name} مرا آگاه کن",
   "account.endorse": "معرّفی در نمایه",
-  "account.follow": "پی بگیرید",
-  "account.followers": "پی‌گیران",
-  "account.followers.empty": "هنوز کسی پیگیر این کاربر نیست.",
-  "account.followers_counter": "{count, plural, one {{counter} پی‌گیر} other {{counter} پی‌گیر}}",
-  "account.following_counter": "{count, plural, other {{counter} پی می‌گیرد}}",
-  "account.follows.empty": "این کاربر هنوز پیگیر کسی نیست.",
-  "account.follows_you": "پیگیر شماست",
-  "account.hide_reblogs": "نهفتن بازبوق‌های @{name}",
+  "account.follow": "پی‌گیری",
+  "account.followers": "پی‌گیرندگان",
+  "account.followers.empty": "هنوز کسی این کاربر را پی‌گیری نمی‌کند.",
+  "account.followers_counter": "{count, plural, one {{counter} پی‌گیرنده} other {{counter} پی‌گیرنده}}",
+  "account.following_counter": "{count, plural, one {{counter} پی‌گرفته} other {{counter} پی‌گرفته}}",
+  "account.follows.empty": "این کاربر هنوز پی‌گیر کسی نیست.",
+  "account.follows_you": "پی‌گیر شماست",
+  "account.hide_reblogs": "نهفتن تقویت‌های @{name}",
   "account.joined": "پیوسته از {date}",
-  "account.last_status": "آخرین فعالیت",
+  "account.last_status": "آخرین فعّالیت",
   "account.link_verified_on": "مالکیت این پیوند در {date} بررسی شد",
-  "account.locked_info": "این حساب خصوصی است. صاحبش تصمیم می‌گیرد که چه کسی بتواند پیگیرش باشد.",
+  "account.locked_info": "این حساب خصوصی است. صاحبش تصمیم می‌گیرد که چه کسی پی‌گیرش باشد.",
   "account.media": "رسانه",
   "account.mention": "نام‌بردن از @{name}",
   "account.moved_to": "{name} منتقل شده به:",
   "account.mute": "خموشاندن @{name}",
-  "account.mute_notifications": "خموشاندن اعلان‌ها از @{name}",
+  "account.mute_notifications": "خموشاندن آگاهی‌ها از @{name}",
   "account.muted": "خموش",
   "account.never_active": "هرگز",
-  "account.posts": "بوق",
-  "account.posts_with_replies": "نوشته‌ها و پاسخ‌ها",
+  "account.posts": "فرسته",
+  "account.posts_with_replies": "فرسته‌ها و پاسخ‌ها",
   "account.report": "گزارش @{name}",
-  "account.requested": "منتظر پذیرش. برای لغو درخواست پی‌گیری کلیک کنید",
+  "account.requested": "منتظر پذیرش است. برای لغو درخواست پی‌گیری کلیک کنید",
   "account.share": "هم‌رسانی نمایهٔ @{name}",
-  "account.show_reblogs": "نمایش بازبوق‌های @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} بوق} other {{counter} بوق}}",
-  "account.unblock": "رفع انسداد @{name}",
-  "account.unblock_domain": "گشودن دامنه {domain}",
+  "account.show_reblogs": "نمایش تقویت‌های @{name}",
+  "account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
+  "account.unblock": "رفع مسدودیت @{name}",
+  "account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
   "account.unendorse": "معرّفی نکردن در نمایه",
-  "account.unfollow": "پایان پیگیری",
-  "account.unmute": "رفع خموشی @{name}",
-  "account.unmute_notifications": "رفع خموشی اعلان‌ها از @{name}",
-  "account_note.placeholder": "نظری فراهم نشده",
+  "account.unfollow": "ناپی‌گیری",
+  "account.unmute": "ناخموشی @{name}",
+  "account.unmute_notifications": "ناخموشی آگاهی‌ها از @{name}",
+  "account_note.placeholder": "برای افزودن یادداشت کلیک کنید",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "لطفاً پس از {retry_time, time, medium} دوباره بیازمایید.",
   "alert.rate_limited.title": "محدودیت تعداد",
   "alert.unexpected.message": "خطایی غیرمنتظره رخ داد.",
   "alert.unexpected.title": "ای وای!",
   "announcement.announcement": "اعلامیه",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} در هفته",
   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
-  "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.",
+  "bundle_column_error.body": "هنگام بارگزاری این بخش خطایی رخ داد.",
   "bundle_column_error.retry": "تلاش دوباره",
   "bundle_column_error.title": "خطای شبکه",
   "bundle_modal_error.close": "بستن",
-  "bundle_modal_error.message": "هنگام بازکردن این بخش خطایی رخ داد.",
+  "bundle_modal_error.message": "هنگام بارگزاری این بخش خطایی رخ داد.",
   "bundle_modal_error.retry": "تلاش دوباره",
-  "column.blocks": "کاربران مسدود",
+  "column.blocks": "کاربران مسدود شده",
   "column.bookmarks": "نشانک‌ها",
-  "column.community": "نوشته‌های محلی",
-  "column.direct": "پیام‌های خصوصی",
+  "column.community": "خط زمانی محلّی",
+  "column.direct": "پیام‌های مستقیم",
   "column.directory": "مرور نمایه‌ها",
-  "column.domain_blocks": "دامنه‌های بسته",
+  "column.domain_blocks": "دامنه‌های مسدود شده",
   "column.favourites": "پسندیده‌ها",
-  "column.follow_requests": "درخواست‌های پیگیری",
+  "column.follow_requests": "درخواست‌های پی‌گیری",
   "column.home": "خانه",
   "column.lists": "فهرست‌ها",
   "column.mutes": "کاربران خموش",
-  "column.notifications": "آگهداد",
-  "column.pins": "بوق‌های ثابت",
-  "column.public": "نوشته‌های همه‌جا",
+  "column.notifications": "آگاهی‌ها",
+  "column.pins": "فرسته‌های سنجاق‌شده",
+  "column.public": "خط زمانی همگانی",
   "column_back_button.label": "بازگشت",
   "column_header.hide_settings": "نهفتن تنظیمات",
-  "column_header.moveLeft_settings": "انتقال ستون به راست",
-  "column_header.moveRight_settings": "انتقال ستون به چپ",
-  "column_header.pin": "ثابت‌کردن",
+  "column_header.moveLeft_settings": "جابه‌جایی ستون به چپ",
+  "column_header.moveRight_settings": "جابه‌جایی ستون به راست",
+  "column_header.pin": "سنجاق‌کردن",
   "column_header.show_settings": "نمایش تنظیمات",
-  "column_header.unpin": "رهاکردن",
+  "column_header.unpin": "برداشتن سنجاق",
   "column_subheading.settings": "تنظیمات",
   "community.column_settings.local_only": "فقط محلّی",
   "community.column_settings.media_only": "فقط رسانه",
   "community.column_settings.remote_only": "تنها دوردست",
-  "compose_form.direct_message_warning": "این بوق تنها به کاربرانی که از آن‌ها نام برده شده فرستاده خواهد شد.",
+  "compose_form.direct_message_warning": "این فرسته تنها به کاربرانی که از آن‌ها نام برده شده فرستاده خواهد شد.",
   "compose_form.direct_message_warning_learn_more": "بیشتر بدانید",
-  "compose_form.hashtag_warning": "از آن‌جا که این بوق فهرست‌نشده است، در نتایج جست‌وجوی هشتگ‌ها پیدا نخواهد شد. تنها بوق‌های عمومی را می‌توان با جست‌وجوی هشتگ یافت.",
-  "compose_form.lock_disclaimer": "حسابتان {locked} نیست. هر کسی می‌تواند پیگیرتان شده و فرسته‌های ویژهٔ پیگیرانتان را ببیند.",
-  "compose_form.lock_disclaimer.lock": "قفل",
+  "compose_form.hashtag_warning": "از آن‌جا که این فرسته فهرست‌نشده است، در نتایج جست‌وجوی برچسب‌ها پیدا نخواهد شد. تنها فرسته‌های عمومی را می‌توان با جست‌وجوی برچسب یافت.",
+  "compose_form.lock_disclaimer": "حسابتان {locked} نیست. هر کسی می‌تواند پی‌گیرتان شده و فرسته‌های ویژهٔ پی‌گیرانتان را ببیند.",
+  "compose_form.lock_disclaimer.lock": "قفل‌شده",
   "compose_form.placeholder": "تازه چه خبر؟",
   "compose_form.poll.add_option": "افزودن گزینه",
   "compose_form.poll.duration": "مدت نظرسنجی",
@@ -99,33 +104,35 @@
   "compose_form.poll.switch_to_single": "تبدیل به نظرسنجی تک‌گزینه‌ای",
   "compose_form.publish": "بوق",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.hide": "علامت‌گذاری رسانه به عنوان حساس",
-  "compose_form.sensitive.marked": "رسانه به عنوان حساس علامت‌گذاری شده",
-  "compose_form.sensitive.unmarked": "رسانه به عنوان حساس علامت‌گذاری نشده",
-  "compose_form.spoiler.marked": "نوشته پشت هشدار پنهان است",
-  "compose_form.spoiler.unmarked": "نوشته پنهان نیست",
+  "compose_form.sensitive.hide": "{count, plural, one {علامت‌گذاری رسانه به عنوان حساس} other {علامت‌گذاری رسانه‌ها به عنوان حساس}}",
+  "compose_form.sensitive.marked": "{count, plural, one {رسانه به عنوان حساس علامت‌گذاری شد} other {رسانه‌ها به عنوان حساس علامت‌گذاری شدند}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {رسانه به عنوان حساس علامت‌گذاری نشد} other {رسانه‌ها به عنوان حساس علامت‌گذاری نشدند}}",
+  "compose_form.spoiler.marked": "برداشتن هشدار محتوا",
+  "compose_form.spoiler.unmarked": "افزودن هشدار محتوا",
   "compose_form.spoiler_placeholder": "هشدارتان را این‌جا بنویسید",
-  "confirmation_modal.cancel": "بی‌خیال",
-  "confirmations.block.block_and_report": "مسدودسازی و گزارش",
-  "confirmations.block.confirm": "مسدود کن",
+  "confirmation_modal.cancel": "لغو",
+  "confirmations.block.block_and_report": "مسدود کردن و گزارش",
+  "confirmations.block.confirm": "مسدود کردن",
   "confirmations.block.message": "مطمئنید که می‌خواهید {name} را مسدود کنید؟",
-  "confirmations.delete.confirm": "پاک کن",
-  "confirmations.delete.message": "آیا مطمئنید که می‌خواهید این بوق را پاک کنید؟",
-  "confirmations.delete_list.confirm": "پاک کن",
-  "confirmations.delete_list.message": "مطمئنید می‌خواهید این فهرست را برای همیشه پاک کنید؟",
-  "confirmations.domain_block.confirm": "نهفتن تمام دامنه",
-  "confirmations.domain_block.message": "آیا جدی جدی می‌خواهید تمام دامنهٔ {domain} را مسدود کنید؟ در بیشتر موارد مسدودسازی یا خموشاندن چند حساب خاص کافی است و توصیه می‌شود. پس از این کار شما هیچ نوشته‌ای را از این دامنه در فهرست نوشته‌های عمومی یا اعلان‌هایتان نخواهید دید. پیگیرانتان از این دامنه هم حذف خواهند شد.",
+  "confirmations.delete.confirm": "حذف",
+  "confirmations.delete.message": "آیا مطمئنید که می‌خواهید این فرسته را حذف کنید؟",
+  "confirmations.delete_list.confirm": "حذف",
+  "confirmations.delete_list.message": "مطمئنید می‌خواهید این فهرست را برای همیشه حذف کنید؟",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
+  "confirmations.domain_block.confirm": "مسدود کردن تمام دامنه",
+  "confirmations.domain_block.message": "آیا جدی جدی می‌خواهید تمام دامنهٔ {domain} را مسدود کنید؟ در بیشتر موارد مسدود کردن یا خموشاندن چند حساب خاص کافی است و توصیه می‌شود. پس از این کار شما هیچ محتوایی را از این دامنه در خط زمانی عمومی یا آگاهی‌هایتان نخواهید دید. پی‌گیرانتان از این دامنه هم برداشته خواهند شد.",
   "confirmations.logout.confirm": "خروج از حساب",
   "confirmations.logout.message": "مطمئنید می‌خواهید خارج شوید؟",
-  "confirmations.mute.confirm": "خموشاندن",
-  "confirmations.mute.explanation": "این کار فرسته‌های آن‌ها و فرسته‌هایی را که از آن‌ها نام برده پنهان می‌کند، ولی آن‌ها همچنان اجازه دارند فرسته‌های شما را ببینند و شما را پی بگیرند.",
+  "confirmations.mute.confirm": "خموش",
+  "confirmations.mute.explanation": "این کار فرسته‌های آن‌ها و فرسته‌هایی را که از آن‌ها نام برده پنهان می‌کند، ولی آن‌ها همچنان اجازه دارند فرسته‌های شما را ببینند و شما را پی‌گیری کنند.",
   "confirmations.mute.message": "مطمئنید می‌خواهید {name} را بخموشانید؟",
-  "confirmations.redraft.confirm": "پاک‌کردن و بازنویسی",
-  "confirmations.redraft.message": "مطمئنید که می‌خواهید این بوق را پاک کنید و از نو بنویسید؟ با این کار بازبوق‌ها و پسندهای آن از دست می‌رود و پاسخ‌ها به آن بی‌مرجع می‌شود.",
+  "confirmations.redraft.confirm": "حذف و بازنویسی",
+  "confirmations.redraft.message": "مطمئنید که می‌خواهید این فرسته را حذف کنید و از نو بنویسید؟ با این کار تقویت‌ها و پسندهای آن از دست می‌رود و پاسخ‌ها به آن بی‌مرجع می‌شود.",
   "confirmations.reply.confirm": "پاسخ",
   "confirmations.reply.message": "اگر الان پاسخ دهید، چیزی که در حال نوشتنش بودید پاک خواهد شد. می‌خواهید ادامه دهید؟",
-  "confirmations.unfollow.confirm": "پایان پیگیری",
-  "confirmations.unfollow.message": "مطمئنید که می‌خواهید به پیگیری از {name} پایان دهید؟",
+  "confirmations.unfollow.confirm": "ناپی‌گیری",
+  "confirmations.unfollow.message": "مطمئنید که می‌خواهید به پی‌گیری از {name} پایان دهید؟",
   "conversation.delete": "حذف گفتگو",
   "conversation.mark_as_read": "علامت‌گذاری به عنوان خوانده شده",
   "conversation.open": "دیدن گفتگو",
@@ -134,7 +141,7 @@
   "directory.local": "تنها از {domain}",
   "directory.new_arrivals": "تازه‌واردان",
   "directory.recently_active": "کاربران فعال اخیر",
-  "embed.instructions": "برای جاگذاری این بوق در سایت خودتان، کد زیر را کپی کنید.",
+  "embed.instructions": "برای جاگذاری این فرسته در سایت خودتان، کد زیر را کپی کنید.",
   "embed.preview": "این گونه دیده خواهد شد:",
   "emoji_button.activity": "فعالیت",
   "emoji_button.custom": "سفارشی",
@@ -142,34 +149,34 @@
   "emoji_button.food": "غذا و نوشیدنی",
   "emoji_button.label": "افزودن شکلک",
   "emoji_button.nature": "طبیعت",
-  "emoji_button.not_found": "این‌جا شکلکی نیست!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "شکلک منطبقی یافت نشد",
   "emoji_button.objects": "اشیا",
   "emoji_button.people": "مردم",
   "emoji_button.recent": "پراستفاده",
-  "emoji_button.search": "جستجو...",
-  "emoji_button.search_results": "نتایج جستجو",
+  "emoji_button.search": "جست‌وجو...",
+  "emoji_button.search_results": "نتایج جست‌وجو",
   "emoji_button.symbols": "نمادها",
   "emoji_button.travel": "سفر و مکان",
   "empty_column.account_suspended": "حساب معلق شد",
-  "empty_column.account_timeline": "هیچ بوقی این‌جا نیست!",
+  "empty_column.account_timeline": "هیچ فرسته‌ای این‌جا نیست!",
   "empty_column.account_unavailable": "نمایهٔ موجود نیست",
   "empty_column.blocks": "هنوز کسی را مسدود نکرده‌اید.",
-  "empty_column.bookmarked_statuses": "هنوز هیچ بوق نشان‌شده‌ای ندارید. وقتی بوقی را نشان‌کنید، این‌جا دیده خواهد شد.",
-  "empty_column.community": "فهرست نوشته‌های محلی خالی است. چیزی بنویسید تا چرخش بچرخد!",
-  "empty_column.direct": "هنوز هیچ پیام مستقیمی ندارید. هروقت چنین پیامی بگیرید یا بفرستید این‌جا نمایش خواهد یافت.",
-  "empty_column.domain_blocks": "هنوز هیچ دامنه‌ای پنهان نشده است.",
-  "empty_column.favourited_statuses": "شما هنوز هیچ بوقی را نپسندیده‌اید. وقتی بوقی را بپسندید، این‌جا نمایش خواهد یافت.",
-  "empty_column.favourites": "هنوز هیچ کسی این بوق را نپسندیده است. وقتی کسی آن را بپسندد، نامش این‌جا نمایش خواهد یافت.",
-  "empty_column.follow_recommendations": "ظاهرا هیچ پیشنهادی برای شما نمی‌توانیم تولید کنیم. می‌توانید از امکان جستجو برای یافتن افرادی که ممکن است بشناسید و یا کاوش میان هشتگ‌های داغ استفاده کنید.",
-  "empty_column.follow_requests": "شما هنوز هیچ درخواست پیگیری‌ای ندارید. وقتی چنین درخواستی بگیرید، این‌جا نمایش خواهد یافت.",
+  "empty_column.bookmarked_statuses": "هنوز هیچ فرستهٔ نشان‌شده‌ای ندارید. هنگامی که فرسته‌ای را نشان‌کنید، این‌جا نشان داده خواهد شد.",
+  "empty_column.community": "خط زمانی محلّی خالی است. چیزی بنویسید تا چرخش بچرخد!",
+  "empty_column.direct": "هنوز هیچ پیام مستقیمی ندارید. هنگامی که چنین پیامی بگیرید یا بفرستید این‌جا نشان داده خواهد شد.",
+  "empty_column.domain_blocks": "هنوز هیچ دامنه‌ای مسدود نشده است.",
+  "empty_column.favourited_statuses": "شما هنوز هیچ فرسته‌ای را نپسندیده‌اید. هنگامی که فرسته‌ای را بپسندید، این‌جا نشان داده خواهد شد.",
+  "empty_column.favourites": "هنوز هیچ کسی این فرسته را نپسندیده است. هنگامی که کسی آن را بپسندد، این‌جا نشان داده خواهد شد.",
+  "empty_column.follow_recommendations": "ظاهرا هیچ پیشنهادی برای شما نمی‌توانیم تولید کنیم. می‌توانید از امکان جست‌وجو برای یافتن افرادی که ممکن است بشناسید و یا کاوش میان برچسب‌های داغ استفاده کنید.",
+  "empty_column.follow_requests": "شما هنوز هیچ درخواست پی‌گیری‌ای ندارید. هنگامی که چنین درخواستی بگیرید، این‌جا نشان داده خواهد شد.",
   "empty_column.hashtag": "هنوز هیچ چیزی در این برچسب نیست.",
-  "empty_column.home": "فهرست خانگی شما خالی است! {public} را ببینید یا چیزی را جستجو کنید تا کاربران دیگر را ببینید.",
+  "empty_column.home": "خط زمانی خانگی شما خالی است! افراد بیشتری را پی‌گیری کنید تا پُر شود. {suggestions}",
   "empty_column.home.suggestions": "چند پیشنهاد را ببینید",
-  "empty_column.list": "در این فهرست هنوز چیزی نیست. وقتی اعضای این فهرست چیزی بفرستند، این‌جا ظاهر خواهد شد.",
-  "empty_column.lists": "هنوز هیچ فهرستی ندارید. هنگامی که فهرستی بسازید، این‌جا دیده خواهد شد.",
+  "empty_column.list": "در این فهرست هنوز چیزی نیست. هنگامی که اعضای این فهرست چیزی بفرستند، این‌جا ظاهر خواهد شد.",
+  "empty_column.lists": "هنوز هیچ فهرستی ندارید. هنگامی که فهرستی بسازید، این‌جا نشان داده خواهد شد.",
   "empty_column.mutes": "هنوز هیچ کاربری را خموش نکرده‌اید.",
   "empty_column.notifications": "هنوز هیچ اعلانی ندارید. به دیگران واکنش نشان دهید تا گفتگو آغاز شود.",
-  "empty_column.public": "این‌جا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران کارسازهای دیگر را پی بگیرید تا این‌جا پر شود",
+  "empty_column.public": "این‌جا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران کارسازهای دیگر را پی‌گیری کنید تا این‌جا پُر شود",
   "error.unexpected_crash.explanation": "به خاطر اشکالی در کدهای ما یا ناسازگاری با مرورگر شما، این صفحه به درستی نمایش نیافت.",
   "error.unexpected_crash.explanation_addons": "این صفحه نمی‌تواند درست نشان داده شود. احتمالاً این خطا ناشی از یک افزونهٔ مرورگر یا ابزار ترجمهٔ خودکار است.",
   "error.unexpected_crash.next_steps": "لطفاً صفحه را دوباره باز کنید. اگر کمکی نکرد، شاید همچنان بتوانید با ماستودون از راه یک مرورگر دیگر یا با یکی از اپ‌های آن کار کنید.",
@@ -177,14 +184,14 @@
   "errors.unexpected_crash.copy_stacktrace": "رونوشت از جزئیات اشکال",
   "errors.unexpected_crash.report_issue": "گزارش مشکل",
   "follow_recommendations.done": "انجام شد",
-  "follow_recommendations.heading": "افرادی را که می‌خواهید فرسته‌هایشان را ببینید دنبال کنید! این‌ها تعدادی پیشنهاد هستند.",
+  "follow_recommendations.heading": "افرادی را که می‌خواهید فرسته‌هایشان را ببینید پی‌گیری کنید! این‌ها تعدادی پیشنهاد هستند.",
   "follow_recommendations.lead": "فرسته‌های افرادی که دنبال می‌کنید به ترتیب زمانی در خوراک خانه‌تان نشان داده خواهد شد. از اشتباه کردن نترسید. می‌توانید به همین سادگی در هر زمانی از دنبال کردن افراد دست بکشید!",
   "follow_request.authorize": "اجازه دهید",
   "follow_request.reject": "رد کنید",
   "follow_requests.unlocked_explanation": "با این که حسابتان قفل نیست، کارکنان {domain} فکر کردند که ممکن است بخواهید درخواست‌ها از این حساب‌ها را به صورت دستی بازبینی کنید.",
   "generic.saved": "ذخیره شده",
   "getting_started.developers": "توسعه‌دهندگان",
-  "getting_started.directory": "فهرست نمایه",
+  "getting_started.directory": "شاخهٔ نمایه",
   "getting_started.documentation": "مستندات",
   "getting_started.heading": "آغاز کنید",
   "getting_started.invite": "دعوت از دیگران",
@@ -195,56 +202,56 @@
   "hashtag.column_header.tag_mode.any": "یا {additional}",
   "hashtag.column_header.tag_mode.none": "بدون {additional}",
   "hashtag.column_settings.select.no_options_message": "هیچ پیشنهادی پیدا نشد",
-  "hashtag.column_settings.select.placeholder": "هشتگ‌ها را وارد کنید…",
-  "hashtag.column_settings.tag_mode.all": "همۀ اینـها",
+  "hashtag.column_settings.select.placeholder": "برچسب‌ها را وارد کنید…",
+  "hashtag.column_settings.tag_mode.all": "همۀ این‌ها",
   "hashtag.column_settings.tag_mode.any": "هرکدام از این‌ها",
   "hashtag.column_settings.tag_mode.none": "هیچ‌کدام از این‌ها",
   "hashtag.column_settings.tag_toggle": "افزودن برچسب‌هایی بیشتر به این ستون",
   "home.column_settings.basic": "پایه‌ای",
-  "home.column_settings.show_reblogs": "نمایش بازبوق‌ها",
+  "home.column_settings.show_reblogs": "نمایش تقویت‌ها",
   "home.column_settings.show_replies": "نمایش پاسخ‌ها",
   "home.hide_announcements": "نهفتن اعلامیه‌ها",
   "home.show_announcements": "نمایش اعلامیه‌ها",
   "intervals.full.days": "{number, plural, one {# روز} other {# روز}}",
   "intervals.full.hours": "{number, plural, one {# ساعت} other {# ساعت}}",
   "intervals.full.minutes": "{number, plural, one {# دقیقه} other {# دقیقه}}",
-  "keyboard_shortcuts.back": "برای بازگشت",
-  "keyboard_shortcuts.blocked": "برای گشودن فهرست کاربران خموش",
-  "keyboard_shortcuts.boost": "برای بازبوقیدن",
-  "keyboard_shortcuts.column": "برای تمرکز روی یک بوق در یکی از ستون‌ها",
-  "keyboard_shortcuts.compose": "برای تمرکز روی محیط نوشتن",
+  "keyboard_shortcuts.back": "بازگشت",
+  "keyboard_shortcuts.blocked": "گشودن فهرست کاربران مسدود شده",
+  "keyboard_shortcuts.boost": "تقویت فرسته",
+  "keyboard_shortcuts.column": "برای تمرکز روی یک فرسته در یکی از ستون‌ها",
+  "keyboard_shortcuts.compose": "تمرکز روی محیط نوشتن",
   "keyboard_shortcuts.description": "توضیح",
-  "keyboard_shortcuts.direct": "برای گشودن ستون پیغام‌های مستقیم",
-  "keyboard_shortcuts.down": "برای پایین رفتن در فهرست",
-  "keyboard_shortcuts.enter": "برای گشودن وضعیت",
-  "keyboard_shortcuts.favourite": "برای پسندیدن",
-  "keyboard_shortcuts.favourites": "برای گشودن فهرست پسندیده‌ها",
-  "keyboard_shortcuts.federated": "برای گشودن فهرست نوشته‌های همه‌جا",
+  "keyboard_shortcuts.direct": "گشودن ستون پیام‌های مستقیم",
+  "keyboard_shortcuts.down": "پایین رفتن در فهرست",
+  "keyboard_shortcuts.enter": "گشودن فرسته",
+  "keyboard_shortcuts.favourite": "پسندیدن فرسته",
+  "keyboard_shortcuts.favourites": "گشودن فهرست پسندیده‌ها",
+  "keyboard_shortcuts.federated": "گشودن خط زمانی همگانی",
   "keyboard_shortcuts.heading": "میان‌برهای صفحه‌کلید",
-  "keyboard_shortcuts.home": "برای گشودن ستون اصلی پیگیری‌ها",
+  "keyboard_shortcuts.home": "گشودن خط زمانی خانگی",
   "keyboard_shortcuts.hotkey": "میان‌بر",
-  "keyboard_shortcuts.legend": "برای نمایش این نشانه",
-  "keyboard_shortcuts.local": "برای گشودن فهرست نوشته‌های محلی",
-  "keyboard_shortcuts.mention": "برای نام‌بردن از نویسنده",
-  "keyboard_shortcuts.muted": "برای گشودن فهرست کاربران خموش",
-  "keyboard_shortcuts.my_profile": "برای گشودن نمایه‌تان",
-  "keyboard_shortcuts.notifications": "برای گشودن ستون اعلان‌ها",
-  "keyboard_shortcuts.open_media": "برای باز کردن رسانه",
-  "keyboard_shortcuts.pinned": "برای گشودن فهرست بوق‌های ثابت",
-  "keyboard_shortcuts.profile": "برای گشودن نمایهٔ نویسنده",
-  "keyboard_shortcuts.reply": "برای پاسخ",
-  "keyboard_shortcuts.requests": "برای گشودن فهرست درخواست‌های پیگیری",
-  "keyboard_shortcuts.search": "برای تمرکز روی جستجو",
+  "keyboard_shortcuts.legend": "نمایش این نشانه",
+  "keyboard_shortcuts.local": "گشودن خط زمانی محلّی",
+  "keyboard_shortcuts.mention": "نام‌بردن نویسنده",
+  "keyboard_shortcuts.muted": "گشودن فهرست کاربران خموش",
+  "keyboard_shortcuts.my_profile": "گشودن نمایه‌تان",
+  "keyboard_shortcuts.notifications": "گشودن ستون آگاهی‌ها",
+  "keyboard_shortcuts.open_media": "گشودن رسانه",
+  "keyboard_shortcuts.pinned": "گشودن فهرست فرسته‌های سنجاق‌شده",
+  "keyboard_shortcuts.profile": "گشودن نمایهٔ نویسنده",
+  "keyboard_shortcuts.reply": "پاسخ به فرسته",
+  "keyboard_shortcuts.requests": "گشودن فهرست درخواست‌های پی‌گیری",
+  "keyboard_shortcuts.search": "تمرکز روی جست‌وجو",
   "keyboard_shortcuts.spoilers": "نمایش/نهفتن زمینهٔ هشدار محتوا",
-  "keyboard_shortcuts.start": "برای گشودن ستون «آغاز کنید»",
-  "keyboard_shortcuts.toggle_hidden": "برای نمایش/نهفتن نوشتهٔ پشت هشدار محتوا",
-  "keyboard_shortcuts.toggle_sensitivity": "برای نمایش/نهفتن رسانه",
-  "keyboard_shortcuts.toot": "برای آغاز یک بوق تازه",
-  "keyboard_shortcuts.unfocus": "برای برداشتن تمرکز از نوشتن/جستجو",
-  "keyboard_shortcuts.up": "برای بالا رفتن در فهرست",
+  "keyboard_shortcuts.start": "گشودن ستون «آغاز کنید»",
+  "keyboard_shortcuts.toggle_hidden": "نمایش/نهفتن نوشتهٔ پشت هشدار محتوا",
+  "keyboard_shortcuts.toggle_sensitivity": "نمایش/نهفتن رسانه",
+  "keyboard_shortcuts.toot": "شروع یک فرستهٔ جدید",
+  "keyboard_shortcuts.unfocus": "برداشتن تمرکز از نوشتن/جست‌وجو",
+  "keyboard_shortcuts.up": "بالا رفتن در فهرست",
   "lightbox.close": "بستن",
-  "lightbox.compress": "فشرده‌سازی جعبه نمایش تصویر",
-  "lightbox.expand": "گسترش جعبه نمایش تصویر",
+  "lightbox.compress": "فشرده‌سازی جعبهٔ نمایش تصویر",
+  "lightbox.expand": "گسترش جعبهٔ نمایش تصویر",
   "lightbox.next": "بعدی",
   "lightbox.previous": "قبلی",
   "lists.account.add": "افزودن به فهرست",
@@ -253,79 +260,79 @@
   "lists.edit": "ویرایش فهرست",
   "lists.edit.submit": "تغییر عنوان",
   "lists.new.create": "افزودن فهرست",
-  "lists.new.title_placeholder": "عنوان فهرست تازه",
-  "lists.replies_policy.followed": "هر کاربر پیگرفته",
+  "lists.new.title_placeholder": "عنوان فهرست جدید",
+  "lists.replies_policy.followed": "هر کاربر پی‌گرفته",
   "lists.replies_policy.list": "اعضای فهرست",
   "lists.replies_policy.none": "هیچ کدام",
   "lists.replies_policy.title": "نمایش پاسخ‌ها به:",
-  "lists.search": "بین کسانی که پی می‌گیرید بگردید",
+  "lists.search": "جست‌وجو بین کسانی که پی‌گرفته‌اید",
   "lists.subheading": "فهرست‌های شما",
-  "load_pending": "{count, plural, one {# مورد تازه} other {# مورد تازه}}",
-  "loading_indicator.label": "بارگیری...",
-  "media_gallery.toggle_visible": "تغییر وضعیت نمایانی",
+  "load_pending": "{count, plural, one {# مورد جدید} other {# مورد جدید}}",
+  "loading_indicator.label": "بارگزاری...",
+  "media_gallery.toggle_visible": "{number, plural, one {نهفتن تصویر} other {نهفتن تصاویر}}",
   "missing_indicator.label": "پیدا نشد",
   "missing_indicator.sublabel": "این منبع پیدا نشد",
   "mute_modal.duration": "مدت زمان",
   "mute_modal.hide_notifications": "اعلان‌های این کاربر پنهان شود؟",
   "mute_modal.indefinite": "نامعلوم",
-  "navigation_bar.apps": "اپ‌های موبایل",
-  "navigation_bar.blocks": "کاربران مسدودشده",
+  "navigation_bar.apps": "برنامه‌های تلفن همراه",
+  "navigation_bar.blocks": "کاربران مسدود شده",
   "navigation_bar.bookmarks": "نشانک‌ها",
-  "navigation_bar.community_timeline": "نوشته‌های محلی",
-  "navigation_bar.compose": "نوشتن بوق تازه",
+  "navigation_bar.community_timeline": "خط زمانی محلّی",
+  "navigation_bar.compose": "نوشتن فرستهٔ تازه",
   "navigation_bar.direct": "پیام‌های مستقیم",
   "navigation_bar.discover": "گشت و گذار",
-  "navigation_bar.domain_blocks": "دامنه‌های بسته",
+  "navigation_bar.domain_blocks": "دامنه‌های مسدود شده",
   "navigation_bar.edit_profile": "ویرایش نمایه",
   "navigation_bar.favourites": "پسندیده‌ها",
-  "navigation_bar.filters": "واژگان خموش",
-  "navigation_bar.follow_requests": "درخواست‌های پیگیری",
-  "navigation_bar.follows_and_followers": "پیگیری‌ها و پیگیران",
+  "navigation_bar.filters": "واژه‌های خموش",
+  "navigation_bar.follow_requests": "درخواست‌های پی‌گیری",
+  "navigation_bar.follows_and_followers": "پی‌گرفتگان و پی‌گیرندگان",
   "navigation_bar.info": "دربارهٔ این کارساز",
   "navigation_bar.keyboard_shortcuts": "میان‌برها",
   "navigation_bar.lists": "فهرست‌ها",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "کاربران خموشانده",
   "navigation_bar.personal": "شخصی",
-  "navigation_bar.pins": "بوق‌های ثابت",
+  "navigation_bar.pins": "فرسته‌های سنجاق‌شده",
   "navigation_bar.preferences": "ترجیحات",
-  "navigation_bar.public_timeline": "نوشته‌های همه‌جا",
+  "navigation_bar.public_timeline": "خط زمانی همگانی",
   "navigation_bar.security": "امنیت",
-  "notification.favourite": "‫{name}‬ وضعیتتان را برگزید",
-  "notification.follow": "‫{name}‬ پیگیرتان شد",
-  "notification.follow_request": "{name} می‌خواهد پیگیر شما باشد",
+  "notification.favourite": "‫{name}‬ فرسته‌تان را پسندید",
+  "notification.follow": "‫{name}‬ پی‌گیرتان شد",
+  "notification.follow_request": "{name} می‌خواهد پی‌گیر شما باشد",
   "notification.mention": "‫{name}‬ از شما نام برد",
   "notification.own_poll": "نظرسنجی شما به پایان رسید",
   "notification.poll": "نظرسنجی‌ای که در آن رأی دادید به پایان رسیده است",
-  "notification.reblog": "‫{name}‬ وضعیتتان را تقویت کرد",
+  "notification.reblog": "‫{name}‬ فرسته‌تان را تقویت کرد",
   "notification.status": "{name} چیزی فرستاد",
-  "notifications.clear": "پاک‌کردن اعلان‌ها",
-  "notifications.clear_confirmation": "مطمئنید می‌خواهید همهٔ اعلان‌هایتان را برای همیشه پاک کنید؟",
-  "notifications.column_settings.alert": "اعلان‌های میزکار",
+  "notifications.clear": "پاک‌کردن آگاهی‌ها",
+  "notifications.clear_confirmation": "مطمئنید می‌خواهید همهٔ آگاهی‌هایتان را برای همیشه پاک کنید؟",
+  "notifications.column_settings.alert": "آگاهی‌های میزکار",
   "notifications.column_settings.favourite": "پسندیده‌ها:",
   "notifications.column_settings.filter_bar.advanced": "نمایش همۀ دسته‌ها",
   "notifications.column_settings.filter_bar.category": "نوار پالایش سریع",
   "notifications.column_settings.filter_bar.show": "نمایش",
-  "notifications.column_settings.follow": "پیگیران تازه:",
+  "notifications.column_settings.follow": "پی‌گیرندگان جدید:",
   "notifications.column_settings.follow_request": "درخواست‌های جدید پی‌گیری:",
   "notifications.column_settings.mention": "نام‌بردن‌ها:",
   "notifications.column_settings.poll": "نتایج نظرسنجی:",
   "notifications.column_settings.push": "اعلان‌ها از سمت سرور",
-  "notifications.column_settings.reblog": "بازبوق‌ها:",
+  "notifications.column_settings.reblog": "تقویت‌ها:",
   "notifications.column_settings.show": "نمایش در ستون",
   "notifications.column_settings.sound": "پخش صدا",
-  "notifications.column_settings.status": "بوق‌های جدید:",
+  "notifications.column_settings.status": "فرسته‌های جدید:",
   "notifications.column_settings.unread_markers.category": "نشانه‌گذارهای آگاهی‌های خوانده‌نشده",
   "notifications.filter.all": "همه",
-  "notifications.filter.boosts": "بازبوق‌ها",
+  "notifications.filter.boosts": "تقویت‌ها",
   "notifications.filter.favourites": "پسندها",
-  "notifications.filter.follows": "پیگیری‌ها",
+  "notifications.filter.follows": "پی‌گرفتگان",
   "notifications.filter.mentions": "نام‌بردن‌ها",
   "notifications.filter.polls": "نتایج نظرسنجی",
   "notifications.filter.statuses": "به‌روز رسانی‌ها از کسانی که پی‌گیرشانید",
   "notifications.grant_permission": "اعطای مجوز.",
-  "notifications.group": "{count} اعلان",
-  "notifications.mark_as_read": "نشانه‌گذاری همۀ آگهدادها با فرنام خوانده شده",
+  "notifications.group": "{count} آگاهی",
+  "notifications.mark_as_read": "نشانه‌گذاری تمام آگاهی‌ها به خوانده‌شده",
   "notifications.permission_denied": "آگاهی‌های میزکار به دلیل رد کردن درخواست اجازهٔ پیشین مرورگر، در دسترس نیستند",
   "notifications.permission_denied_alert": "از آن‌جا که پیش از این اجازهٔ مرورگر رد شده است، آگاهی‌های میزکار نمی‌توانند به کار بیفتند",
   "notifications.permission_required": "اعلان‌های میزکار در دسترس نیستند زیرا نیازمند مجوزی هستند که اعطا نشده است.",
@@ -334,28 +341,29 @@
   "notifications_permission_banner.title": "هرگز چیزی را از دست ندهید",
   "picture_in_picture.restore": "برگرداندن",
   "poll.closed": "پایان‌یافته",
-  "poll.refresh": "به‌روزرسانی",
+  "poll.refresh": "نوسازی",
   "poll.total_people": "{count, plural, one {# نفر} other {# نفر}}",
   "poll.total_votes": "{count, plural, one {# رأی} other {# رأی}}",
   "poll.vote": "رأی",
-  "poll.voted": "شما به این گزینه رأی دادید",
+  "poll.voted": "شما به این جواب رأی دادید",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "افزودن نظرسنجی",
-  "poll_button.remove_poll": "حذف نظرسنجی",
-  "privacy.change": "تنظیم محرمانگی نوشته",
-  "privacy.direct.long": "ارسال فقط به کاربران اشاره‌شده",
-  "privacy.direct.short": "خصوصی",
-  "privacy.private.long": "ارسال فقط به پی‌گیران",
-  "privacy.private.short": "خصوصی",
-  "privacy.public.long": "ارسال به خط‌زمانی عمومی",
-  "privacy.public.short": "همگانی",
-  "privacy.unlisted.long": "ارسال نکردن به خط‌زمانی عمومی",
+  "poll_button.remove_poll": "برداشتن نظرسنجی",
+  "privacy.change": "تغییر محرمانگی فرسته",
+  "privacy.direct.long": "فقط برای کاربران نام‌برده نمایان است",
+  "privacy.direct.short": "مستقیم",
+  "privacy.private.long": "نمایان فقط برای پی‌گیرندگان",
+  "privacy.private.short": "فقط پی‌گیرندگان",
+  "privacy.public.long": "نمایان برای همه، در خط‌های زمانی همگانی نمایش داده خواهد شد",
+  "privacy.public.short": "عمومی",
+  "privacy.unlisted.long": "نمایان برای همه، ولی در خط‌های زمانی همگانی نمایش داده نخواهد شد",
   "privacy.unlisted.short": "فهرست‌نشده",
-  "refresh": "به‌روزرسانی",
-  "regeneration_indicator.label": "در حال باز شدن…",
+  "refresh": "نوسازی",
+  "regeneration_indicator.label": "در حال بار شدن…",
   "regeneration_indicator.sublabel": "این فهرست دارد آماده می‌شود!",
   "relative_time.days": "{number} روز",
   "relative_time.hours": "{number} ساعت",
-  "relative_time.just_now": "الان",
+  "relative_time.just_now": "حالا",
   "relative_time.minutes": "{number} دقیقه",
   "relative_time.seconds": "{number} ثانیه",
   "relative_time.today": "امروز",
@@ -363,79 +371,79 @@
   "report.forward": "فرستادن به {target}",
   "report.forward_hint": "این حساب در کارساز دیگری ثبت شده. آیا می‌خواهید رونوشتی ناشناس از این گزارش به آن‌جا هم فرستاده شود؟",
   "report.hint": "این گزارش به مدیران کارسازتان فرستاده خواهد شد. می‌توانید دلیل گزارش این حساب را در ادامه بنویسید:",
-  "report.placeholder": "توضیح اضافه",
-  "report.submit": "بفرست",
+  "report.placeholder": "توضیحات اضافه",
+  "report.submit": "فرستادن",
   "report.target": "در حال گزارش {target}",
-  "search.placeholder": "جستجو",
-  "search_popout.search_format": "راهنمای جستجوی پیشرفته",
-  "search_popout.tips.full_text": "جست‌وجوی متنی ساده وضعیت‌هایی که که نوشته، برگزیده، تقویت‌کرده یا در آن‌ها اشاره‌شده‌اید را به اضافهٔ نام‌های کاربری، نام‌های نمایشی و برچسب‌های مطابق برمی‌گرداند.",
-  "search_popout.tips.hashtag": "هشتگ",
-  "search_popout.tips.status": "بوق",
-  "search_popout.tips.text": "جستجوی متنی ساده برای نام‌ها، نام‌های کاربری، و برچسب‌ها",
+  "search.placeholder": "جست‌وجو",
+  "search_popout.search_format": "راهنمای جست‌وجوی پیشرفته",
+  "search_popout.tips.full_text": "جست‌وجوی متنی ساده فرسته‌هایی که نوشته، پسندیده، تقویت‌کرده یا در آن‌ها نام‌برده شده‌اید را به علاوهٔ نام‌های کاربری، نام‌های نمایشی و برچسب‌ها برمی‌گرداند.",
+  "search_popout.tips.hashtag": "برچسب",
+  "search_popout.tips.status": "فرسته",
+  "search_popout.tips.text": "جست‌وجوی متنی ساده برای نام‌ها، نام‌های کاربری، و برچسب‌ها",
   "search_popout.tips.user": "کاربر",
   "search_results.accounts": "افراد",
-  "search_results.hashtags": "هشتگ‌ها",
-  "search_results.statuses": "بوق‌ها",
-  "search_results.statuses_fts_disabled": "جستجوی محتوای بوق‌ها در این کارساز ماستودون فعال نشده است.",
+  "search_results.hashtags": "برچسب‌ها",
+  "search_results.statuses": "فرسته‌ها",
+  "search_results.statuses_fts_disabled": "جست‌وجوی محتوای فرسته‌ها در این کارساز ماستودون فعال نشده است.",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
   "status.admin_account": "گشودن واسط مدیریت برای @{name}",
-  "status.admin_status": "گشودن این بوق در واسط مدیریت",
-  "status.block": "مسدودسازی @{name}",
+  "status.admin_status": "گشودن این فرسته در واسط مدیریت",
+  "status.block": "مسدود کردن @{name}",
   "status.bookmark": "نشانک",
-  "status.cancel_reblog_private": "حذف بازبوق",
-  "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
-  "status.copy": "رونوشت‌برداری از نشانی بوق",
-  "status.delete": "پاک‌کردن",
+  "status.cancel_reblog_private": "لغو تقویت",
+  "status.cannot_reblog": "این فرسته قابل تقویت نیست",
+  "status.copy": "رونویسی از نشانی فرسته",
+  "status.delete": "حذف",
   "status.detailed_status": "نمایش کامل گفتگو",
-  "status.direct": "پیغام مستقیم به @{name}",
+  "status.direct": "پیام مستقیم به @{name}",
   "status.embed": "جاگذاری",
   "status.favourite": "پسندیدن",
-  "status.filtered": "پالوده",
-  "status.load_more": "بیشتر نشان بده",
+  "status.filtered": "پالایش‌شده",
+  "status.load_more": "بارگزاری بیشتر",
   "status.media_hidden": "رسانهٔ نهفته",
   "status.mention": "نام‌بردن از @{name}",
   "status.more": "بیشتر",
   "status.mute": "خموشاندن @{name}",
-  "status.mute_conversation": "خموشاندن گفتگو",
-  "status.open": "گشودن این بوق",
-  "status.pin": "ثابت کردن در نمایه",
-  "status.pinned": "بوق ثابت",
+  "status.mute_conversation": "خموشاندن گفت‌وگو",
+  "status.open": "گسترش این فرسته",
+  "status.pin": "سنجاق‌کردن در نمایه",
+  "status.pinned": "فرستهٔ سنجاق‌شده",
   "status.read_more": "بیشتر بخوانید",
-  "status.reblog": "بازبوقیدن",
+  "status.reblog": "تقویت",
   "status.reblog_private": "تقویت برای مخاطبان نخستین",
-  "status.reblogged_by": "‫{name}‬ بازبوقید",
-  "status.reblogs.empty": "هنوز هیچ کسی این بوق را بازنبوقیده است. وقتی کسی چنین کاری کند، این‌جا نمایش خواهد یافت.",
-  "status.redraft": "پاک‌کردن و بازنویسی",
+  "status.reblogged_by": "‫{name}‬ تقویت کرد",
+  "status.reblogs.empty": "هنوز هیچ کسی این فرسته را تقویت نکرده است. وقتی کسی چنین کاری کند، این‌جا نمایش داده خواهد شد.",
+  "status.redraft": "حذف و بازنویسی",
   "status.remove_bookmark": "برداشتن نشانک",
   "status.reply": "پاسخ",
   "status.replyAll": "پاسخ به رشته",
   "status.report": "گزارش @{name}",
   "status.sensitive_warning": "محتوای حساس",
-  "status.share": "همرسانی",
+  "status.share": "هم‌رسانی",
   "status.show_less": "نمایش کمتر",
   "status.show_less_all": "نمایش کمتر همه",
   "status.show_more": "نمایش بیشتر",
   "status.show_more_all": "نمایش بیشتر همه",
   "status.show_thread": "نمایش رشته",
   "status.uncached_media_warning": "ناموجود",
-  "status.unmute_conversation": "رفع خموشی گفتگو",
-  "status.unpin": "برداشتن نوشتهٔ ثابت نمایه",
+  "status.unmute_conversation": "رفع خموشی گفت‌وگو",
+  "status.unpin": "برداشتن سنجاق از نمایه",
   "suggestions.dismiss": "نادیده گرفتن پیشنهاد",
   "suggestions.header": "شاید این هم برایتان جالب باشد…",
   "tabs_bar.federated_timeline": "همگانی",
   "tabs_bar.home": "خانه",
-  "tabs_bar.local_timeline": "بومی",
-  "tabs_bar.notifications": "اعلان‌ها",
-  "tabs_bar.search": "جستجو",
+  "tabs_bar.local_timeline": "محلّی",
+  "tabs_bar.notifications": "آگاهی‌ها",
+  "tabs_bar.search": "جست‌وجو",
   "time_remaining.days": "{number, plural, one {# روز} other {# روز}} باقی مانده",
   "time_remaining.hours": "{number, plural, one {# ساعت} other {# ساعت}} باقی مانده",
   "time_remaining.minutes": "{number, plural, one {# دقیقه} other {# دقیقه}} باقی مانده",
   "time_remaining.moments": "زمان باقی‌مانده",
   "time_remaining.seconds": "{number, plural, one {# ثانیه} other {# ثانیه}} باقی مانده",
   "timeline_hint.remote_resource_not_displayed": "{resource} از دیگر کارسازها نمایش داده نمی‌شوند.",
-  "timeline_hint.resources.followers": "پی‌گیر",
-  "timeline_hint.resources.follows": "پی می‌گیرد",
-  "timeline_hint.resources.statuses": "بوق‌های قدیمی‌تر",
+  "timeline_hint.resources.followers": "پیگیرندگان",
+  "timeline_hint.resources.follows": "پی‌گرفتگان",
+  "timeline_hint.resources.statuses": "فرسته‌های قدیمی‌تر",
   "trends.counter_by_accounts": "{count, plural, one {{counter} نفر} other {{counter} نفر}} صحبت می‌کنند",
   "trends.trending_now": "پرطرفدار",
   "ui.beforeunload": "اگر از ماستودون خارج شوید پیش‌نویس شما از دست خواهد رفت.",
@@ -443,8 +451,8 @@
   "units.short.million": "{count}میلیون",
   "units.short.thousand": "{count}هزار",
   "upload_area.title": "برای بارگذاری به این‌جا بکشید",
-  "upload_button.label": "افزودن رسانه",
-  "upload_error.limit": "از حد مجاز باگذاری پرونده فراتر رفتید.",
+  "upload_button.label": "افزودن تصاویر، ویدیو یا یک پروندهٔ صوتی",
+  "upload_error.limit": "از حد مجاز بارگذاری پرونده فراتر رفتید.",
   "upload_error.poll": "بارگذاری پرونده در نظرسنجی‌ها مجاز نیست.",
   "upload_form.audio_description": "برای ناشنوایان توصیفش کنید",
   "upload_form.description": "برای کم‌بینایان توصیفش کنید",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "برای کم‌بینایان یا ناشنوایان توصیفش کنید",
   "upload_modal.analyzing_picture": "در حال پردازش تصویر…",
   "upload_modal.apply": "اعمال",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "گزینش تصویر",
   "upload_modal.description_placeholder": "الا یا ایّها الساقی، ادر کأساً و ناولها",
   "upload_modal.detect_text": "تشخیص متن درون عکس",
@@ -465,11 +474,11 @@
   "video.close": "بستن ویدیو",
   "video.download": "بارگیری پرونده",
   "video.exit_fullscreen": "خروج از حالت تمام‌صفحه",
-  "video.expand": "بزرگ‌کردن ویدیو",
+  "video.expand": "گسترش ویدیو",
   "video.fullscreen": "تمام‌صفحه",
   "video.hide": "نهفتن ویدیو",
-  "video.mute": "قطع صدا",
+  "video.mute": "خموشی صدا",
   "video.pause": "مکث",
   "video.play": "پخش",
-  "video.unmute": "پخش صدا"
+  "video.unmute": "لغو خموشی صدا"
 }
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index ae2a2eef6..6829aced1 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -8,33 +8,33 @@
   "account.blocked": "Estetty",
   "account.browse_more_on_origin_server": "Selaile lisää alkuperäisellä palvelimella",
   "account.cancel_follow_request": "Peruuta seurauspyyntö",
-  "account.direct": "Viesti käyttäjälle @{name}",
+  "account.direct": "Pikaviesti käyttäjälle @{name}",
   "account.disable_notifications": "Lopeta ilmoittamasta minulle, kun @{name} viestii",
   "account.domain_blocked": "Verkko-osoite piilotettu",
   "account.edit_profile": "Muokkaa profiilia",
   "account.enable_notifications": "Ilmoita minulle, kun @{name} viestii",
   "account.endorse": "Suosittele profiilissasi",
   "account.follow": "Seuraa",
-  "account.followers": "Seuraajaa",
-  "account.followers.empty": "Tällä käyttäjällä ei ole vielä seuraajia.",
+  "account.followers": "Seuraajat",
+  "account.followers.empty": "Kukaan ei seuraa tätä käyttäjää vielä.",
   "account.followers_counter": "{count, plural, one {{counter} seuraaja} other {{counter} seuraajat}}",
   "account.following_counter": "{count, plural, one {{counter} seuraa} other {{counter} seuraa}}",
   "account.follows.empty": "Tämä käyttäjä ei vielä seuraa ketään.",
   "account.follows_you": "Seuraa sinua",
   "account.hide_reblogs": "Piilota buustaukset käyttäjältä @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Liittynyt {date}",
   "account.last_status": "Aktiivinen viimeksi",
   "account.link_verified_on": "Tämän linkin omistaja tarkistettiin {date}",
-  "account.locked_info": "Tämän tili on yksityinen. Käyttäjä vahvistaa itse kuka voi seurata häntä.",
+  "account.locked_info": "Tämän tilin yksityisyyden tila on asetettu lukituksi. Omistaja arvioi manuaalisesti, kuka voi seurata niitä.",
   "account.media": "Media",
   "account.mention": "Mainitse @{name}",
-  "account.moved_to": "{name} on muuttanut instanssiin:",
+  "account.moved_to": "{name} on muuttanut:",
   "account.mute": "Mykistä @{name}",
   "account.mute_notifications": "Mykistä ilmoitukset käyttäjältä @{name}",
   "account.muted": "Mykistetty",
   "account.never_active": "Ei koskaan",
-  "account.posts": "Tuuttaukset",
-  "account.posts_with_replies": "Tuuttaukset ja vastaukset",
+  "account.posts": "Viestit",
+  "account.posts_with_replies": "Viestit ja vastaukset",
   "account.report": "Raportoi @{name}",
   "account.requested": "Odottaa hyväksyntää. Peruuta seuraamispyyntö klikkaamalla",
   "account.share": "Jaa käyttäjän @{name} profiili",
@@ -47,11 +47,16 @@
   "account.unmute": "Poista käyttäjän @{name} mykistys",
   "account.unmute_notifications": "Poista mykistys käyttäjän @{name} ilmoituksilta",
   "account_note.placeholder": "Lisää muistiinpano napsauttamalla",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Yritä uudestaan {retry_time, time, medium} jälkeen.",
   "alert.rate_limited.title": "Määrää rajoitettu",
   "alert.unexpected.message": "Tapahtui odottamaton virhe.",
   "alert.unexpected.title": "Hups!",
   "announcement.announcement": "Ilmoitus",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} viikossa",
   "boost_modal.combo": "Ensi kerralla voit ohittaa tämän painamalla {combo}",
   "bundle_column_error.body": "Jokin meni vikaan komponenttia ladattaessa.",
@@ -59,11 +64,11 @@
   "bundle_column_error.title": "Verkkovirhe",
   "bundle_modal_error.close": "Sulje",
   "bundle_modal_error.message": "Jokin meni vikaan komponenttia ladattaessa.",
-  "bundle_modal_error.retry": "Yritä uudestaan",
+  "bundle_modal_error.retry": "Yritä uudelleen",
   "column.blocks": "Estetyt käyttäjät",
   "column.bookmarks": "Kirjanmerkit",
   "column.community": "Paikallinen aikajana",
-  "column.direct": "Viestit",
+  "column.direct": "Pikaviestit",
   "column.directory": "Selaa profiileja",
   "column.domain_blocks": "Piilotetut verkkotunnukset",
   "column.favourites": "Suosikit",
@@ -85,26 +90,26 @@
   "community.column_settings.local_only": "Vain paikalliset",
   "community.column_settings.media_only": "Vain media",
   "community.column_settings.remote_only": "Vain etäkäyttö",
-  "compose_form.direct_message_warning": "Tämä tuuttaus näkyy vain mainituille käyttäjille.",
+  "compose_form.direct_message_warning": "Tämä viesti näkyy vain mainituille käyttäjille.",
   "compose_form.direct_message_warning_learn_more": "Lisätietoja",
   "compose_form.hashtag_warning": "Tämä tuuttaus ei näy hashtag-hauissa, koska se on listaamaton. Hashtagien avulla voi hakea vain julkisia tuuttauksia.",
   "compose_form.lock_disclaimer": "Tilisi ei ole {locked}. Kuka tahansa voi seurata tiliäsi ja nähdä vain seuraajille rajaamasi julkaisut.",
   "compose_form.lock_disclaimer.lock": "lukittu",
-  "compose_form.placeholder": "Mitä mietit?",
+  "compose_form.placeholder": "Mitä sinulla on mielessäsi?",
   "compose_form.poll.add_option": "Lisää valinta",
   "compose_form.poll.duration": "Äänestyksen kesto",
-  "compose_form.poll.option_placeholder": "Valinta numero",
+  "compose_form.poll.option_placeholder": "Valinta {number}",
   "compose_form.poll.remove_option": "Poista tämä valinta",
   "compose_form.poll.switch_to_multiple": "Muuta kysely monivalinnaksi",
   "compose_form.poll.switch_to_single": "Muuta kysely sallimaan vain yksi valinta",
-  "compose_form.publish": "Tuuttaa",
-  "compose_form.publish_loud": "Julkista!",
-  "compose_form.sensitive.hide": "Valitse tämä arkaluontoisena",
-  "compose_form.sensitive.marked": "Media on merkitty arkaluontoiseksi",
-  "compose_form.sensitive.unmarked": "Mediaa ei ole merkitty arkaluontoiseksi",
-  "compose_form.spoiler.marked": "Teksti on piilotettu varoituksen taakse",
-  "compose_form.spoiler.unmarked": "Teksti ei ole piilotettu",
-  "compose_form.spoiler_placeholder": "Sisältövaroitus",
+  "compose_form.publish": "Lähetä viesti",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "{count, plural, one {Merkitse media arkaluontoiseksi} other {Merkitse media arkaluontoiseksi}}",
+  "compose_form.sensitive.marked": "{count, plural, one {Media on merkitty arkaluontoiseksi} other {Media on merkitty arkaluontoiseksi}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {Mediaa ei ole merkitty arkaluontoiseksi} other {Mediaa ei ole merkitty arkaluontoiseksi}}",
+  "compose_form.spoiler.marked": "Poista sisältövaroitus",
+  "compose_form.spoiler.unmarked": "Lisää sisältövaroitus",
+  "compose_form.spoiler_placeholder": "Kirjoita varoituksesi tähän",
   "confirmation_modal.cancel": "Peruuta",
   "confirmations.block.block_and_report": "Estä ja raportoi",
   "confirmations.block.confirm": "Estä",
@@ -113,8 +118,10 @@
   "confirmations.delete.message": "Haluatko varmasti poistaa tämän tilapäivityksen?",
   "confirmations.delete_list.confirm": "Poista",
   "confirmations.delete_list.message": "Haluatko varmasti poistaa tämän listan kokonaan?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Piilota koko verkko-osoite",
-  "confirmations.domain_block.message": "Haluatko aivan varmasti estää koko verkko-osoitteen {domain}? Useimmiten jokunen kohdistettu esto ja mykistys riittää, ja se on suositeltavampi tapa toimia.",
+  "confirmations.domain_block.message": "Oletko todella varma, että haluat estää koko {domain}? Useimmissa tapauksissa muutama kohdennettu lohko tai mykistys on riittävä ja parempi. Et näe kyseisen verkkotunnuksen sisältöä missään julkisessa aikajanassa tai ilmoituksissa. Seuraajasi tältä verkkotunnukselta poistetaan.",
   "confirmations.logout.confirm": "Kirjaudu ulos",
   "confirmations.logout.message": "Oletko varma, että haluat kirjautua ulos?",
   "confirmations.mute.confirm": "Mykistä",
@@ -142,106 +149,106 @@
   "emoji_button.food": "Ruoka ja juoma",
   "emoji_button.label": "Lisää emoji",
   "emoji_button.nature": "Luonto",
-  "emoji_button.not_found": "Ei emojeja!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Vastaavia emojeja ei löytynyt",
   "emoji_button.objects": "Esineet",
   "emoji_button.people": "Ihmiset",
   "emoji_button.recent": "Usein käytetyt",
   "emoji_button.search": "Etsi...",
   "emoji_button.search_results": "Hakutulokset",
   "emoji_button.symbols": "Symbolit",
-  "emoji_button.travel": "Matkailu",
+  "emoji_button.travel": "Matkailu ja paikat",
   "empty_column.account_suspended": "Tilin käyttäminen keskeytetty",
-  "empty_column.account_timeline": "Ei ole 'toots' täällä!",
+  "empty_column.account_timeline": "Täällä ei viestejä!",
   "empty_column.account_unavailable": "Profiilia ei löydy",
   "empty_column.blocks": "Et ole vielä estänyt yhtään käyttäjää.",
-  "empty_column.bookmarked_statuses": "Et ole vielä lisännyt tuuttauksia kirjanmerkkeihisi. Kun teet niin, tuuttaus näkyy tässä.",
-  "empty_column.community": "Paikallinen aikajana on tyhjä. Homma lähtee käyntiin, kun kirjoitat jotain julkista!",
+  "empty_column.bookmarked_statuses": "Et ole vielä lisännyt viestejä kirjanmerkkeihisi. Kun lisäät yhden, se näkyy tässä.",
+  "empty_column.community": "Paikallinen aikajana on tyhjä. Kirjoita jotain julkista, niin homma lähtee käyntiin!",
   "empty_column.direct": "Sinulla ei ole vielä yhtään viestiä yksittäiselle käyttäjälle. Kun lähetät tai vastaanotat sellaisen, se näkyy täällä.",
-  "empty_column.domain_blocks": "Yhtään verkko-osoitetta ei ole vielä piilotettu.",
-  "empty_column.favourited_statuses": "Et ole vielä lisännyt tuuttauksia suosikkeihisi. Kun teet niin, tuuttaus näkyy tässä.",
-  "empty_column.favourites": "Kukaan ei ole vielä lisännyt tätä tuuttausta suosikkeihinsa. Kun joku tekee niin, näkyy kyseinen henkilö tässä.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.domain_blocks": "Yhtään verkko-osoitetta ei ole vielä estetty.",
+  "empty_column.favourited_statuses": "Et ole vielä lisännyt viestejä kirjanmerkkeihisi. Kun lisäät yhden, se näkyy tässä.",
+  "empty_column.favourites": "Kukaan ei ole vielä lisännyt tätä viestiä suosikkeihinsa. Kun joku tekee niin, näkyy kyseinen henkilö tässä.",
+  "empty_column.follow_recommendations": "Näyttää siltä, että sinulle ei voi luoda ehdotuksia. Voit yrittää etsiä ihmisiä, jotka saatat tuntea tai tutkia trendaavia aihesanoja.",
   "empty_column.follow_requests": "Sinulla ei ole vielä seurauspyyntöjä. Kun saat sellaisen, näkyy se tässä.",
   "empty_column.hashtag": "Tällä hashtagilla ei ole vielä mitään.",
-  "empty_column.home": "Kotiaikajanasi on tyhjä! {public} ja hakutoiminto auttavat alkuun ja kohtaamaan muita käyttäjiä.",
+  "empty_column.home": "Kotisi aikajana on tyhjä! Seuraa lisää ihmisiä täyttääksesi sen. {suggestions}",
   "empty_column.home.suggestions": "Katso joitakin ehdotuksia",
-  "empty_column.list": "Lista on vielä tyhjä. Listan jäsenten julkaisemat tilapäivitykset tulevat tähän näkyviin.",
+  "empty_column.list": "Tässä luettelossa ei ole vielä mitään. Kun tämän luettelon jäsenet julkaisevat uusia viestejä, ne näkyvät täällä.",
   "empty_column.lists": "Sinulla ei ole vielä yhtään listaa. Kun luot sellaisen, näkyy se tässä.",
   "empty_column.mutes": "Et ole mykistänyt vielä yhtään käyttäjää.",
-  "empty_column.notifications": "Sinulle ei ole vielä ilmoituksia. Aloita keskustelu juttelemalla muille.",
-  "empty_column.public": "Täällä ei ole mitään! Saat sisältöä, kun kirjoitat jotain julkisesti tai käyt seuraamassa muiden instanssien käyttäjiä",
+  "empty_column.notifications": "Sinulla ei ole vielä ilmoituksia. Kun muut ihmiset ovat vuorovaikutuksessa kanssasi, näet sen täällä.",
+  "empty_column.public": "Täällä ei ole mitään! Kirjoita jotain julkisesti tai manuaalisesti seuraa muiden palvelimien käyttäjiä niin saat sisältöä",
   "error.unexpected_crash.explanation": "Sivua ei voi näyttää oikein, johtuen bugista tai ongelmasta selaimen yhteensopivuudessa.",
   "error.unexpected_crash.explanation_addons": "Sivua ei voitu näyttää oikein. Tämä virhe johtuu todennäköisesti selaimen lisäosasta tai automaattisista käännöstyökaluista.",
   "error.unexpected_crash.next_steps": "Kokeile päivittää sivu. Jos tämä ei auta, saatat yhä pystyä käyttämään Mastodonia toisen selaimen tai sovelluksen kautta.",
   "error.unexpected_crash.next_steps_addons": "Yritä poistaa ne käytöstä ja päivittää sivu. Jos se ei auta, voit silti käyttää Mastodonia eri selaimen tai sovelluksen kautta.",
-  "errors.unexpected_crash.copy_stacktrace": "Kopioi stacktrace leikepöydälle",
+  "errors.unexpected_crash.copy_stacktrace": "Kopioi pinon jäljitys leikepöydälle",
   "errors.unexpected_crash.report_issue": "Ilmoita ongelmasta",
   "follow_recommendations.done": "Valmis",
   "follow_recommendations.heading": "Seuraa ihmisiä, joilta haluaisit nähdä viestejä! Tässä on muutamia ehdotuksia.",
   "follow_recommendations.lead": "Viestit seuraamiltasi henkilöiltä näkyvät aikajärjestyksessä kotinäytön syötteessä. Älä pelkää seurata vahingossa, voit lopettaa seuraaminen yhtä helposti milloin tahansa!",
   "follow_request.authorize": "Valtuuta",
   "follow_request.reject": "Hylkää",
-  "follow_requests.unlocked_explanation": "Vaikka tilisi ei ole lukittu, {domain} ylläpitäjien mielestä haluat tarkistaa näiden tilien seurauspyynnöt manuaalisesti.",
+  "follow_requests.unlocked_explanation": "Vaikka tiliäsi ei ole lukittu, {domain}:n ylläpitäjien mielestä saatat haluta tarkistaa nämä seurauspyynnöt manuaalisesti.",
   "generic.saved": "Tallennettu",
-  "getting_started.developers": "Kehittäjille",
+  "getting_started.developers": "Kehittäjät",
   "getting_started.directory": "Profiilihakemisto",
-  "getting_started.documentation": "Documentaatio",
-  "getting_started.heading": "Aloitus",
+  "getting_started.documentation": "Käyttöohjeet",
+  "getting_started.heading": "Näin pääset alkuun",
   "getting_started.invite": "Kutsu ihmisiä",
   "getting_started.open_source_notice": "Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia GitHubissa: {github}.",
-  "getting_started.security": "Tunnukset",
+  "getting_started.security": "Tiliasetukset",
   "getting_started.terms": "Käyttöehdot",
   "hashtag.column_header.tag_mode.all": "ja {additional}",
   "hashtag.column_header.tag_mode.any": "tai {additional}",
   "hashtag.column_header.tag_mode.none": "ilman {additional}",
-  "hashtag.column_settings.select.no_options_message": "Ehdostuta ei löydetty",
-  "hashtag.column_settings.select.placeholder": "Laita häshtägejä…",
-  "hashtag.column_settings.tag_mode.all": "Kaikki",
-  "hashtag.column_settings.tag_mode.any": "Kaikki",
-  "hashtag.column_settings.tag_mode.none": "Ei mikään",
-  "hashtag.column_settings.tag_toggle": "Include additional tags in this column",
+  "hashtag.column_settings.select.no_options_message": "Ehdotuksia ei löytynyt",
+  "hashtag.column_settings.select.placeholder": "Syötä aihetunnisteet…",
+  "hashtag.column_settings.tag_mode.all": "Kaikki nämä",
+  "hashtag.column_settings.tag_mode.any": "Mikä tahansa näistä",
+  "hashtag.column_settings.tag_mode.none": "Ei mitään näistä",
+  "hashtag.column_settings.tag_toggle": "Sisällytä lisätunnisteet tähän sarakkeeseen",
   "home.column_settings.basic": "Perusasetukset",
   "home.column_settings.show_reblogs": "Näytä buustaukset",
   "home.column_settings.show_replies": "Näytä vastaukset",
   "home.hide_announcements": "Piilota ilmoitukset",
   "home.show_announcements": "Näytä ilmoitukset",
-  "intervals.full.days": "Päivä päiviä",
-  "intervals.full.hours": "Tunti tunteja",
-  "intervals.full.minutes": "Minuuti minuuteja",
-  "keyboard_shortcuts.back": "liiku taaksepäin",
-  "keyboard_shortcuts.blocked": "avaa lista estetyistä käyttäjistä",
-  "keyboard_shortcuts.boost": "buustaa",
-  "keyboard_shortcuts.column": "siirrä fokus tietyn sarakkeen tilapäivitykseen",
+  "intervals.full.days": "{number, plural, one {# päivä} other {# päivää}}",
+  "intervals.full.hours": "{number, plural, one {# tunti} other {# tuntia}}",
+  "intervals.full.minutes": "{number, plural, one {# minuutti} other {# minuuttia}}",
+  "keyboard_shortcuts.back": "Siirry takaisin",
+  "keyboard_shortcuts.blocked": "Avaa estettyjen käyttäjien luettelo",
+  "keyboard_shortcuts.boost": "Buustaa viestiä",
+  "keyboard_shortcuts.column": "Kohdista sarakkeeseen",
   "keyboard_shortcuts.compose": "siirry tekstinsyöttöön",
   "keyboard_shortcuts.description": "Kuvaus",
-  "keyboard_shortcuts.direct": "avaa pikaviestisarake",
-  "keyboard_shortcuts.down": "siirry listassa alaspäin",
-  "keyboard_shortcuts.enter": "avaa tilapäivitys",
-  "keyboard_shortcuts.favourite": "lisää suosikkeihin",
-  "keyboard_shortcuts.favourites": "avaa lista suosikeista",
-  "keyboard_shortcuts.federated": "avaa yleinen aikajana",
+  "keyboard_shortcuts.direct": "Avaa pikaviestisarake",
+  "keyboard_shortcuts.down": "Siirry listassa alaspäin",
+  "keyboard_shortcuts.enter": "Avaa viesti",
+  "keyboard_shortcuts.favourite": "Lisää suosikkeihin",
+  "keyboard_shortcuts.favourites": "Avaa lista suosikeista",
+  "keyboard_shortcuts.federated": "Avaa yleinen aikajana",
   "keyboard_shortcuts.heading": "Näppäinkomennot",
-  "keyboard_shortcuts.home": "avaa kotiaikajana",
+  "keyboard_shortcuts.home": "Avaa kotiaikajana",
   "keyboard_shortcuts.hotkey": "Pikanäppäin",
-  "keyboard_shortcuts.legend": "näytä tämä selite",
-  "keyboard_shortcuts.local": "avaa paikallinen aikajana",
-  "keyboard_shortcuts.mention": "mainitse julkaisija",
-  "keyboard_shortcuts.muted": "avaa lista mykistetyistä käyttäjistä",
-  "keyboard_shortcuts.my_profile": "avaa profiilisi",
-  "keyboard_shortcuts.notifications": "avaa ilmoitukset-sarake",
-  "keyboard_shortcuts.open_media": "median avaus",
-  "keyboard_shortcuts.pinned": "avaa lista kiinnitetyistä tuuttauksista",
-  "keyboard_shortcuts.profile": "avaa kirjoittajan profiili",
-  "keyboard_shortcuts.reply": "vastaa",
-  "keyboard_shortcuts.requests": "avaa lista seurauspyynnöistä",
+  "keyboard_shortcuts.legend": "Näytä tämä selite",
+  "keyboard_shortcuts.local": "Avaa paikallinen aikajana",
+  "keyboard_shortcuts.mention": "Mainitse julkaisija",
+  "keyboard_shortcuts.muted": "Avaa lista mykistetyistä käyttäjistä",
+  "keyboard_shortcuts.my_profile": "Avaa profiilisi",
+  "keyboard_shortcuts.notifications": "Avaa ilmoitukset-sarake",
+  "keyboard_shortcuts.open_media": "Avaa media",
+  "keyboard_shortcuts.pinned": "Avaa lista kiinnitetyistä viesteistä",
+  "keyboard_shortcuts.profile": "Avaa kirjoittajan profiili",
+  "keyboard_shortcuts.reply": "Vastaa viestiin",
+  "keyboard_shortcuts.requests": "Avaa lista seurauspyynnöistä",
   "keyboard_shortcuts.search": "siirry hakukenttään",
   "keyboard_shortcuts.spoilers": "näyttääksesi/piilottaaksesi CW kentän",
   "keyboard_shortcuts.start": "avaa \"Aloitus\" -sarake",
   "keyboard_shortcuts.toggle_hidden": "näytä/piilota sisältövaroituksella merkitty teksti",
   "keyboard_shortcuts.toggle_sensitivity": "näytä/piilota media",
-  "keyboard_shortcuts.toot": "ala kirjoittaa uutta tuuttausta",
-  "keyboard_shortcuts.unfocus": "siirry pois tekstikentästä tai hakukentästä",
-  "keyboard_shortcuts.up": "siirry listassa ylöspäin",
+  "keyboard_shortcuts.toot": "Aloita uusi viesti",
+  "keyboard_shortcuts.unfocus": "Poistu teksti-/hakukentästä",
+  "keyboard_shortcuts.up": "Siirry listassa ylöspäin",
   "lightbox.close": "Sulje",
   "lightbox.compress": "Pakkaa kuvan näkymälaatikko",
   "lightbox.expand": "Laajenna kuvan näkymälaatikko",
@@ -259,10 +266,10 @@
   "lists.replies_policy.none": "Ei kukaan",
   "lists.replies_policy.title": "Näytä vastaukset:",
   "lists.search": "Etsi seuraamistasi henkilöistä",
-  "lists.subheading": "Omat listat",
+  "lists.subheading": "Omat listasi",
   "load_pending": "{count, plural, one {# uusi kappale} other {# uutta kappaletta}}",
   "loading_indicator.label": "Ladataan...",
-  "media_gallery.toggle_visible": "Säädä näkyvyyttä",
+  "media_gallery.toggle_visible": "{number, plural, one {Piilota kuva} other {Piilota kuvat}}",
   "missing_indicator.label": "Ei löytynyt",
   "missing_indicator.sublabel": "Tätä resurssia ei löytynyt",
   "mute_modal.duration": "Kesto",
@@ -272,10 +279,10 @@
   "navigation_bar.blocks": "Estetyt käyttäjät",
   "navigation_bar.bookmarks": "Kirjanmerkit",
   "navigation_bar.community_timeline": "Paikallinen aikajana",
-  "navigation_bar.compose": "Kirjoita uusi tuuttaus",
-  "navigation_bar.direct": "Viestit",
+  "navigation_bar.compose": "Luo uusi viesti",
+  "navigation_bar.direct": "Pikaviestit",
   "navigation_bar.discover": "Löydä uutta",
-  "navigation_bar.domain_blocks": "Piilotetut verkkotunnukset",
+  "navigation_bar.domain_blocks": "Estetyt verkkotunnukset",
   "navigation_bar.edit_profile": "Muokkaa profiilia",
   "navigation_bar.favourites": "Suosikit",
   "navigation_bar.filters": "Mykistetyt sanat",
@@ -286,12 +293,12 @@
   "navigation_bar.lists": "Listat",
   "navigation_bar.logout": "Kirjaudu ulos",
   "navigation_bar.mutes": "Mykistetyt käyttäjät",
-  "navigation_bar.personal": "Henkilökohtaiset",
-  "navigation_bar.pins": "Kiinnitetyt tuuttaukset",
+  "navigation_bar.personal": "Henkilökohtainen",
+  "navigation_bar.pins": "Kiinnitetyt viestit",
   "navigation_bar.preferences": "Asetukset",
   "navigation_bar.public_timeline": "Yleinen aikajana",
-  "navigation_bar.security": "Tunnukset",
-  "notification.favourite": "{name} tykkäsi tilastasi",
+  "navigation_bar.security": "Turvallisuus",
+  "notification.favourite": "{name} tykkäsi viestistäsi",
   "notification.follow": "{name} seurasi sinua",
   "notification.follow_request": "{name} haluaa seurata sinua",
   "notification.mention": "{name} mainitsi sinut",
@@ -330,7 +337,7 @@
   "notifications.permission_denied_alert": "Työpöytäilmoituksia ei voi ottaa käyttöön, koska selaimen käyttöoikeus on aiemmin estetty",
   "notifications.permission_required": "Työpöytäilmoitukset eivät ole käytettävissä, koska siihen tarvittavaa lupaa ei ole myönnetty.",
   "notifications_permission_banner.enable": "Ota työpöytäilmoitukset käyttöön",
-  "notifications_permission_banner.how_to_control": "Saadaksesi ilmoituksia, kun Mastodon ei ole auki, ota työpöytäilmoitukset käyttöön. Voit hallita tarkasti, mistä saat työpöytäilmoituksia kun ilmoitukset on otettu käyttöön yllä olevan {icon} -painikkeen kautta.",
+  "notifications_permission_banner.how_to_control": "Saadaksesi ilmoituksia, kun Mastodon ei ole auki, ota työpöytäilmoitukset käyttöön. Voit hallita tarkasti, mistä saat työpöytäilmoituksia kun ilmoitukset on otettu käyttöön yllä olevan {icon}-painikkeen kautta.",
   "notifications_permission_banner.title": "Älä anna minkään mennä ohi",
   "picture_in_picture.restore": "Laita se takaisin",
   "poll.closed": "Suljettu",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# ääni} other {# ääntä}}",
   "poll.vote": "Äänestä",
   "poll.voted": "Äänestit tätä vastausta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Lisää kysely",
   "poll_button.remove_poll": "Poista kysely",
   "privacy.change": "Säädä tuuttauksen näkyvyyttä",
@@ -354,40 +362,40 @@
   "regeneration_indicator.label": "Ladataan…",
   "regeneration_indicator.sublabel": "Kotinäkymääsi valmistellaan!",
   "relative_time.days": "{number} pv",
-  "relative_time.hours": "{number} h",
+  "relative_time.hours": "{number} tuntia",
   "relative_time.just_now": "nyt",
-  "relative_time.minutes": "{number} m",
-  "relative_time.seconds": "{number} s",
+  "relative_time.minutes": "{number} min",
+  "relative_time.seconds": "{number} sek",
   "relative_time.today": "tänään",
   "reply_indicator.cancel": "Peruuta",
   "report.forward": "Välitä kohteeseen {target}",
   "report.forward_hint": "Tämä tili on toisella palvelimella. Haluatko lähettää nimettömän raportin myös sinne?",
-  "report.hint": "Raportti lähetetään oman instanssisi moderaattoreille. Seuraavassa voit kertoa, miksi raportoit tästä tilistä:",
+  "report.hint": "Raportti lähetetään oman palvelimesi moderaattoreille. Voit kertoa alla, miksi raportoit tästä tilistä:",
   "report.placeholder": "Lisäkommentit",
   "report.submit": "Lähetä",
   "report.target": "Raportoidaan {target}",
   "search.placeholder": "Hae",
   "search_popout.search_format": "Tarkennettu haku",
-  "search_popout.tips.full_text": "Tekstihaku palauttaa tilapäivitykset, jotka olet kirjoittanut, lisännyt suosikkeihisi, boostannut tai joissa sinut mainitaan, sekä tekstin sisältävät käyttäjänimet, nimimerkit ja hastagit.",
-  "search_popout.tips.hashtag": "hashtagit",
+  "search_popout.tips.full_text": "Tekstihaku listaa tilapäivitykset, jotka olet kirjoittanut, lisännyt suosikkeihisi, boostannut tai joissa sinut mainitaan, sekä tekstin sisältävät käyttäjänimet, nimimerkit ja hastagit.",
+  "search_popout.tips.hashtag": "aihetunnisteet",
   "search_popout.tips.status": "tila",
-  "search_popout.tips.text": "Tekstihaku palauttaa hakua vastaavat nimimerkit, käyttäjänimet ja hastagit",
+  "search_popout.tips.text": "Tekstihaku listaa hakua vastaavat nimimerkit, käyttäjänimet ja hastagit",
   "search_popout.tips.user": "käyttäjä",
   "search_results.accounts": "Ihmiset",
-  "search_results.hashtags": "Hashtagit",
-  "search_results.statuses": "Tuuttaukset",
-  "search_results.statuses_fts_disabled": "Tuuttausten haku sisällön perusteella ei ole käytössä tällä Mastodon-serverillä.",
-  "search_results.total": "{count, number} {count, plural, one {tulos} other {tulosta}}",
+  "search_results.hashtags": "Aihetunnisteet",
+  "search_results.statuses": "Viestit",
+  "search_results.statuses_fts_disabled": "Viestien haku sisällön perusteella ei ole käytössä tällä Mastodon-palvelimella.",
+  "search_results.total": "{count, number} {count, plural, one {tulos} other {tulokset}}",
   "status.admin_account": "Avaa moderaattorinäkymä tilistä @{name}",
-  "status.admin_status": "Avaa tilapäivitys moderaattorinäkymässä",
+  "status.admin_status": "Avaa tämä viesti moderointinäkymässä",
   "status.block": "Estä @{name}",
   "status.bookmark": "Tallenna kirjanmerkki",
   "status.cancel_reblog_private": "Peru buustaus",
-  "status.cannot_reblog": "Tätä julkaisua ei voi buustata",
+  "status.cannot_reblog": "Tätä viestiä ei voi buustata",
   "status.copy": "Kopioi linkki tilapäivitykseen",
   "status.delete": "Poista",
   "status.detailed_status": "Yksityiskohtainen keskustelunäkymä",
-  "status.direct": "Viesti käyttäjälle @{name}",
+  "status.direct": "Pikaviesti käyttäjälle @{name}",
   "status.embed": "Upota",
   "status.favourite": "Tykkää",
   "status.filtered": "Suodatettu",
@@ -397,15 +405,15 @@
   "status.more": "Lisää",
   "status.mute": "Mykistä @{name}",
   "status.mute_conversation": "Mykistä keskustelu",
-  "status.open": "Laajenna tilapäivitys",
+  "status.open": "Laajenna viestit",
   "status.pin": "Kiinnitä profiiliin",
-  "status.pinned": "Kiinnitetty tuuttaus",
+  "status.pinned": "Kiinnitetty viesti",
   "status.read_more": "Näytä enemmän",
   "status.reblog": "Buustaa",
   "status.reblog_private": "Buustaa alkuperäiselle yleisölle",
   "status.reblogged_by": "{name} buustasi",
-  "status.reblogs.empty": "Kukaan ei ole vielä buustannut tätä tuuttausta. Kun joku tekee niin, näkyy kyseinen henkilö tässä.",
-  "status.redraft": "Poista & palauta muokattavaksi",
+  "status.reblogs.empty": "Kukaan ei ole vielä buustannut tätä viestiä. Kun joku tekee niin, näkyy kyseinen henkilö tässä.",
+  "status.redraft": "Poista ja palauta muokattavaksi",
   "status.remove_bookmark": "Poista kirjanmerkki",
   "status.reply": "Vastaa",
   "status.replyAll": "Vastaa ketjuun",
@@ -436,12 +444,12 @@
   "timeline_hint.resources.followers": "Seuraajat",
   "timeline_hint.resources.follows": "Seuraa",
   "timeline_hint.resources.statuses": "Vanhemmat tuuttaukset",
-  "trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
+  "trends.counter_by_accounts": "{count, plural, one {{counter} henkilö} other {{counter} henkilöä}} puhuu",
   "trends.trending_now": "Suosittua nyt",
   "ui.beforeunload": "Luonnos häviää, jos poistut Mastodonista.",
-  "units.short.billion": "{count}B",
-  "units.short.million": "{count}m",
-  "units.short.thousand": "{count}k",
+  "units.short.billion": "{count} miljardia",
+  "units.short.million": "{count} miljoonaa",
+  "units.short.thousand": "{count} tuhatta",
   "upload_area.title": "Lataa raahaamalla ja pudottamalla tähän",
   "upload_button.label": "Lisää mediaa",
   "upload_error.limit": "Tiedostolatauksien raja ylitetty.",
@@ -454,8 +462,9 @@
   "upload_form.video_description": "Kuvaile kuulo- tai näkövammaisille",
   "upload_modal.analyzing_picture": "Analysoidaan kuvaa…",
   "upload_modal.apply": "Käytä",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Valitse kuva",
-  "upload_modal.description_placeholder": "Eräänä jäätävänä ja pimeänä yönä gorilla ratkaisi sudokun kahdessa minuutissa",
+  "upload_modal.description_placeholder": "Nopea ruskea kettu hyppää laiskan koiran yli",
   "upload_modal.detect_text": "Tunnista teksti kuvasta",
   "upload_modal.edit_media": "Muokkaa mediaa",
   "upload_modal.hint": "Klikkaa tai vedä ympyrä esikatselussa valitaksesi keskipiste, joka näkyy aina pienoiskuvissa.",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 811e318b4..91efd024b 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -5,7 +5,7 @@
   "account.badges.group": "Groupe",
   "account.block": "Bloquer @{name}",
   "account.block_domain": "Bloquer le domaine {domain}",
-  "account.blocked": "Bloqué·e",
+  "account.blocked": "Bloqué",
   "account.browse_more_on_origin_server": "Parcourir davantage sur le profil original",
   "account.cancel_follow_request": "Annuler la demande de suivi",
   "account.direct": "Envoyer un message direct à @{name}",
@@ -15,17 +15,17 @@
   "account.enable_notifications": "Me notifier quand @{name} publie",
   "account.endorse": "Recommander sur le profil",
   "account.follow": "Suivre",
-  "account.followers": "Abonné·e·s",
-  "account.followers.empty": "Personne ne suit cet·te utilisateur·rice pour l’instant.",
-  "account.followers_counter": "{count, plural, one {{counter} Abonné·e} other {{counter} Abonné·e·s}}",
+  "account.followers": "Abonnés",
+  "account.followers.empty": "Personne ne suit cet utilisateur pour l’instant.",
+  "account.followers_counter": "{count, plural, one {{counter} Abonné} other {{counter} Abonnés}}",
   "account.following_counter": "{count, plural, other {{counter} Abonnements}}",
-  "account.follows.empty": "Cet·te utilisateur·rice ne suit personne pour l’instant.",
+  "account.follows.empty": "Cet utilisateur ne suit personne pour l’instant.",
   "account.follows_you": "Vous suit",
   "account.hide_reblogs": "Masquer les partages de @{name}",
   "account.joined": "Ici depuis {date}",
   "account.last_status": "Dernière activité",
   "account.link_verified_on": "La propriété de ce lien a été vérifiée le {date}",
-  "account.locked_info": "Ce compte est privé. Son ou sa propriétaire approuve manuellement qui peut le suivre.",
+  "account.locked_info": "Ce compte est privé. Son propriétaire approuve manuellement qui peut le suivre.",
   "account.media": "Médias",
   "account.mention": "Mentionner @{name}",
   "account.moved_to": "{name} a déménagé vers :",
@@ -47,11 +47,16 @@
   "account.unmute": "Ne plus masquer @{name}",
   "account.unmute_notifications": "Ne plus masquer les notifications de @{name}",
   "account_note.placeholder": "Cliquez pour ajouter une note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Veuillez réessayer après {retry_time, time, medium}.",
   "alert.rate_limited.title": "Débit limité",
   "alert.unexpected.message": "Une erreur inattendue s’est produite.",
   "alert.unexpected.title": "Oups !",
   "announcement.announcement": "Annonce",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} par semaine",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci la prochaine fois",
   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
@@ -113,15 +118,17 @@
   "confirmations.delete.message": "Voulez-vous vraiment supprimer ce message ?",
   "confirmations.delete_list.confirm": "Supprimer",
   "confirmations.delete_list.message": "Voulez-vous vraiment supprimer définitivement cette liste ?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Bloquer tout le domaine",
-  "confirmations.domain_block.message": "Voulez-vous vraiment, vraiment bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables. Vous ne verrez plus de contenu provenant de ce domaine, ni dans fils publics, ni dans vos notifications. Vos abonné·e·s utilisant ce domaine seront retiré·e·s.",
+  "confirmations.domain_block.message": "Voulez-vous vraiment, vraiment bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables. Vous ne verrez plus de contenu provenant de ce domaine, ni dans fils publics, ni dans vos notifications. Vos abonnés utilisant ce domaine seront retirés.",
   "confirmations.logout.confirm": "Se déconnecter",
   "confirmations.logout.message": "Voulez-vous vraiment vous déconnecter ?",
   "confirmations.mute.confirm": "Masquer",
   "confirmations.mute.explanation": "Cela masquera ses messages et les messages le ou la mentionnant, mais cela lui permettra quand même de voir vos messages et de vous suivre.",
   "confirmations.mute.message": "Voulez-vous vraiment masquer {name} ?",
   "confirmations.redraft.confirm": "Supprimer et ré-écrire",
-  "confirmations.redraft.message": "Êtes-vous sûr·e de vouloir effacer ce statut pour le ré-écrire ? Ses partages ainsi que ses mises en favori seront perdu·e·s et ses réponses seront orphelines.",
+  "confirmations.redraft.message": "Êtes-vous sûr de vouloir effacer ce statut pour le récrire ? Ses partages ainsi que ses mises en favori seront perdus et ses réponses seront orphelines.",
   "confirmations.reply.confirm": "Répondre",
   "confirmations.reply.message": "Répondre maintenant écrasera le message que vous rédigez actuellement. Voulez-vous vraiment continuer ?",
   "confirmations.unfollow.confirm": "Ne plus suivre",
@@ -142,7 +149,7 @@
   "emoji_button.food": "Nourriture & Boisson",
   "emoji_button.label": "Insérer un émoji",
   "emoji_button.nature": "Nature",
-  "emoji_button.not_found": "Pas d’émoji !! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Aucune correspondance d'émoji trouvée",
   "emoji_button.objects": "Objets",
   "emoji_button.people": "Personnes",
   "emoji_button.recent": "Fréquemment utilisés",
@@ -153,7 +160,7 @@
   "empty_column.account_suspended": "Compte suspendu",
   "empty_column.account_timeline": "Aucun message ici !",
   "empty_column.account_unavailable": "Profil non disponible",
-  "empty_column.blocks": "Vous n’avez bloqué aucun·e utilisateur·rice pour le moment.",
+  "empty_column.blocks": "Vous n’avez bloqué aucun utilisateur pour le moment.",
   "empty_column.bookmarked_statuses": "Vous n'avez pas de message en marque-page. Lorsque vous en ajouterez un, il apparaîtra ici.",
   "empty_column.community": "Le fil public local est vide. Écrivez donc quelque chose pour le remplir !",
   "empty_column.direct": "Vous n’avez pas encore de messages directs. Lorsque vous en enverrez ou recevrez un, il s’affichera ici.",
@@ -165,9 +172,9 @@
   "empty_column.hashtag": "Il n’y a encore aucun contenu associé à ce hashtag.",
   "empty_column.home": "Vous ne suivez personne. Visitez {public} ou utilisez la recherche pour trouver d’autres personnes à suivre.",
   "empty_column.home.suggestions": "Voir quelques suggestions",
-  "empty_column.list": "Il n’y a rien dans cette liste pour l’instant. Quand des membres de cette liste publieront de nouveaux messages, iels apparaîtront ici.",
+  "empty_column.list": "Il n’y a rien dans cette liste pour l’instant. Quand des membres de cette liste publieront de nouveaux messages, ils apparaîtront ici.",
   "empty_column.lists": "Vous n’avez pas encore de liste. Lorsque vous en créerez une, elle apparaîtra ici.",
-  "empty_column.mutes": "Vous n’avez masqué aucun·e utilisateur·rice pour le moment.",
+  "empty_column.mutes": "Vous n’avez masqué aucun utilisateur pour le moment.",
   "empty_column.notifications": "Vous n’avez pas encore de notification. Interagissez avec d’autres personnes pour débuter la conversation.",
   "empty_column.public": "Il n’y a rien ici ! Écrivez quelque chose publiquement, ou bien suivez manuellement des personnes d’autres serveurs pour remplir le fil public",
   "error.unexpected_crash.explanation": "En raison d’un bug dans notre code ou d’un problème de compatibilité avec votre navigateur, cette page n’a pas pu être affichée correctement.",
@@ -225,13 +232,13 @@
   "keyboard_shortcuts.hotkey": "Raccourci clavier",
   "keyboard_shortcuts.legend": "Afficher cet aide-mémoire",
   "keyboard_shortcuts.local": "Ouvrir le fil public local",
-  "keyboard_shortcuts.mention": "mentionner l’auteur·rice",
+  "keyboard_shortcuts.mention": "Mentionner l’auteur",
   "keyboard_shortcuts.muted": "Ouvrir la liste des comptes masqués",
   "keyboard_shortcuts.my_profile": "Ouvrir votre profil",
   "keyboard_shortcuts.notifications": "Ouvrir la colonne de notifications",
   "keyboard_shortcuts.open_media": "ouvrir le média",
   "keyboard_shortcuts.pinned": "Ouvrir la liste des messages épinglés",
-  "keyboard_shortcuts.profile": "ouvrir le profil de l’auteur·rice",
+  "keyboard_shortcuts.profile": "Ouvrir le profil de l’auteur",
   "keyboard_shortcuts.reply": "Répondre au message",
   "keyboard_shortcuts.requests": "Ouvrir la liste de demandes d’abonnement",
   "keyboard_shortcuts.search": "Se placer dans le champ de recherche",
@@ -254,7 +261,7 @@
   "lists.edit.submit": "Modifier le titre",
   "lists.new.create": "Ajouter une liste",
   "lists.new.title_placeholder": "Titre de la nouvelle liste",
-  "lists.replies_policy.followed": "Comptes que vous suivez",
+  "lists.replies_policy.followed": "N'importe quel utilisateur suivi",
   "lists.replies_policy.list": "Membres de la liste",
   "lists.replies_policy.none": "Personne",
   "lists.replies_policy.title": "Afficher les réponses à :",
@@ -294,7 +301,7 @@
   "notification.favourite": "{name} a ajouté le message à ses favoris",
   "notification.follow": "{name} vous suit",
   "notification.follow_request": "{name} a demandé à vous suivre",
-  "notification.mention": "{name} vous a mentionné·e :",
+  "notification.mention": "{name} vous a mentionné·",
   "notification.own_poll": "Votre sondage est terminé",
   "notification.poll": "Un sondage auquel vous avez participé vient de se terminer",
   "notification.reblog": "{name} a partagé votre message",
@@ -306,7 +313,7 @@
   "notifications.column_settings.filter_bar.advanced": "Afficher toutes les catégories",
   "notifications.column_settings.filter_bar.category": "Barre de filtrage rapide",
   "notifications.column_settings.filter_bar.show": "Afficher",
-  "notifications.column_settings.follow": "Nouveaux·elles abonné·e·s :",
+  "notifications.column_settings.follow": "Nouveaux abonnés :",
   "notifications.column_settings.follow_request": "Nouvelles demandes d’abonnement :",
   "notifications.column_settings.mention": "Mentions :",
   "notifications.column_settings.poll": "Résultats des sondage :",
@@ -339,16 +346,17 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Voter",
   "poll.voted": "Vous avez voté pour cette réponse",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Ajouter un sondage",
   "poll_button.remove_poll": "Supprimer le sondage",
   "privacy.change": "Ajuster la confidentialité du message",
   "privacy.direct.long": "Visible uniquement par les comptes mentionnés",
   "privacy.direct.short": "Direct",
   "privacy.private.long": "Visible uniquement par vos abonné·e·s",
-  "privacy.private.short": "Abonné·e·s uniquement",
-  "privacy.public.long": "Visible par tou·te·s, affiché dans les fils publics",
+  "privacy.private.short": "Abonnés uniquement",
+  "privacy.public.long": "Visible par tous, affiché dans les fils publics",
   "privacy.public.short": "Public",
-  "privacy.unlisted.long": "Visible par tou·te·s, mais pas dans les fils publics",
+  "privacy.unlisted.long": "Visible par tous, mais pas dans les fils publics",
   "privacy.unlisted.short": "Non listé",
   "refresh": "Actualiser",
   "regeneration_indicator.label": "Chargement…",
@@ -362,7 +370,7 @@
   "reply_indicator.cancel": "Annuler",
   "report.forward": "Transférer à {target}",
   "report.forward_hint": "Le compte provient d’un autre serveur. Envoyer également une copie anonyme du rapport ?",
-  "report.hint": "Le rapport sera envoyé aux modérateur·rice·s de votre instance. Vous pouvez expliquer pourquoi vous signalez le compte ci-dessous :",
+  "report.hint": "Le rapport sera envoyé aux modérateurs de votre instance. Vous pouvez expliquer pourquoi vous signalez le compte ci-dessous :",
   "report.placeholder": "Commentaires additionnels",
   "report.submit": "Envoyer",
   "report.target": "Signalement de {target}",
@@ -405,7 +413,7 @@
   "status.reblog_private": "Partager à l’audience originale",
   "status.reblogged_by": "{name} a partagé",
   "status.reblogs.empty": "Personne n’a encore partagé ce message. Lorsque quelqu’un le fera, il apparaîtra ici.",
-  "status.redraft": "Supprimer et ré-écrire",
+  "status.redraft": "Supprimer et récrire",
   "status.remove_bookmark": "Retirer des marque-pages",
   "status.reply": "Répondre",
   "status.replyAll": "Répondre au fil",
@@ -421,17 +429,17 @@
   "status.unmute_conversation": "Ne plus masquer la conversation",
   "status.unpin": "Retirer du profil",
   "suggestions.dismiss": "Rejeter la suggestion",
-  "suggestions.header": "Vous pourriez être intéressé·e par…",
+  "suggestions.header": "Vous pourriez être intéressé par…",
   "tabs_bar.federated_timeline": "Fil public global",
   "tabs_bar.home": "Accueil",
   "tabs_bar.local_timeline": "Fil public local",
   "tabs_bar.notifications": "Notifications",
   "tabs_bar.search": "Chercher",
-  "time_remaining.days": "{number, plural, one {# jour} other {# jours}} restant·s",
-  "time_remaining.hours": "{number, plural, one {# heure} other {# heures}} restantes",
-  "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} restantes",
+  "time_remaining.days": "{number, plural, one {# jour restant} other {# jours restants}}",
+  "time_remaining.hours": "{number, plural, one {# heure restante} other {# heures restantes}}",
+  "time_remaining.minutes": "{number, plural, one {# minute restante} other {# minutes restantes}}",
   "time_remaining.moments": "Encore quelques instants",
-  "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} restantes",
+  "time_remaining.seconds": "{number, plural, one {# seconde restante} other {# secondes restantes}}",
   "timeline_hint.remote_resource_not_displayed": "{resource} des autres serveurs ne sont pas affichés.",
   "timeline_hint.resources.followers": "Les abonnés",
   "timeline_hint.resources.follows": "Les abonnements",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Décrire pour les personnes ayant des problèmes d’audition ou de vision",
   "upload_modal.analyzing_picture": "Analyse de l’image en cours…",
   "upload_modal.apply": "Appliquer",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choisir une image",
   "upload_modal.description_placeholder": "Buvez de ce whisky que le patron juge fameux",
   "upload_modal.detect_text": "Détecter le texte de l’image",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 7c2dfc17b..9c94c61aa 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "No comment provided",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/gd.json b/app/javascript/mastodon/locales/gd.json
index 5a0f378c1..023ee3ecb 100644
--- a/app/javascript/mastodon/locales/gd.json
+++ b/app/javascript/mastodon/locales/gd.json
@@ -47,11 +47,16 @@
   "account.unmute": "Dì-mhùch @{name}",
   "account.unmute_notifications": "Dì-mhùch na brathan o @{name}",
   "account_note.placeholder": "Briog airson nòta a chur ris",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Feuch ris a-rithist às dèidh {retry_time, time, medium}.",
   "alert.rate_limited.title": "Cuingeachadh ùine",
   "alert.unexpected.message": "Thachair mearachd ris nach robh dùil.",
   "alert.unexpected.title": "Oich!",
   "announcement.announcement": "Brath-fios",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} gach seachdain",
   "boost_modal.combo": "Brùth air {combo} nam b’ fheàrr leat leum a ghearradh thar seo an ath-thuras",
   "bundle_column_error.body": "Chaidh rudeigin cearr nuair a dh’fheuch sinn ris a’ cho-phàirt seo a luchdadh.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "A bheil thu cinnteach gu bheil thu airson am post seo a sguabadh às?",
   "confirmations.delete_list.confirm": "Sguab às",
   "confirmations.delete_list.message": "A bheil thu cinnteach gu bheil thu airson an liosta seo a sguabadh às gu buan?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Bac an àrainn uile gu lèir",
   "confirmations.domain_block.message": "A bheil thu cinnteach dha-rìribh gu bheil thu airson an àrainn {domain} a bhacadh uile gu lèir? Mar as trice, foghnaidh gun dèan thu bacadh no mùchadh no dhà gu sònraichte agus bhiod sin na b’ fheàrr. Chan fhaic thu susbaint on àrainn ud air loidhne-ama phoblach sam bith no am measg nam brathan agad. Thèid an luchd-leantainn agad on àrainn ud a thoirt air falbh.",
   "confirmations.logout.confirm": "Clàraich a-mach",
@@ -133,7 +140,7 @@
   "directory.federated": "On cho-shaoghal aithnichte",
   "directory.local": "O {domain} a-mhàin",
   "directory.new_arrivals": "Feadhainn ùra",
-  "directory.recently_active": "Gnìomhach o chionn ghoirid",
+  "directory.recently_active": "Gnìomhach o chionn goirid",
   "embed.instructions": "Leabaich am post seo san làrach-lìn agad is tu a’ dèanamh lethbhreac dhen chòd gu h-ìosal.",
   "embed.preview": "Seo an coltas a bhios air:",
   "emoji_button.activity": "Gnìomhachd",
@@ -145,7 +152,7 @@
   "emoji_button.not_found": "Cha deach Emoji iomchaidh a lorg",
   "emoji_button.objects": "Nithean",
   "emoji_button.people": "Daoine",
-  "emoji_button.recent": "Air a chleachdadh o chionn ghoirid",
+  "emoji_button.recent": "Air a chleachdadh o chionn goirid",
   "emoji_button.search": "Lorg…",
   "emoji_button.search_results": "Toraidhean an luirg",
   "emoji_button.symbols": "Samhlaidhean",
@@ -168,7 +175,7 @@
   "empty_column.list": "Chan eil dad air an liosta seo fhathast. Nuair a phostaicheas buill a tha air an liosta seo postaichean ùra, nochdaidh iad an-seo.",
   "empty_column.lists": "Chan eil liosta agad fhathast. Nuair chruthaicheas tu tè, nochdaidh i an-seo.",
   "empty_column.mutes": "Cha do mhùch thu cleachdaiche sam bith fhathast.",
-  "empty_column.notifications": "Cha d’ fhuair thu brath sam bith fhathast. Nuair a ghabhas càch eadar-ghnìomh leat, chì thu an-seo e.",
+  "empty_column.notifications": "Cha d’ fhuair thu brath sam bith fhathast. Nuair a nì càch conaltradh leat, chì thu an-seo e.",
   "empty_column.public": "Chan eil dad an-seo! Sgrìobh rudeigin gu poblach no lean air càch o fhrithealaichean eile a làimh airson seo a lìonadh",
   "error.unexpected_crash.explanation": "Air sàilleibh buga sa chòd againn no duilgheadas co-chòrdalachd leis a’ bhrabhsair, chan urrainn dhuinn an duilleag seo a shealltainn mar bu chòir.",
   "error.unexpected_crash.explanation_addons": "Cha b’ urrainn dhuinn an duilleag seo a shealltainn mar bu chòir. Tha sinn an dùil gu do dh’adhbharaich tuilleadan a’ bhrabhsair no inneal eadar-theangachaidh fèin-obrachail a’ mhearachd.",
@@ -330,7 +337,7 @@
   "notifications.permission_denied_alert": "Cha ghabh brathan deasga a chur an comas on a chaidh iarrtas ceadan a’ bhrabhsair a dhiùltadh cheana",
   "notifications.permission_required": "Chan eil brathan deasga ri fhaighinn on nach deach an cead riatanach a thoirt seachad.",
   "notifications_permission_banner.enable": "Cuir brathan deasga an comas",
-  "notifications_permission_banner.how_to_control": "Airson brathan fhaighinn nuair nach eil Mastodon fosgailte, cuir na brathan deasga an comas. Tha an smachd agad fhèin air dè na seòrsaichean de dh’eadar-ghnìomhan a ghineas brathan deasga leis a’ phutan {icon} gu h-àrd nuair a bhios iad air an cur an comas.",
+  "notifications_permission_banner.how_to_control": "Airson brathan fhaighinn nuair nach eil Mastodon fosgailte, cuir na brathan deasga an comas. Tha an smachd agad fhèin air dè na seòrsaichean de chonaltradh a ghineas brathan deasga leis a’ phutan {icon} gu h-àrd nuair a bhios iad air an cur an comas.",
   "notifications_permission_banner.title": "Na caill dad gu bràth tuilleadh",
   "picture_in_picture.restore": "Thoir air ais e",
   "poll.closed": "Dùinte",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# bhòt} two {# bhòt} few {# bhòtaichean} other {# bhòt}}",
   "poll.vote": "Bhòt",
   "poll.voted": "Bhòt thu dhan fhreagairt seo",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Cuir cunntas-bheachd ris",
   "poll_button.remove_poll": "Thoir air falbh an cunntas-bheachd",
   "privacy.change": "Cuir gleus air prìobhaideachd a’ phuist",
@@ -432,7 +440,7 @@
   "time_remaining.minutes": "{number, plural, one {# mhionaid} two {# mhionaid} few {# mionaidean} other {# mionaid}} air fhàgail",
   "time_remaining.moments": "Cha doir e ach greiseag",
   "time_remaining.seconds": "{number, plural, one {# diog} two {# dhiog} few {# diogan} other {# diog}} air fhàgail",
-  "timeline_hint.remote_resource_not_displayed": "Cha dèid {stòrasan} o fhrithealaichean eile a shealltainn.",
+  "timeline_hint.remote_resource_not_displayed": "Cha dèid {resource} o fhrithealaichean eile a shealltainn.",
   "timeline_hint.resources.followers": "Luchd-leantainn",
   "timeline_hint.resources.follows": "A’ leantainn air",
   "timeline_hint.resources.statuses": "Postaichean nas sine",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Mìnich e dhan fheadhainn le èisteachd bheag no cion-lèirsinne",
   "upload_modal.analyzing_picture": "A’ sgrùdadh an deilbh…",
   "upload_modal.apply": "Cuir an sàs",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Tagh dealbh",
   "upload_modal.description_placeholder": "Lorg Sìm fiù bò, cè ⁊ neup ’ad àth",
   "upload_modal.detect_text": "Mothaich dhan teacsa on dealbh",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 087472b86..d85fd97bf 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -47,11 +47,16 @@
   "account.unmute": "Deixar de silenciar a @{name}",
   "account.unmute_notifications": "Deixar de silenciar as notificacións de @{name}",
   "account_note.placeholder": "Preme para engadir nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Téntao novamente após {retry_time, time, medium}.",
   "alert.rate_limited.title": "Límite de intentos",
   "alert.unexpected.message": "Aconteceu un fallo non agardado.",
   "alert.unexpected.title": "Vaites!",
   "announcement.announcement": "Anuncio",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Preme {combo} para ignorar isto na seguinte vez",
   "bundle_column_error.body": "Ocorreu un erro ó cargar este compoñente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Tes a certeza de querer eliminar esta publicación?",
   "confirmations.delete_list.confirm": "Eliminar",
   "confirmations.delete_list.message": "Tes a certeza de querer eliminar de xeito permanente esta listaxe?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Agochar dominio enteiro",
   "confirmations.domain_block.message": "Tes a certeza de querer bloquear todo de {domain}? Na meirande parte dos casos uns bloqueos ou silenciados específicos son suficientes. Non verás máis o contido deste dominio en ningunha cronoloxía pública ou nas túas notificacións. As túas seguidoras deste dominio serán eliminadas.",
   "confirmations.logout.confirm": "Pechar sesión",
@@ -142,7 +149,7 @@
   "emoji_button.food": "Comida e Bebida",
   "emoji_button.label": "Inserir emoticona",
   "emoji_button.nature": "Natureza",
-  "emoji_button.not_found": "Non hai emoticonas!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Non se atoparon emojis",
   "emoji_button.objects": "Obxectos",
   "emoji_button.people": "Persoas",
   "emoji_button.recent": "Empregadas acotío",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# voto} other {# votos}}",
   "poll.vote": "Votar",
   "poll.voted": "Votaches por esta opción",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Engadir unha enquisa",
   "poll_button.remove_poll": "Eliminar enquisa",
   "privacy.change": "Axustar privacidade",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describir para persoas con problemas visuais ou auditivos",
   "upload_modal.analyzing_picture": "Estase a analizar a imaxe…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Elixir imaxe",
   "upload_modal.description_placeholder": "Un raposo veloz brinca sobre o can preguiceiro",
   "upload_modal.detect_text": "Detectar texto na imaxe",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index ecc1b9eb1..311323d21 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -9,20 +9,20 @@
   "account.browse_more_on_origin_server": "המשך לגלוש בפרופיל המקורי",
   "account.cancel_follow_request": "בטל בקשת מעקב",
   "account.direct": "Direct Message @{name}",
-  "account.disable_notifications": "Stop notifying me when @{name} posts",
+  "account.disable_notifications": "הפסק לשלוח לי התראות כש@{name} מפרסמים",
   "account.domain_blocked": "הדומיין חסוי",
   "account.edit_profile": "עריכת פרופיל",
-  "account.enable_notifications": "Notify me when @{name} posts",
+  "account.enable_notifications": "שלח לי התראות כש@{name} מפרסמים",
   "account.endorse": "הצג בפרופיל",
   "account.follow": "מעקב",
   "account.followers": "עוקבים",
   "account.followers.empty": "אף אחד לא עוקב אחר המשתמש הזה עדיין.",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
-  "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
+  "account.followers_counter": "{count, plural,one {עוקב אחד} other {{counter} עוקבים}}",
+  "account.following_counter": "{count, plural,one {עוקב אחרי {counter}}other {עוקב אחרי {counter}}}",
   "account.follows.empty": "משתמש זה לא עוקב אחר אף אחד עדיין.",
   "account.follows_you": "במעקב אחריך",
   "account.hide_reblogs": "להסתיר הידהודים מאת @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "הצטרפו ב{date}",
   "account.last_status": "פעילות אחרונה",
   "account.link_verified_on": "בעלות על הקישור הזה נבדקה לאחרונה ב{date}",
   "account.locked_info": "מצב הפרטיות של החשבון הנוכחי הוגדר כנעול. בעל החשבון קובע באופן פרטני מי יכול לעקוב אחריו.",
@@ -47,11 +47,16 @@
   "account.unmute": "הפסקת השתקת @{name}",
   "account.unmute_notifications": "להפסיק הסתרת הודעות מעם @{name}",
   "account_note.placeholder": "ללא הערה",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "נא לנסות אחרי {retry_time, time, medium}.",
   "alert.rate_limited.title": "מגבלות מיכסה",
   "alert.unexpected.message": "אירעה שגיאה בלתי צפויה.",
   "alert.unexpected.title": "אופס!",
   "announcement.announcement": "הודעה",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} לשבוע",
   "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה",
   "bundle_column_error.body": "משהו השתבש בעת הצגת הרכיב הזה.",
@@ -91,12 +96,12 @@
   "compose_form.lock_disclaimer": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.",
   "compose_form.lock_disclaimer.lock": "נעול",
   "compose_form.placeholder": "מה עובר לך בראש?",
-  "compose_form.poll.add_option": "Add a choice",
-  "compose_form.poll.duration": "Poll duration",
-  "compose_form.poll.option_placeholder": "Choice {number}",
-  "compose_form.poll.remove_option": "Remove this choice",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.add_option": "הוסיפו בחירה",
+  "compose_form.poll.duration": "משך הסקר",
+  "compose_form.poll.option_placeholder": "אפשרות מספר {number}",
+  "compose_form.poll.remove_option": "הסר בחירה זו",
+  "compose_form.poll.switch_to_multiple": "אפשרו בחירה מרובה בסקר",
+  "compose_form.poll.switch_to_single": "אפשרו בחירה בודדת בסקר",
   "compose_form.publish": "ללחוש",
   "compose_form.publish_loud": "לחצרץ!",
   "compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
@@ -106,17 +111,19 @@
   "compose_form.spoiler.unmarked": "Text is not hidden",
   "compose_form.spoiler_placeholder": "אזהרת תוכן",
   "confirmation_modal.cancel": "ביטול",
-  "confirmations.block.block_and_report": "Block & Report",
+  "confirmations.block.block_and_report": "לחסום ולדווח",
   "confirmations.block.confirm": "לחסום",
   "confirmations.block.message": "לחסום את {name}?",
   "confirmations.delete.confirm": "למחוק",
   "confirmations.delete.message": "למחוק את ההודעה?",
-  "confirmations.delete_list.confirm": "Delete",
-  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.delete_list.confirm": "למחוק",
+  "confirmations.delete_list.message": "האם אתם בטוחים שאתם רוצים למחוק את הרשימה לצמיתות?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "הסתר קהילה שלמה",
   "confirmations.domain_block.message": "באמת באמת לחסום את כל קהילת {domain}? ברב המקרים השתקות נבחרות של מספר משתמשים מסויימים צריכה להספיק.",
-  "confirmations.logout.confirm": "Log out",
-  "confirmations.logout.message": "Are you sure you want to log out?",
+  "confirmations.logout.confirm": "להתנתק",
+  "confirmations.logout.message": "האם אתם בטוחים שאתם רוצים להתנתק?",
   "confirmations.mute.confirm": "להשתיק",
   "confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
   "confirmations.mute.message": "להשתיק את {name}?",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "שינוי פרטיות ההודעה",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index 0b987d38d..0bcf2a68b 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -47,11 +47,16 @@
   "account.unmute": "अनम्यूट @{name}",
   "account.unmute_notifications": "@{name} के नोटिफिकेशन अनम्यूट करे",
   "account_note.placeholder": "नोट्स जोड़ने के लिए क्लिक करें",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "कृप्या {retry_time, time, medium} के बाद दुबारा कोशिश करें",
   "alert.rate_limited.title": "सीमित दर",
   "alert.unexpected.message": "एक अप्रत्याशित त्रुटि हुई है!",
   "alert.unexpected.title": "उफ़!",
   "announcement.announcement": "घोषणा",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} हर सप्ताह",
   "boost_modal.combo": "अगली बार स्किप करने के लिए आप {combo} दबा सकते है",
   "bundle_column_error.body": "इस कॉम्पोनेन्ट को लोड करते वक्त कुछ गलत हो गया",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "क्या आप वाकई इस स्टेटस को हटाना चाहते हैं?",
   "confirmations.delete_list.confirm": "मिटाए",
   "confirmations.delete_list.message": "क्या आप वाकई इस लिस्ट को हमेशा के लिये मिटाना चाहते हैं?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "संपूर्ण डोमेन छिपाएं",
   "confirmations.domain_block.message": "क्या आप वास्तव में, वास्तव में आप पूरे {domain} को ब्लॉक करना चाहते हैं? ज्यादातर मामलों में कुछ लक्षित ब्लॉक या म्यूट पर्याप्त और बेहतर हैं। आप किसी भी सार्वजनिक समय-सीमा या अपनी सूचनाओं में उस डोमेन की सामग्री नहीं देखेंगे। उस डोमेन से आपके फॉलोवर्स को हटा दिया जाएगा।",
   "confirmations.logout.confirm": "लॉग आउट करें",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "वोट",
   "poll.voted": "आपने इसी उत्तर का चुनाव किया है।",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "लागू करें",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 77da48c90..d11c73860 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -47,11 +47,16 @@
   "account.unmute": "Poništi utišavanje @{name}",
   "account.unmute_notifications": "Ne utišavaj obavijesti od @{name}",
   "account_note.placeholder": "Kliknite za dodavanje bilješke",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Molimo pokušajte nakon {retry_time, time, medium}.",
   "alert.rate_limited.title": "Ograničenje učestalosti",
   "alert.unexpected.message": "Dogodila se neočekivana greška.",
   "alert.unexpected.title": "Ups!",
   "announcement.announcement": "Najava",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} tjedno",
   "boost_modal.combo": "Možete pritisnuti {combo} kako biste preskočili ovo sljedeći put",
   "bundle_column_error.body": "Nešto je pošlo po zlu tijekom učitavanja ove komponente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Stvarno želite obrisati ovaj toot?",
   "confirmations.delete_list.confirm": "Obriši",
   "confirmations.delete_list.message": "Jeste li sigurni da želite trajno obrisati ovu listu?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Blokiraj cijelu domenu",
   "confirmations.domain_block.message": "Jeste li zaista, zaista sigurni da želite blokirati cijelu domenu {domain}? U većini slučajeva dovoljno je i preferirano nekoliko ciljanih blokiranja ili utišavanja. Nećete vidjeti sadržaj s te domene ni u kojim javnim vremenskim crtama ili Vašim obavijestima. Vaši pratitelji s te domene bit će uklonjeni.",
   "confirmations.logout.confirm": "Odjavi se",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# glas} few {# glasa} other {# glasova}}",
   "poll.vote": "Glasaj",
   "poll.voted": "Vi ste glasali za ovaj odgovor",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Dodaj anketu",
   "poll_button.remove_poll": "Ukloni anketu",
   "privacy.change": "Podesi privatnost toota",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Opišite za ljude sa slabim sluhom ili vidom",
   "upload_modal.analyzing_picture": "Analiza slike…",
   "upload_modal.apply": "Primijeni",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Odaberite sliku",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detektiraj tekst sa slike",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 07f8b5f10..db08bfc75 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -1,23 +1,23 @@
 {
-  "account.account_note_header": "Feljegyzés",
-  "account.add_or_remove_from_list": "Hozzáadás vagy eltávolítás a listáról",
+  "account.account_note_header": "Jegyzet",
+  "account.add_or_remove_from_list": "Hozzáadás vagy eltávolítás a listákról",
   "account.badges.bot": "Bot",
   "account.badges.group": "Csoport",
   "account.block": "@{name} letiltása",
   "account.block_domain": "Domain blokkolása: {domain}",
   "account.blocked": "Letiltva",
-  "account.browse_more_on_origin_server": "További böngészés az eredeti profilon",
-  "account.cancel_follow_request": "Követési kérelem törlése",
+  "account.browse_more_on_origin_server": "Böngéssz tovább az eredeti profilon",
+  "account.cancel_follow_request": "Követési kérelem visszavonása",
   "account.direct": "Közvetlen üzenet @{name} számára",
   "account.disable_notifications": "Ne figyelmeztessen, ha @{name} bejegyzést tesz közzé",
-  "account.domain_blocked": "Rejtett domain",
+  "account.domain_blocked": "Letiltott domain",
   "account.edit_profile": "Profil szerkesztése",
   "account.enable_notifications": "Figyelmeztessen, ha @{name} bejegyzést tesz közzé",
   "account.endorse": "Kiemelés a profilodon",
   "account.follow": "Követés",
   "account.followers": "Követő",
   "account.followers.empty": "Ezt a felhasználót még senki sem követi.",
-  "account.followers_counter": "{count, plural, one {{counter} Követő} other {{counter} Követő}}",
+  "account.followers_counter": "{count, plural, one {{counter} követő} other {{counter} követő}}",
   "account.following_counter": "{count, plural, other {{counter} Követett}}",
   "account.follows.empty": "Ez a felhasználó még senkit sem követ.",
   "account.follows_you": "Követ téged",
@@ -47,11 +47,16 @@
   "account.unmute": "@{name} némítás feloldása",
   "account.unmute_notifications": "@{name} némított értesítéseinek feloldása",
   "account_note.placeholder": "Klikk a feljegyzéshez",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Próbáld újra {retry_time, time, medium} után.",
   "alert.rate_limited.title": "Forgalomkorlátozás",
   "alert.unexpected.message": "Váratlan hiba történt.",
   "alert.unexpected.title": "Hoppá!",
   "announcement.announcement": "Közlemény",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} hetente",
   "boost_modal.combo": "Hogy átugord ezt következő alkalommal, használd {combo}",
   "bundle_column_error.body": "Valami hiba történt a komponens betöltése közben.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Biztos, hogy törölni szeretnéd ezt a bejegyzést?",
   "confirmations.delete_list.confirm": "Törlés",
   "confirmations.delete_list.message": "Biztos, hogy véglegesen törölni szeretnéd ezt a listát?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Teljes domain elrejtése",
   "confirmations.domain_block.message": "Biztos, hogy le szeretnéd tiltani a teljes {domain} domaint? A legtöbb esetben néhány célzott tiltás vagy némítás elegendő, és kívánatosabb megoldás. Semmilyen tartalmat nem fogsz látni ebből a domainből se az idővonalakon, se az értesítésekben. Az ebben a domainben lévő követőidet is eltávolítjuk.",
   "confirmations.logout.confirm": "Kijelentkezés",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# szavazat} other {# szavazat}}",
   "poll.vote": "Szavazás",
   "poll.voted": "Erre a válaszra szavaztál",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Új szavazás",
   "poll_button.remove_poll": "Szavazás törlése",
   "privacy.change": "Bejegyzés láthatóságának módosítása",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Írja le a hallás- vagy látássérültek számára",
   "upload_modal.analyzing_picture": "Kép elemzése…",
   "upload_modal.apply": "Alkalmaz",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Kép kiválasztása",
   "upload_modal.description_placeholder": "A gyors, barna róka átugrik a lusta kutya fölött",
   "upload_modal.detect_text": "Szöveg felismerése a képről",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index a3fd51347..e412917f0 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -1,5 +1,5 @@
 {
-  "account.account_note_header": "Գրառում",
+  "account.account_note_header": "Նշում",
   "account.add_or_remove_from_list": "Աւելացնել կամ հեռացնել ցանկերից",
   "account.badges.bot": "Բոտ",
   "account.badges.group": "Խումբ",
@@ -17,13 +17,13 @@
   "account.follow": "Հետեւել",
   "account.followers": "Հետեւողներ",
   "account.followers.empty": "Այս օգտատիրոջը դեռ ոչ մէկ չի հետեւում։",
-  "account.followers_counter": "{count, plural, one {{counter} Հետեւորդ} other {{counter} Հետեւորդներ}}",
-  "account.following_counter": "{count, plural, other {{counter} Հետեւում են}}",
+  "account.followers_counter": "{count, plural, one {{counter} Հետեւորդ} other {{counter} Հետեւորդ}}",
+  "account.following_counter": "{count, plural, one {{counter} հետեւած} other {{counter} հետեւած}}",
   "account.follows.empty": "Այս օգտատէրը դեռ ոչ մէկի չի հետեւում։",
   "account.follows_you": "Հետեւում է քեզ",
   "account.hide_reblogs": "Թաքցնել @{name}֊ի տարածածները",
-  "account.joined": "Joined {date}",
-  "account.last_status": "Վերջին թութը",
+  "account.joined": "Միացել է {date}-ից",
+  "account.last_status": "Վերջին այցը",
   "account.link_verified_on": "Սոյն յղման տիրապետումը ստուգուած է՝ {date}֊ին",
   "account.locked_info": "Սոյն հաշուի գաղտնիութեան մակարդակը նշուած է որպէս՝ փակ։ Հաշուի տէրն ընտրում է, թէ ով կարող է հետեւել իրեն։",
   "account.media": "Մեդիա",
@@ -33,13 +33,13 @@
   "account.mute_notifications": "Անջատել ծանուցումները @{name}֊ից",
   "account.muted": "Լռեցուած",
   "account.never_active": "Երբեք",
-  "account.posts": "Թութ",
-  "account.posts_with_replies": "Թթեր եւ պատասխաններ",
+  "account.posts": "Գրառումներ",
+  "account.posts_with_replies": "Գրառումներ եւ պատասխաններ",
   "account.report": "Բողոքել @{name}֊ի մասին",
   "account.requested": "Հաստատման կարիք ունի։ Սեղմիր՝ հետեւելու հայցը չեղարկելու համար։",
   "account.share": "Կիսուել @{name}֊ի էջով",
   "account.show_reblogs": "Ցուցադրել @{name}֊ի տարածածները",
-  "account.statuses_counter": "{count, plural, one {{counter} Թութ} other {{counter} Թութեր}}",
+  "account.statuses_counter": "{count, plural, one {{counter} Գրառում} other {{counter} Գրառումներ}}",
   "account.unblock": "Ապաարգելափակել @{name}֊ին",
   "account.unblock_domain": "Ցուցադրել {domain} թաքցուած տիրոյթի գրառումները",
   "account.unendorse": "Չցուցադրել անձնական էջում",
@@ -47,11 +47,16 @@
   "account.unmute": "Ապալռեցնել @{name}֊ին",
   "account.unmute_notifications": "Միացնել ծանուցումները @{name}֊ից",
   "account_note.placeholder": "Սեղմէ՛ք գրառելու համար\n",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Փորձէք  որոշ ժամանակ անց՝ {retry_time, time, medium}։",
   "alert.rate_limited.title": "Գործողութիւնների յաճախութիւնը գերազանցում է թոյլատրելին",
   "alert.unexpected.message": "Անսպասելի սխալ տեղի ունեցաւ։",
   "alert.unexpected.title": "Վա՜յ",
   "announcement.announcement": "Յայտարարութիւններ",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "շաբաթը՝ {count}",
   "boost_modal.combo": "Կարող ես սեղմել {combo}՝ սա յաջորդ անգամ բաց թողնելու համար",
   "bundle_column_error.body": "Այս բաղադրիչը բեռնելու ընթացքում ինչ֊որ բան խափանուեց։",
@@ -72,7 +77,7 @@
   "column.lists": "Ցանկեր",
   "column.mutes": "Լռեցրած օգտատէրեր",
   "column.notifications": "Ծանուցումներ",
-  "column.pins": "Ամրացուած թթեր",
+  "column.pins": "Ամրացուած գրառում",
   "column.public": "Դաշնային հոսք",
   "column_back_button.label": "Ետ",
   "column_header.hide_settings": "Թաքցնել կարգաւորումները",
@@ -85,9 +90,9 @@
   "community.column_settings.local_only": "Միայն տեղական",
   "community.column_settings.media_only": "Միայն մեդիա",
   "community.column_settings.remote_only": "Միայն հեռակայ",
-  "compose_form.direct_message_warning": "Այս թութը տեսանելի կը լինի միայն նշուած օգտատէրերին։",
+  "compose_form.direct_message_warning": "Այս գրառումը տեսանելի կը լինի միայն նշուած օգտատէրերին։",
   "compose_form.direct_message_warning_learn_more": "Իմանալ աւելին",
-  "compose_form.hashtag_warning": "Այս թութը չի հաշուառուի որեւէ պիտակի տակ, քանզի այն ծածուկ է։ Միայն հրապարակային թթերը հնարաւոր է որոնել պիտակներով։",
+  "compose_form.hashtag_warning": "Այս գրառումը չի հաշուառուի որեւէ պիտակի տակ, քանզի այն ծածուկ է։ Միայն հրապարակային թթերը հնարաւոր է որոնել պիտակներով։",
   "compose_form.lock_disclaimer": "Քո հաշիւը {locked} չէ։ Իւրաքանչիւրութիւն ոք կարող է հետեւել քեզ եւ տեսնել միայն հետեւողների համար նախատեսուած գրառումները։",
   "compose_form.lock_disclaimer.lock": "փակ",
   "compose_form.placeholder": "Ի՞նչ կայ մտքիդ",
@@ -97,8 +102,8 @@
   "compose_form.poll.remove_option": "Հեռացնել այս տարբերակը",
   "compose_form.poll.switch_to_multiple": "Հարցումը դարձնել բազմակի ընտրութեամբ",
   "compose_form.poll.switch_to_single": "Հարցումը դարձնել եզակի ընտրութեամբ",
-  "compose_form.publish": "Թթել",
-  "compose_form.publish_loud": "Թթե՜լ",
+  "compose_form.publish": "Հրապարակել",
+  "compose_form.publish_loud": "Հրապարակե՜լ",
   "compose_form.sensitive.hide": "Նշել մեդիան որպէս դիւրազգաց",
   "compose_form.sensitive.marked": "Մեդիան նշուած է որպէս դիւրազգաց",
   "compose_form.sensitive.unmarked": "Մեդիան նշուած չէ որպէս դիւրազգաց",
@@ -110,9 +115,11 @@
   "confirmations.block.confirm": "Արգելափակել",
   "confirmations.block.message": "Վստա՞հ ես, որ ուզում ես արգելափակել {name}֊ին։",
   "confirmations.delete.confirm": "Ջնջել",
-  "confirmations.delete.message": "Վստա՞հ ես, որ ուզում ես ջնջել այս թութը։",
+  "confirmations.delete.message": "Վստա՞հ ես, որ ուզում ես ջնջել այս գրառումը։",
   "confirmations.delete_list.confirm": "Ջնջել",
   "confirmations.delete_list.message": "Վստա՞հ ես, որ ուզում ես մշտապէս ջնջել այս ցանկը։",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Թաքցնել ամբողջ տիրույթը",
   "confirmations.domain_block.message": "Հաստատ֊հաստա՞տ վստահ ես, որ ուզում ես արգելափակել ամբողջ {domain} տիրոյթը։ Սովորաբար մի երկու թիրախաւորուած արգելափակում կամ լռեցում բաւական է ու նախընտրելի։",
   "confirmations.logout.confirm": "Ելք",
@@ -121,7 +128,7 @@
   "confirmations.mute.explanation": "Սա թաքցնելու ա իրենց գրառումներն, ինչպէս նաեւ իրենց նշող գրառումներն, բայց իրենք միեւնոյն է կը կարողանան հետեւել ձեզ եւ տեսնել ձեր գրառումները։",
   "confirmations.mute.message": "Վստա՞հ ես, որ ուզում ես {name}֊ին լռեցնել։",
   "confirmations.redraft.confirm": "Ջնջել եւ խմբագրել նորից",
-  "confirmations.redraft.message": "Վստահ ե՞ս, որ ցանկանում ես ջնջել եւ վերախմբագրել այս թութը։ Դու կը կորցնես այս գրառման բոլոր պատասխանները, տարածումները եւ հաւանումները։",
+  "confirmations.redraft.message": "Վստահ ե՞ս, որ ցանկանում ես ջնջել եւ վերախմբագրել այս գրառումը։ Դու կը կորցնես այս գրառման բոլոր պատասխանները, տարածումները եւ հաւանումները։",
   "confirmations.reply.confirm": "Պատասխանել",
   "confirmations.reply.message": "Այս պահին պատասխանելը կը չեղարկի ձեր՝ այս պահին անաւարտ հաղորդագրութիւնը։ Համոզուա՞ծ էք։",
   "confirmations.unfollow.confirm": "Ապահետեւել",
@@ -134,8 +141,8 @@
   "directory.local": "{domain} տիրոյթից միայն",
   "directory.new_arrivals": "Նորեկներ",
   "directory.recently_active": "Վերջերս ակտիւ",
-  "embed.instructions": "Այս թութը քո կայքում ներդնելու համար կարող ես պատճէնել ներքինանալ կոդը։",
-  "embed.preview": "Ահայ, թէ ինչ տեսք կը ունենայ այն՝",
+  "embed.instructions": "Այս գրառումը քո կայքում ներդնելու համար կարող ես պատճէնել ներքեւի կոդը։",
+  "embed.preview": "Ահա, թէ ինչ տեսք կը ունենայ այն՝",
   "emoji_button.activity": "Զբաղմունքներ",
   "emoji_button.custom": "Յատուկ",
   "emoji_button.flags": "Դրօշներ",
@@ -151,21 +158,21 @@
   "emoji_button.symbols": "Նշաններ",
   "emoji_button.travel": "Ուղեւորութիւն եւ տեղանքներ",
   "empty_column.account_suspended": "Հաշիւը արգելափակուած է",
-  "empty_column.account_timeline": "Այստեղ թթեր չկա՛ն։",
+  "empty_column.account_timeline": "Այստեղ գրառումներ չկա՛ն։",
   "empty_column.account_unavailable": "Անձնական էջը հասանելի չի",
   "empty_column.blocks": "Դու դեռ ոչ մէկի չես արգելափակել։",
-  "empty_column.bookmarked_statuses": "Դու դեռ չունես որեւէ էջանշւած թութ։ Երբ էջանշես, դրանք կը երեւան այստեղ։",
+  "empty_column.bookmarked_statuses": "Դու դեռ չունես որեւէ էջանշուած գրառում։ Երբ էջանշես, դրանք կը երեւան այստեղ։",
   "empty_column.community": "Տեղական հոսքը դատարկ է։ Հրապարակային մի բան գրի՛ր շարժիչը գործարկելու համար։",
   "empty_column.direct": "Դու դեռ չունես ոչ մի հասցէագրուած հաղորդագրութիւն։ Երբ ուղարկես կամ ստանաս որեւէ անձնական նամակ, այն այստեղ կերեւայ։",
   "empty_column.domain_blocks": "Թաքցուած տիրոյթներ դեռ չկան։",
-  "empty_column.favourited_statuses": "Դու դեռ չունես որեւէ հաւանած թութ։ Երբ հաւանես, դրանք կերեւան այստեղ։",
-  "empty_column.favourites": "Այս թութը ոչ մէկ դեռ չի հաւանել։ Հաւանողները կերեւան այստեղ, երբ նշեն թութը հաւանած։",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.favourited_statuses": "Դու դեռ չունես որեւէ հաւանած գրառում։ Երբ հաւանես, դրանք կերեւան այստեղ։",
+  "empty_column.favourites": "Այս գրառումը ոչ մէկ դեռ չի հաւանել։ Հաւանողները կերեւան այստեղ, երբ հաւանեն։",
+  "empty_column.follow_recommendations": "Կարծես քեզ համար ոչ մի առաջարկ չի գեներացուել։ Օգտագործիր որոնման դաշտը մարդկանց փնտրելու համար կամ բացայայտիր յայտնի պիտակներով։",
   "empty_column.follow_requests": "Դու դեռ չունես որեւէ հետեւելու յայտ։ Բոլոր նման յայտերը կը յայտնուեն այստեղ։",
   "empty_column.hashtag": "Այս պիտակով դեռ ոչինչ չկայ։",
   "empty_column.home": "Քո հիմնական հոսքը դատարկ է։ Այցելի՛ր {public}ը կամ օգտուիր որոնումից՝ այլ մարդկանց հանդիպելու համար։",
-  "empty_column.home.suggestions": "See some suggestions",
-  "empty_column.list": "Այս ցանկում դեռ ոչինչ չկայ։ Երբ ցանկի անդամներից որեւէ մեկը նոր թութ գրի, այն կը յայտնուի այստեղ։",
+  "empty_column.home.suggestions": "Տեսնել որոշ առաջարկներ",
+  "empty_column.list": "Այս ցանկում դեռ ոչինչ չկայ։ Երբ ցանկի անդամներից որեւէ մէկը նոր գրառում անի, այն կը յայտնուի այստեղ։",
   "empty_column.lists": "Դուք դեռ չունէք ստեղծած ցանկ։ Ցանկ ստեղծելուն պէս այն կը յայտնուի այստեղ։",
   "empty_column.mutes": "Առայժմ ոչ ոքի չէք լռեցրել։",
   "empty_column.notifications": "Ոչ մի ծանուցում դեռ չունես։ Բզիր միւսներին՝ խօսակցութիւնը սկսելու համար։",
@@ -176,9 +183,9 @@
   "error.unexpected_crash.next_steps_addons": "Փորձիր անջատել յաւելուածները եւ թարմացնել էջը։ Եթե դա չօգնի, կարող ես օգտուել Մաստադոնից այլ դիտարկիչով կամ յաւելուածով։",
   "errors.unexpected_crash.copy_stacktrace": "Պատճենել սթաքթրեյսը սեղմատախտակին",
   "errors.unexpected_crash.report_issue": "Զեկուցել խնդրի մասին",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "follow_recommendations.done": "Աւարտուած է",
+  "follow_recommendations.heading": "Հետեւիր այն մարդկանց, որոնց գրառումները կը ցանկանաս տեսնել։ Ահա մի քանի առաջարկ։",
+  "follow_recommendations.lead": "Քո հոսքում, ժամանակագրական դասաւորութեամբ կը տեսնես այն մարդկանց գրառումները, որոնց հետեւում ես։ Մի վախեցիր սխալուել, դու միշտ կարող ես հեշտութեամբ ապահետեւել մարդկանց։",
   "follow_request.authorize": "Վաւերացնել",
   "follow_request.reject": "Մերժել",
   "follow_requests.unlocked_explanation": "Այս հարցումը ուղարկուած է հաշուից, որի համար {domain}-ի անձնակազմը միացրել է ձեռքով ստուգում։",
@@ -216,7 +223,7 @@
   "keyboard_shortcuts.description": "Նկարագրութիւն",
   "keyboard_shortcuts.direct": "հասցէագրուած գրուածքների հոսքը բացելու համար",
   "keyboard_shortcuts.down": "ցանկով ներքեւ շարժուելու համար",
-  "keyboard_shortcuts.enter": "թութը բացելու համար",
+  "keyboard_shortcuts.enter": "Գրառումը բացելու համար",
   "keyboard_shortcuts.favourite": "հաւանելու համար",
   "keyboard_shortcuts.favourites": "էջանիշերի ցուցակը բացելու համար",
   "keyboard_shortcuts.federated": "դաշնային հոսքին անցնելու համար",
@@ -230,7 +237,7 @@
   "keyboard_shortcuts.my_profile": "սեփական էջին անցնելու համար",
   "keyboard_shortcuts.notifications": "ծանուցումների սիւնակը բացելու համար",
   "keyboard_shortcuts.open_media": "ցուցադրել մեդիան",
-  "keyboard_shortcuts.pinned": "ամրացուած թթերի ցանկը բացելու համար",
+  "keyboard_shortcuts.pinned": "Բացել ամրացուած գրառումների ցանկը",
   "keyboard_shortcuts.profile": "հեղինակի անձնական էջը բացելու համար",
   "keyboard_shortcuts.reply": "պատասխանելու համար",
   "keyboard_shortcuts.requests": "հետեւելու հայցերի ցանկը դիտելու համար",
@@ -239,7 +246,7 @@
   "keyboard_shortcuts.start": "«սկսել» սիւնակը բացելու համար",
   "keyboard_shortcuts.toggle_hidden": "CW֊ի ետեւի տեքստը ցուցադրել֊թաքցնելու համար",
   "keyboard_shortcuts.toggle_sensitivity": "մեդիան ցուցադրել֊թաքցնելու համար",
-  "keyboard_shortcuts.toot": "թարմ թութ սկսելու համար",
+  "keyboard_shortcuts.toot": "Նոր գրառում անելու համար",
   "keyboard_shortcuts.unfocus": "տեքստի/որոնման տիրոյթից ապասեւեռուելու համար",
   "keyboard_shortcuts.up": "ցանկով վերեւ շարժուելու համար",
   "lightbox.close": "Փակել",
@@ -266,13 +273,13 @@
   "missing_indicator.label": "Չգտնուեց",
   "missing_indicator.sublabel": "Պաշարը չի գտնւում",
   "mute_modal.duration": "Տեւողութիւն",
-  "mute_modal.hide_notifications": "Թաքցնե՞լ ցանուցումներն այս օգտատիրոջից։",
+  "mute_modal.hide_notifications": "Թաքցնե՞լ ծանուցումներն այս օգտատիրոջից։",
   "mute_modal.indefinite": "Անժամկէտ",
   "navigation_bar.apps": "Դիւրակիր յաւելուածներ",
   "navigation_bar.blocks": "Արգելափակուած օգտատէրեր",
   "navigation_bar.bookmarks": "Էջանիշեր",
   "navigation_bar.community_timeline": "Տեղական հոսք",
-  "navigation_bar.compose": "Գրել նոր թութ",
+  "navigation_bar.compose": "Ստեղծել նոր գրառում",
   "navigation_bar.direct": "Հասցէագրուած",
   "navigation_bar.discover": "Բացայայտել",
   "navigation_bar.domain_blocks": "Թաքցուած տիրոյթներ",
@@ -287,18 +294,18 @@
   "navigation_bar.logout": "Դուրս գալ",
   "navigation_bar.mutes": "Լռեցրած օգտատէրեր",
   "navigation_bar.personal": "Անձնական",
-  "navigation_bar.pins": "Ամրացուած թթեր",
+  "navigation_bar.pins": "Ամրացուած գրառումներ",
   "navigation_bar.preferences": "Նախապատուութիւններ",
   "navigation_bar.public_timeline": "Դաշնային հոսք",
   "navigation_bar.security": "Անվտանգութիւն",
-  "notification.favourite": "{name} հաւանեց թութդ",
+  "notification.favourite": "{name} հաւանեց գրառումդ",
   "notification.follow": "{name} սկսեց հետեւել քեզ",
   "notification.follow_request": "{name} քեզ հետեւելու հայց է ուղարկել",
   "notification.mention": "{name} նշեց քեզ",
   "notification.own_poll": "Հարցումդ աւարտուեց",
   "notification.poll": "Հարցումը, ուր դու քուէարկել ես, աւարտուեց։",
-  "notification.reblog": "{name} տարածեց թութդ",
-  "notification.status": "{name} հենց նոր թթեց",
+  "notification.reblog": "{name} տարածեց գրառումդ",
+  "notification.status": "{name} հենց նոր գրառում արեց",
   "notifications.clear": "Մաքրել ծանուցումները",
   "notifications.clear_confirmation": "Վստա՞հ ես, որ ուզում ես մշտապէս մաքրել քո բոլոր ծանուցումները։",
   "notifications.column_settings.alert": "Աշխատատիրոյթի ծանուցումներ",
@@ -314,8 +321,8 @@
   "notifications.column_settings.reblog": "Տարածածներից՝",
   "notifications.column_settings.show": "Ցուցադրել սիւնում",
   "notifications.column_settings.sound": "Ձայն հանել",
-  "notifications.column_settings.status": "Նոր թթեր։",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.status": "Նոր գրառումներ։",
+  "notifications.column_settings.unread_markers.category": "Չկարդացուած ծանուցումների նշաններ",
   "notifications.filter.all": "Բոլորը",
   "notifications.filter.boosts": "Տարածածները",
   "notifications.filter.favourites": "Հաւանածները",
@@ -339,16 +346,17 @@
   "poll.total_votes": "{count, plural, one {# ձայն} other {# ձայն}}",
   "poll.vote": "Քուէարկել",
   "poll.voted": "Դու քուէարկել ես այս տարբերակի համար",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Աւելացնել հարցում",
   "poll_button.remove_poll": "Հեռացնել հարցումը",
-  "privacy.change": "Կարգաւորել թթի գաղտնիութիւնը",
-  "privacy.direct.long": "Թթել միայն նշուած օգտատէրերի համար",
+  "privacy.change": "Կարգաւորել գրառման գաղտնիութիւնը",
+  "privacy.direct.long": "Կը տեսնեն միայն նշուած օգտատէրերը",
   "privacy.direct.short": "Հասցէագրուած",
-  "privacy.private.long": "Թթել միայն հետեւողների համար",
+  "privacy.private.long": "Կը տեսնեն միայն հետեւորդները",
   "privacy.private.short": "Միայն հետեւողներին",
-  "privacy.public.long": "Թթել հրապարակային հոսքերում",
+  "privacy.public.long": "Կը տեսնեն բոլոր,  հրապարակային հոսքում",
   "privacy.public.short": "Հրապարակային",
-  "privacy.unlisted.long": "Չթթել հրապարակային հոսքերում",
+  "privacy.unlisted.long": "Կը տեսնեն բոլոր,  բայց ոչ հրապարակային հոսքում",
   "privacy.unlisted.short": "Ծածուկ",
   "refresh": "Թարմացնել",
   "regeneration_indicator.label": "Բեռնւում է…",
@@ -370,20 +378,20 @@
   "search_popout.search_format": "Փնտրելու առաջադէմ ձեւ",
   "search_popout.tips.full_text": "Պարզ տեքստը վերադարձնում է գրառումներդ, հաւանածներդ, տարածածներդ, որտեղ ես նշուած եղել, ինչպէս նաեւ նման օգտանուններ, անուններ եւ պիտակներ։",
   "search_popout.tips.hashtag": "պիտակ",
-  "search_popout.tips.status": "թութ",
+  "search_popout.tips.status": "գրառում",
   "search_popout.tips.text": "Հասարակ տեքստը կը վերադարձնի համընկնող անուններ, օգտանուններ ու պիտակներ",
   "search_popout.tips.user": "օգտատէր",
   "search_results.accounts": "Մարդիկ",
   "search_results.hashtags": "Պիտակներ",
-  "search_results.statuses": "Թթեր",
-  "search_results.statuses_fts_disabled": "Այս հանգոյցում միացուած չէ ըստ բովանդակութեան թթեր փնտրելու հնարաւորութիւնը։",
+  "search_results.statuses": "Գրառումներ",
+  "search_results.statuses_fts_disabled": "Այս հանգոյցում միացուած չէ ըստ բովանդակութեան գրառում փնտրելու հնարաւորութիւնը։",
   "search_results.total": "{count, number} {count, plural, one {արդիւնք} other {արդիւնք}}",
   "status.admin_account": "Բացել @{name} օգտատիրոջ մոդերացիայի դիմերէսը։",
   "status.admin_status": "Բացել այս գրառումը մոդերատորի դիմերէսի մէջ",
   "status.block": "Արգելափակել @{name}֊ին",
   "status.bookmark": "Էջանիշ",
   "status.cancel_reblog_private": "Ապատարածել",
-  "status.cannot_reblog": "Այս թութը չի կարող տարածուել",
+  "status.cannot_reblog": "Այս գրառումը չի կարող տարածուել",
   "status.copy": "Պատճէնել գրառման յղումը",
   "status.delete": "Ջնջել",
   "status.detailed_status": "Շղթայի ընդլայնուած դիտում",
@@ -397,14 +405,14 @@
   "status.more": "Աւելին",
   "status.mute": "Լռեցնել @{name}֊ին",
   "status.mute_conversation": "Լռեցնել խօսակցութիւնը",
-  "status.open": "Ընդարձակել այս թութը",
+  "status.open": "Ընդարձակել այս գրառումը",
   "status.pin": "Ամրացնել անձնական էջում",
-  "status.pinned": "Ամրացուած թութ",
+  "status.pinned": "Ամրացուած գրառում",
   "status.read_more": "Կարդալ աւելին",
   "status.reblog": "Տարածել",
   "status.reblog_private": "Տարածել սեփական լսարանին",
   "status.reblogged_by": "{name} տարածել է",
-  "status.reblogs.empty": "Այս թութը ոչ մէկ դեռ չի տարածել։ Տարածողները կերեւան այստեղ, երբ որեւէ մէկը տարածի։",
+  "status.reblogs.empty": "Այս գրառումը ոչ մէկ դեռ չի տարածել։ Տարածողները կերեւան այստեղ, երբ տարածեն։",
   "status.redraft": "Ջնջել եւ վերակազմել",
   "status.remove_bookmark": "Հեռացնել էջանիշերից",
   "status.reply": "Պատասխանել",
@@ -435,7 +443,7 @@
   "timeline_hint.remote_resource_not_displayed": "{resource} այլ սպասարկիչներից չեն ցուցադրվել:",
   "timeline_hint.resources.followers": "Հետևորդներ",
   "timeline_hint.resources.follows": "Հետեւել",
-  "timeline_hint.resources.statuses": "Հին թութեր",
+  "timeline_hint.resources.statuses": "Հին գրառումներ",
   "trends.counter_by_accounts": "{count, plural, one {{counter} մարդ} other {{counter} մարդիկ}} խօսում են",
   "trends.trending_now": "Այժմ արդիական",
   "ui.beforeunload": "Քո սեւագիրը կը կորի, եթէ լքես Մաստոդոնը։",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Նկարագրիր տեսանիւթը լսողական կամ տեսողական խնդիրներով անձանց համար",
   "upload_modal.analyzing_picture": "Լուսանկարի վերլուծում…",
   "upload_modal.apply": "Կիրառել",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Ընտրել նկար",
   "upload_modal.description_placeholder": "Բել դղեակի ձախ ժամն օֆ ազգութեանը ցպահանջ չճշտած վնաս էր եւ փառք։",
   "upload_modal.detect_text": "Յայտնաբերել տեքստը նկարից",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index ef76f295b..a664aff56 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -4,19 +4,19 @@
   "account.badges.bot": "Bot",
   "account.badges.group": "Grup",
   "account.block": "Blokir @{name}",
-  "account.block_domain": "Sembunyikan segalanya dari {domain}",
+  "account.block_domain": "Blokir domain {domain}",
   "account.blocked": "Terblokir",
   "account.browse_more_on_origin_server": "Lihat lebih lanjut diprofil asli",
   "account.cancel_follow_request": "Batalkan permintaan ikuti",
-  "account.direct": "Direct Message @{name}",
+  "account.direct": "Pesan Langsung @{name}",
   "account.disable_notifications": "Berhenti memberitahu saya ketika @{name} memposting",
-  "account.domain_blocked": "Domain disembunyikan",
+  "account.domain_blocked": "Domain diblokir",
   "account.edit_profile": "Ubah profil",
   "account.enable_notifications": "Beritahu saya saat @{name} memposting",
   "account.endorse": "Tampilkan di profil",
   "account.follow": "Ikuti",
   "account.followers": "Pengikut",
-  "account.followers.empty": "Tidak ada satupun yang mengkuti pengguna ini saat ini.",
+  "account.followers.empty": "Pengguna ini belum ada pengikut.",
   "account.followers_counter": "{count, plural, other {{counter} Pengikut}}",
   "account.following_counter": "{count, plural, other {{counter} Mengikuti}}",
   "account.follows.empty": "Pengguna ini belum mengikuti siapapun.",
@@ -25,15 +25,15 @@
   "account.joined": "Bergabung {date}",
   "account.last_status": "Terakhir aktif",
   "account.link_verified_on": "Kepemilikan tautan ini telah dicek pada {date}",
-  "account.locked_info": "Status privasi akun ini disetel untuk dikunci. Pemilik secara manual meninjau siapa yang dapat mengikuti mereka.",
+  "account.locked_info": "Status privasi akun ini disetel untuk dikunci. Pemilik secara manual meninjau siapa yang dapat mengikutinya.",
   "account.media": "Media",
   "account.mention": "Balasan @{name}",
   "account.moved_to": "{name} telah pindah ke:",
   "account.mute": "Bisukan @{name}",
-  "account.mute_notifications": "Sembunyikan notifikasi dari @{name}",
+  "account.mute_notifications": "Bisukan pemberitahuan dari @{name}",
   "account.muted": "Dibisukan",
   "account.never_active": "Tak pernah",
-  "account.posts": "Toot",
+  "account.posts": "Kiriman",
   "account.posts_with_replies": "Postingan dengan balasan",
   "account.report": "Laporkan @{name}",
   "account.requested": "Menunggu persetujuan. Klik untuk membatalkan permintaan",
@@ -45,13 +45,18 @@
   "account.unendorse": "Jangan tampilkan di profil",
   "account.unfollow": "Berhenti mengikuti",
   "account.unmute": "Berhenti membisukan @{name}",
-  "account.unmute_notifications": "Munculkan notifikasi dari @{name}",
+  "account.unmute_notifications": "Berhenti bisukan pemberitahuan dari @{name}",
   "account_note.placeholder": "Klik untuk menambah catatan",
-  "alert.rate_limited.message": "Tolong ulangi setelah {retry_time, time, medium}.",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
+  "alert.rate_limited.message": "Mohon ulangi setelah {retry_time, time, medium}.",
   "alert.rate_limited.title": "Batasan tingkat",
   "alert.unexpected.message": "Terjadi kesalahan yang tidak terduga.",
   "alert.unexpected.title": "Ups!",
   "announcement.announcement": "Pengumuman",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per minggu",
   "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini",
   "bundle_column_error.body": "Kesalahan terjadi saat memuat komponen ini.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Apa anda yakin untuk menghapus status ini?",
   "confirmations.delete_list.confirm": "Hapus",
   "confirmations.delete_list.message": "Apakah anda yakin untuk menghapus daftar ini secara permanen?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Sembunyikan keseluruhan domain",
   "confirmations.domain_block.message": "Apakah anda benar benar yakin untuk memblokir keseluruhan {domain}? Dalam kasus tertentu beberapa pemblokiran atau penyembunyian lebih baik.",
   "confirmations.logout.confirm": "Keluar",
@@ -282,7 +289,7 @@
   "navigation_bar.follow_requests": "Permintaan mengikuti",
   "navigation_bar.follows_and_followers": "Ikuti dan pengikut",
   "navigation_bar.info": "Informasi selengkapnya",
-  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.keyboard_shortcuts": "Pintasan keyboard",
   "navigation_bar.lists": "Daftar",
   "navigation_bar.logout": "Keluar",
   "navigation_bar.mutes": "Pengguna dibisukan",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, other {# suara}}",
   "poll.vote": "Memilih",
   "poll.voted": "Anda memilih jawaban ini",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Tambah japat",
   "poll_button.remove_poll": "Hapus japat",
   "privacy.change": "Tentukan privasi status",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Penjelasan untuk orang dengan gangguan pendengaran atau penglihatan",
   "upload_modal.analyzing_picture": "Analisis gambar…",
   "upload_modal.apply": "Terapkan",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Pilih gambar",
   "upload_modal.description_placeholder": "Muharjo seorang xenofobia universal yang takut pada warga jazirah, contohnya Qatar",
   "upload_modal.detect_text": "Deteksi teks pada gambar",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index dd034dbdc..a0c7c4912 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -47,11 +47,16 @@
   "account.unmute": "Ne plus celar @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Aranjar privateso di mesaji",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json
index 427c75d18..9aeca30ac 100644
--- a/app/javascript/mastodon/locales/is.json
+++ b/app/javascript/mastodon/locales/is.json
@@ -1,15 +1,15 @@
 {
   "account.account_note_header": "Minnispunktur",
-  "account.add_or_remove_from_list": "Bæta á eða fjarlægja af listum",
-  "account.badges.bot": "Róbót",
+  "account.add_or_remove_from_list": "Bæta við eða fjarlægja af listum",
+  "account.badges.bot": "Þjarkur",
   "account.badges.group": "Hópur",
-  "account.block": "Útiloka @{name}",
+  "account.block": "Loka á @{name}",
   "account.block_domain": "Fela allt frá {domain}",
-  "account.blocked": "Útilokaður",
+  "account.blocked": "Lokað á",
   "account.browse_more_on_origin_server": "Skoða nánari upplýsingar á notandasniðinu",
-  "account.cancel_follow_request": "Hætta við beiðni um að fylgjast með",
+  "account.cancel_follow_request": "Hætta við beiðni um að fylgjas",
   "account.direct": "Bein skilaboð til @{name}",
-  "account.disable_notifications": "Hætta að láta mig vita þegar @{name} sendir inn",
+  "account.disable_notifications": "Hættu að láta mig vita þegar @{name} þýtur",
   "account.domain_blocked": "Lén falið",
   "account.edit_profile": "Breyta notandasniði",
   "account.enable_notifications": "Láta mig vita þegar @{name} sendir inn",
@@ -33,8 +33,8 @@
   "account.mute_notifications": "Þagga tilkynningar frá @{name}",
   "account.muted": "Þaggað",
   "account.never_active": "Aldrei",
-  "account.posts": "Tíst",
-  "account.posts_with_replies": "Tíst og svör",
+  "account.posts": "Þyt",
+  "account.posts_with_replies": "Þyt og svör",
   "account.report": "Kæra @{name}",
   "account.requested": "Bíður eftir samþykki. Smelltu til að hætta við beiðni um að fylgjast með",
   "account.share": "Deila notandasniði fyrir @{name}",
@@ -47,11 +47,16 @@
   "account.unmute": "Hætta að þagga niður í @{name}",
   "account.unmute_notifications": "Hætta að þagga tilkynningar frá @{name}",
   "account_note.placeholder": "Engin athugasemd gefin",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Prófaðu aftur eftir {retry_time, time, medium}.",
   "alert.rate_limited.title": "Með takmörkum",
   "alert.unexpected.message": "Upp kom óvænt villa.",
   "alert.unexpected.title": "Úbbs!",
   "announcement.announcement": "Auglýsing",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} á viku",
   "boost_modal.combo": "Þú getur ýtt á {combo} til að sleppa þessu næst",
   "bundle_column_error.body": "Eitthvað fór úrskeiðis við að hlaða inn þessari einingu.",
@@ -66,7 +71,7 @@
   "column.direct": "Bein skilaboð",
   "column.directory": "Skoða notandasnið",
   "column.domain_blocks": "Falin lén",
-  "column.favourites": "Eftirlæti",
+  "column.favourites": "Fílanir",
   "column.follow_requests": "Fylgja beiðnum",
   "column.home": "Heim",
   "column.lists": "Listar",
@@ -97,7 +102,7 @@
   "compose_form.poll.remove_option": "Fjarlægja þennan valkost",
   "compose_form.poll.switch_to_multiple": "Breyta könnun svo hægt sé að hafa marga valkosti",
   "compose_form.poll.switch_to_single": "Breyta könnun svo hægt sé að hafa einn stakan valkost",
-  "compose_form.publish": "Tíst",
+  "compose_form.publish": "Þyt",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Merkja myndir sem viðkvæmar",
   "compose_form.sensitive.marked": "Mynd er merkt sem viðkvæm",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Ertu viss um að þú viljir eyða þessari stöðufærslu?",
   "confirmations.delete_list.confirm": "Eyða",
   "confirmations.delete_list.message": "Ertu viss um að þú viljir eyða þessum lista endanlega?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Fela allt lénið",
   "confirmations.domain_block.message": "Ertu alveg algjörlega viss um að þú viljir loka á allt {domain}? Í flestum tilfellum er vænlegra að nota færri en markvissari útilokanir eða að þagga niður tiltekna aðila. Þú munt ekki sjá efni frá þessu léni í neinum opinberum tímalínum eða í tilkynningunum þínum. Fylgjendur þínir frá þessu léni verða fjarlægðir.",
   "confirmations.logout.confirm": "Skrá út",
@@ -121,7 +128,7 @@
   "confirmations.mute.explanation": "Þetta mun fela færslur frá þeim og þær færslur þar sem minnst er á þau, en það mun samt sem áður gera þeim kleift að sjá færslurnar þínar og að fylgjast með þér.",
   "confirmations.mute.message": "Ertu viss um að þú viljir þagga niður í {name}?",
   "confirmations.redraft.confirm": "Eyða og enduvinna drög",
-  "confirmations.redraft.message": "Ertu viss um að þú viljir eyða þessari stöðufærslu og enduvinna drögin? Eftirlæti og endurbirtingar munu tapast og svör við upprunalegu fæerslunni munu verða munaðarlaus.",
+  "confirmations.redraft.message": "Ertu viss um að þú viljir eyða þessari stöðufærslu og enduvinna drögin? Fílanir og endurbirtingar munu glatast og svör við upprunalegu fæerslunni munu verða munaðarlaus.",
   "confirmations.reply.confirm": "Svara",
   "confirmations.reply.message": "Ef þú svarar núna verður skrifað yfir skilaboðin sem þú ert að semja núna. Ertu viss um að þú viljir halda áfram?",
   "confirmations.unfollow.confirm": "Hætta að fylgja",
@@ -151,15 +158,15 @@
   "emoji_button.symbols": "Tákn",
   "emoji_button.travel": "Ferðalög og staðir",
   "empty_column.account_suspended": "Notandaaðgangur í bið",
-  "empty_column.account_timeline": "Engin tíst hér!",
+  "empty_column.account_timeline": "Engin þyt hér!",
   "empty_column.account_unavailable": "Notandasnið ekki tiltækt",
   "empty_column.blocks": "Þú hefur ekki ennþá útilokað neina notendur.",
-  "empty_column.bookmarked_statuses": "Þú ert ekki ennþá með nein bókamerkt tíst. Þegar þú gefur tísti bókamerki, munu það birtast hér.",
+  "empty_column.bookmarked_statuses": "Þú ert ekki ennþá með nein bókamerkt þyt. Þegar þú gefur þyti bókamerki, mun það birtast hér.",
   "empty_column.community": "Staðværa tímalínan er tóm. Skrifaðu eitthvað opinberlega til að láta boltann fara að rúlla!",
   "empty_column.direct": "Þú átt ennþá engin bein skilaboð. Þegar þú sendir eða tekur á móti slíkum skilaboðum, munu þau birtast hér.",
   "empty_column.domain_blocks": "Það eru engin falin lén ennþá.",
-  "empty_column.favourited_statuses": "Þú átt ennþá engin eftirlætistíst. Þegar þú setur tíst í eftirlæti, munu þau birtast hér.",
-  "empty_column.favourites": "Enginn hefur ennþá set þetta tíst í eftirlæti. Þegar einhverjir gera það, munu þeir birtast hér.",
+  "empty_column.favourited_statuses": "Þú hefur ekki fílað nein þyt. Þegar að þú fílar þyt, þá mun það birtast hér.",
+  "empty_column.favourites": "Enginn hefu fílað þetta þyt ennþá. Þegar einhver gerir það, mun sá birtast hér.",
   "empty_column.follow_recommendations": "Það lítur út fyrir að ekki hafi verið hægt að útbúa neinar tillögur fyrir þig. Þú getur reynt að leita að fólki sem þú gætir þekkt eða skoðað myllumerki sem eru í umræðunni.",
   "empty_column.follow_requests": "Þú átt ennþá engar beiðnir um að fylgja þér. Þegar þú færð slíkar beiðnir, munu þær birtast hér.",
   "empty_column.hashtag": "Það er ekkert ennþá undir þessu myllumerki.",
@@ -217,8 +224,8 @@
   "keyboard_shortcuts.direct": "að opna dálk með beinum skilaboðum",
   "keyboard_shortcuts.down": "að fara neðar í listanum",
   "keyboard_shortcuts.enter": "að opna stöðufærslu",
-  "keyboard_shortcuts.favourite": "að setja í eftirlæti",
-  "keyboard_shortcuts.favourites": "að opna eftirlætislista",
+  "keyboard_shortcuts.favourite": "Fíla þyt",
+  "keyboard_shortcuts.favourites": "Opna fílanir",
   "keyboard_shortcuts.federated": "að opna sameiginlega tímalínu",
   "keyboard_shortcuts.heading": "Flýtileiðir á lyklaborði",
   "keyboard_shortcuts.home": "að opna heimatímalínu",
@@ -230,7 +237,7 @@
   "keyboard_shortcuts.my_profile": "að opna notandasniðið þitt",
   "keyboard_shortcuts.notifications": "að opna tilkynningadálk",
   "keyboard_shortcuts.open_media": "til að opna margmiðlunargögn",
-  "keyboard_shortcuts.pinned": "að opna lista yfir föst tíst",
+  "keyboard_shortcuts.pinned": "Opna lista yfir föst þyt",
   "keyboard_shortcuts.profile": "að opna notandasnið höfundar",
   "keyboard_shortcuts.reply": "að svara",
   "keyboard_shortcuts.requests": "að opna lista yfir fylgjendabeiðnir",
@@ -239,7 +246,7 @@
   "keyboard_shortcuts.start": "að opna \"komast í gang\" dálk",
   "keyboard_shortcuts.toggle_hidden": "að birta/fela texta á bak við aðvörun vegna efnis",
   "keyboard_shortcuts.toggle_sensitivity": "að birta/fela myndir",
-  "keyboard_shortcuts.toot": "að byrja glænýtt tíst",
+  "keyboard_shortcuts.toot": "Hefja glænýtt þyt",
   "keyboard_shortcuts.unfocus": "að taka virkni úr textainnsetningarreit eða leit",
   "keyboard_shortcuts.up": "að fara ofar í listanum",
   "lightbox.close": "Loka",
@@ -272,12 +279,12 @@
   "navigation_bar.blocks": "Útilokaðir notendur",
   "navigation_bar.bookmarks": "Bókamerki",
   "navigation_bar.community_timeline": "Staðvær tímalína",
-  "navigation_bar.compose": "Semja nýtt tíst",
+  "navigation_bar.compose": "Semja nýtt þyt",
   "navigation_bar.direct": "Bein skilaboð",
   "navigation_bar.discover": "Uppgötva",
   "navigation_bar.domain_blocks": "Falin lén",
   "navigation_bar.edit_profile": "Breyta notandasniði",
-  "navigation_bar.favourites": "Eftirlæti",
+  "navigation_bar.favourites": "Fílanir",
   "navigation_bar.filters": "Þögguð orð",
   "navigation_bar.follow_requests": "Beiðnir um að fylgjast með",
   "navigation_bar.follows_and_followers": "Fylgist með og fylgjendur",
@@ -287,11 +294,11 @@
   "navigation_bar.logout": "Útskráning",
   "navigation_bar.mutes": "Þaggaðir notendur",
   "navigation_bar.personal": "Einka",
-  "navigation_bar.pins": "Föst tíst",
+  "navigation_bar.pins": "Föst þyt",
   "navigation_bar.preferences": "Kjörstillingar",
   "navigation_bar.public_timeline": "Sameiginleg tímalína",
   "navigation_bar.security": "Öryggi",
-  "notification.favourite": "{name} setti stöðufærslu þína í eftirlæti",
+  "notification.favourite": "{name} filaði stöðufærslu þína",
   "notification.follow": "{name} fylgist með þér",
   "notification.follow_request": "{name} hefur beðið um að fylgjast með þér",
   "notification.mention": "{name} minntist á þig",
@@ -302,7 +309,7 @@
   "notifications.clear": "Hreinsa tilkynningar",
   "notifications.clear_confirmation": "Ertu viss um að þú viljir endanlega eyða öllum tilkynningunum þínum?",
   "notifications.column_settings.alert": "Tilkynningar á skjáborði",
-  "notifications.column_settings.favourite": "Eftirlæti:",
+  "notifications.column_settings.favourite": "Fílanir:",
   "notifications.column_settings.filter_bar.advanced": "Birta alla flokka",
   "notifications.column_settings.filter_bar.category": "Skyndisíustika",
   "notifications.column_settings.filter_bar.show": "Sýna",
@@ -314,11 +321,11 @@
   "notifications.column_settings.reblog": "Endurbirtingar:",
   "notifications.column_settings.show": "Sýna í dálki",
   "notifications.column_settings.sound": "Spila hljóð",
-  "notifications.column_settings.status": "Ný tíst:",
+  "notifications.column_settings.status": "Ný þyt:",
   "notifications.column_settings.unread_markers.category": "Merki fyrir ólesnar tilkynningar",
   "notifications.filter.all": "Allt",
   "notifications.filter.boosts": "Endurbirtingar",
-  "notifications.filter.favourites": "Eftirlæti",
+  "notifications.filter.favourites": "Fílanir",
   "notifications.filter.follows": "Fylgist með",
   "notifications.filter.mentions": "Tilvísanir",
   "notifications.filter.polls": "Niðurstöður könnunar",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# atkvæði} other {# atkvæði}}",
   "poll.vote": "Greiða atkvæði",
   "poll.voted": "Þú kaust þetta svar",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Bæta við könnun",
   "poll_button.remove_poll": "Fjarlægja könnun",
   "privacy.change": "Aðlaga gagnaleynd stöðufærslu",
@@ -368,15 +376,15 @@
   "report.target": "Kæri {target}",
   "search.placeholder": "Leita",
   "search_popout.search_format": "Snið ítarlegrar leitar",
-  "search_popout.tips.full_text": "Einfaldur texti skilar stöðufærslum sem þú hefur skrifað, sett í eftirlæti, endurbirt eða verið minnst á þig í, ásamt samsvarandi birtingarnöfnum, notendanöfnum og myllumerkjum.",
+  "search_popout.tips.full_text": "Einfaldur texti skilar stöðufærslum sem þú hefur skrifað, fílað, endurbirt eða sem á þig hefur verið minnst í, ásamt samsvarandi birtingarnöfnum, notendanöfnum og myllumerkjum.",
   "search_popout.tips.hashtag": "myllumerki",
   "search_popout.tips.status": "stöðufærsla",
   "search_popout.tips.text": "Einfaldur texti skilar samsvarandi birtingarnöfnum, notendanöfnum og myllumerkjum",
   "search_popout.tips.user": "notandi",
   "search_results.accounts": "Fólk",
   "search_results.hashtags": "Myllumerki",
-  "search_results.statuses": "Tíst",
-  "search_results.statuses_fts_disabled": "Að leita í efni tísta er ekki virk á þessum Mastodon-þjóni.",
+  "search_results.statuses": "Þyt",
+  "search_results.statuses_fts_disabled": "Að leita í efni þyta er ekki virk á þessum Mastodon-þjóni.",
   "search_results.total": "{count, number} {count, plural, one {niðurstaða} other {niðurstöður}}",
   "status.admin_account": "Opna umsjónarviðmót fyrir @{name}",
   "status.admin_status": "Opna þessa stöðufærslu í umsjónarviðmótinu",
@@ -389,7 +397,7 @@
   "status.detailed_status": "Nákvæm spjallþráðasýn",
   "status.direct": "Bein skilaboð @{name}",
   "status.embed": "Ívefja",
-  "status.favourite": "Eftirlæti",
+  "status.favourite": "Fílanir",
   "status.filtered": "Síað",
   "status.load_more": "Hlaða inn meiru",
   "status.media_hidden": "Mynd er falin",
@@ -399,12 +407,12 @@
   "status.mute_conversation": "Þagga niður í samtali",
   "status.open": "Útliða þessa stöðu",
   "status.pin": "Festa á notandasnið",
-  "status.pinned": "Fast tíst",
+  "status.pinned": "Fast þyt",
   "status.read_more": "Lesa meira",
   "status.reblog": "Endurbirting",
   "status.reblog_private": "Endurbirta til upphaflegra lesenda",
   "status.reblogged_by": "{name} endurbirti",
-  "status.reblogs.empty": "Enginn hefur ennþá endurbirt þetta tíst. Þegar einhverjir gera það, munu þeir birtast hér.",
+  "status.reblogs.empty": "Enginn hefur ennþá endurbirt þetta þyt. Þegar einhver gerir það, mun sá birtast hér.",
   "status.redraft": "Eyða og enduvinna drög",
   "status.remove_bookmark": "Fjarlægja bókamerki",
   "status.reply": "Svara",
@@ -435,7 +443,7 @@
   "timeline_hint.remote_resource_not_displayed": "{resource} frá öðrum netþjónum er ekki birt.",
   "timeline_hint.resources.followers": "Fylgjendur",
   "timeline_hint.resources.follows": "Fylgist með",
-  "timeline_hint.resources.statuses": "Eldri tíst",
+  "timeline_hint.resources.statuses": "Eldri þyt",
   "trends.counter_by_accounts": "{count, plural, one {{counter} aðili} other {{counter} aðilar}} tala",
   "trends.trending_now": "Í umræðunni núna",
   "ui.beforeunload": "Drögin tapast ef þú ferð út úr Mastodon.",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Lýstu þessu fyrir fólk sem heyrir illa eða er með skerta sjón",
   "upload_modal.analyzing_picture": "Greini mynd…",
   "upload_modal.apply": "Virkja",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Veldu mynd",
   "upload_modal.description_placeholder": "Öllum dýrunum í skóginum þætti bezt að vera vinir",
   "upload_modal.detect_text": "Skynja texta úr mynd",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 23b7c0d86..d2c9e1179 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -22,7 +22,7 @@
   "account.follows.empty": "Questo utente non segue ancora nessuno.",
   "account.follows_you": "Ti segue",
   "account.hide_reblogs": "Nascondi condivisioni da @{name}",
-  "account.joined": "Registrato dal {date}",
+  "account.joined": "Su questa istanza dal {date}",
   "account.last_status": "Ultima attività",
   "account.link_verified_on": "La proprietà di questo link è stata controllata il {date}",
   "account.locked_info": "Questo è un account privato. Il proprietario approva manualmente chi può seguirlo.",
@@ -47,11 +47,16 @@
   "account.unmute": "Riattiva @{name}",
   "account.unmute_notifications": "Riattiva le notifiche da @{name}",
   "account_note.placeholder": "Clicca per aggiungere una nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Sei pregato di riprovare tra {retry_time, time, medium}.",
   "alert.rate_limited.title": "Limitazione per eccesso di richieste",
   "alert.unexpected.message": "Si è verificato un errore imprevisto.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Annuncio",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per settimana",
   "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta",
   "bundle_column_error.body": "E' avvenuto un errore durante il caricamento di questo componente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Sei sicuro di voler cancellare questo toot?",
   "confirmations.delete_list.confirm": "Cancella",
   "confirmations.delete_list.message": "Sei sicuro di voler cancellare definitivamente questa lista?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Blocca l'intero dominio",
   "confirmations.domain_block.message": "Sei davvero, davvero sicur@ di voler bloccare {domain} completamente? Nella maggioranza dei casi, è preferibile e sufficiente bloccare o silenziare pochi account in modo mirato. Non vedrai più il contenuto da quel dominio né nelle timeline pubbliche né nelle tue notifiche. Anzi, verranno rimossi dai follower gli account di questo dominio.",
   "confirmations.logout.confirm": "Disconnettiti",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# voto} other {# voti}}",
   "poll.vote": "Vota",
   "poll.voted": "Hai votato per questa risposta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Aggiungi un sondaggio",
   "poll_button.remove_poll": "Rimuovi sondaggio",
   "privacy.change": "Modifica privacy del post",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Descrizione per persone con difetti uditivi o visivi",
   "upload_modal.analyzing_picture": "Analisi immagine…",
   "upload_modal.apply": "Applica",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Scegli immagine",
   "upload_modal.description_placeholder": "Ma la volpe col suo balzo ha raggiunto il quieto Fido",
   "upload_modal.detect_text": "Rileva testo dall'immagine",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 3a4617448..baba5d66b 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -47,11 +47,16 @@
   "account.unmute": "@{name}さんのミュートを解除",
   "account.unmute_notifications": "@{name}さんからの通知を受け取るようにする",
   "account_note.placeholder": "クリックしてメモを追加",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "{retry_time, time, medium} 以降に再度実行してください。",
   "alert.rate_limited.title": "制限に達しました",
   "alert.unexpected.message": "不明なエラーが発生しました。",
   "alert.unexpected.title": "エラー!",
   "announcement.announcement": "お知らせ",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} 回 / 週",
   "boost_modal.combo": "次からは{combo}を押せばスキップできます",
   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。",
@@ -117,6 +122,8 @@
   "confirmations.delete.message": "本当に削除しますか?",
   "confirmations.delete_list.confirm": "削除",
   "confirmations.delete_list.message": "本当にこのリストを完全に削除しますか?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "ドメイン全体をブロック",
   "confirmations.domain_block.message": "本当に{domain}全体を非表示にしますか? 多くの場合は個別にブロックやミュートするだけで充分であり、また好ましいです。公開タイムラインにそのドメインのコンテンツが表示されなくなり、通知も届かなくなります。そのドメインのフォロワーはアンフォローされます。",
   "confirmations.logout.confirm": "ログアウト",
@@ -344,6 +351,7 @@
   "poll.total_votes": "{count}票",
   "poll.vote": "投票",
   "poll.voted": "この項目に投票しました",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "アンケートを追加",
   "poll_button.remove_poll": "アンケートを削除",
   "privacy.change": "公開範囲を変更",
@@ -459,6 +467,7 @@
   "upload_form.video_description": "視聴が難しいユーザーへの説明",
   "upload_modal.analyzing_picture": "画像を解析中…",
   "upload_modal.apply": "適用",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "画像を選択",
   "upload_modal.description_placeholder": "あのイーハトーヴォのすきとおった風",
   "upload_modal.detect_text": "画像からテキストを検出",
diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json
index 714ec2e49..2b1221d6f 100644
--- a/app/javascript/mastodon/locales/ka.json
+++ b/app/javascript/mastodon/locales/ka.json
@@ -47,11 +47,16 @@
   "account.unmute": "ნუღარ აჩუმებ @{name}-ს",
   "account.unmute_notifications": "ნუღარ აჩუმებ შეტყობინებებს @{name}-სგან",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "წარმოიშვა მოულოდნელი შეცდომა.",
   "alert.unexpected.title": "უპს!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "კვირაში {count}",
   "boost_modal.combo": "შეგიძლიათ დააჭიროთ {combo}-ს რათა შემდეგ ჯერზე გამოტოვოთ ეს",
   "bundle_column_error.body": "ამ კომპონენტის ჩატვირთვისას რაღაც აირია.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "დარწმუნებული ხართ, გსურთ გააუქმოთ ეს სტატუსი?",
   "confirmations.delete_list.confirm": "გაუქმება",
   "confirmations.delete_list.message": "დარწმუნებული ხართ, გსურთ სამუდამოდ გააუქმოთ ეს სია?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "მთელი დომენის დამალვა",
   "confirmations.domain_block.message": "ნაღდად, ნაღდად, დარწმუნებული ხართ, გსურთ დაბლოკოთ მთელი {domain}? უმეტეს შემთხვევაში რამდენიმე გამიზნული ბლოკი ან გაჩუმება საკმარისი და უკეთესია. კონტენტს ამ დომენიდან ვერ იხილავთ ვერც ერთ ღია თაიმლაინზე ან თქვენს შეტყობინებებში. ამ დომენიდან არსებული მიმდევრები ამოიშლება.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "სტატუსის კონფიდენციალურობის მითითება",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json
index 175a509ff..632054786 100644
--- a/app/javascript/mastodon/locales/kab.json
+++ b/app/javascript/mastodon/locales/kab.json
@@ -22,7 +22,7 @@
   "account.follows.empty": "Ar tura, amseqdac-agi ur yeṭṭafaṛ yiwen.",
   "account.follows_you": "Yeṭṭafaṛ-ik",
   "account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Yerna-d {date}",
   "account.last_status": "Armud aneggaru",
   "account.link_verified_on": "Taɣara n useɣwen-a tettwasenqed ass n {date}",
   "account.locked_info": "Amiḍan-agi uslig isekweṛ. D bab-is kan i izemren ad yeǧǧ, s ufus-is, win ara t-iḍefṛen.",
@@ -47,11 +47,16 @@
   "account.unmute": "Kkes asgugem ɣef @{name}",
   "account.unmute_notifications": "Serreḥ ilɣa sɣur @{name}",
   "account_note.placeholder": "Ulac iwenniten",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Ma ulac aɣilif ɛreḍ tikelt-nniḍen akka {retry_time, time, medium}.",
   "alert.rate_limited.title": "Aktum s talast",
   "alert.unexpected.message": "Yeḍra-d unezri ur netturaǧu ara.",
   "alert.unexpected.title": "Ayhuh!",
   "announcement.announcement": "Ulɣu",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} i yimalas",
   "boost_modal.combo": "Tzemreḍ ad tetekkiḍ ɣef {combo} akken ad tessurfeḍ aya tikelt-nniḍen",
   "bundle_column_error.body": "Tella-d kra n tuccḍa mi d-yettali ugbur-agi.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Tebɣiḍ s tidet ad tekkseḍ tasuffeɣt-agi?",
   "confirmations.delete_list.confirm": "Kkes",
   "confirmations.delete_list.message": "Tebɣiḍ s tidet ad tekkseḍ umuɣ-agi i lebda?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Ffer taɣult meṛṛa",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Ffeɣ",
@@ -150,7 +157,7 @@
   "emoji_button.search_results": "Igemmaḍ n unadi",
   "emoji_button.symbols": "Izamulen",
   "emoji_button.travel": "Imeḍqan d Yinigen",
-  "empty_column.account_suspended": "Account suspended",
+  "empty_column.account_suspended": "Amiḍan yettwaḥebsen",
   "empty_column.account_timeline": "Ulac tijewwaqin dagi!",
   "empty_column.account_unavailable": "Ur nufi ara amaɣnu-ayi",
   "empty_column.blocks": "Ur tesḥebseḍ ula yiwen n umseqdac ar tura.",
@@ -164,7 +171,7 @@
   "empty_column.follow_requests": "Ulac ɣur-k ula yiwen n usuter n teḍfeṛt. Ticki teṭṭfeḍ-d yiwen ad d-yettwasken da.",
   "empty_column.hashtag": "Ar tura ulac kra n ugbur yesɛan assaɣ ɣer uhacṭag-agi.",
   "empty_column.home": "Tasuddemt tagejdant n yisallen d tilemt! Ẓer {public} neɣ nadi ad tafeḍ imseqdacen-nniḍen ad ten-ḍefṛeḍ.",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home.suggestions": "Ẓer kra n yisumar",
   "empty_column.list": "Ar tura ur yelli kra deg umuɣ-a. Ad d-yettwasken da ticki iɛeggalen n wumuɣ-a suffɣen-d kra.",
   "empty_column.lists": "Ulac ɣur-k kra n wumuɣ yakan. Ad d-tettwasken da ticki tesluleḍ-d yiwet.",
   "empty_column.mutes": "Ulac ɣur-k imseqdacen i yettwasgugmen.",
@@ -176,7 +183,7 @@
   "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
   "errors.unexpected_crash.copy_stacktrace": "Nɣel stacktrace ɣef wafus",
   "errors.unexpected_crash.report_issue": "Mmel ugur",
-  "follow_recommendations.done": "Done",
+  "follow_recommendations.done": "Immed",
   "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
   "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
   "follow_request.authorize": "Ssireg",
@@ -254,8 +261,8 @@
   "lists.edit.submit": "Beddel azwel",
   "lists.new.create": "Rnu tabdart",
   "lists.new.title_placeholder": "Azwel amaynut n tebdart",
-  "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
+  "lists.replies_policy.followed": "Kra n useqdac i yettwaḍefren",
+  "lists.replies_policy.list": "Iɛeggalen n tebdart",
   "lists.replies_policy.none": "Ula yiwen·t",
   "lists.replies_policy.title": "Ssken-d tiririyin i:",
   "lists.search": "Nadi gar yemdanen i teṭṭafaṛeḍ",
@@ -267,7 +274,7 @@
   "missing_indicator.sublabel": "Ur nufi ara aɣbalu-a",
   "mute_modal.duration": "Tanzagt",
   "mute_modal.hide_notifications": "Tebɣiḍ ad teffreḍ talɣutin n umseqdac-a?",
-  "mute_modal.indefinite": "Indefinite",
+  "mute_modal.indefinite": "Ur yettwasbadu ara",
   "navigation_bar.apps": "Isnasen izirazen",
   "navigation_bar.blocks": "Imseqdacen yettusḥebsen",
   "navigation_bar.bookmarks": "Ticraḍ",
@@ -296,9 +303,9 @@
   "notification.follow_request": "{name} yessuter-d ad k-yeḍfeṛ",
   "notification.mention": "{name} yebder-ik-id",
   "notification.own_poll": "Tafrant-ik·im tfuk",
-  "notification.poll": "A poll you have voted in has ended",
+  "notification.poll": "Tfukk tefrant ideg tettekkaḍ",
   "notification.reblog": "{name} yebḍa tajewwiqt-ik i tikelt-nniḍen",
-  "notification.status": "{name} just posted",
+  "notification.status": "{name} akken i d-yessufeɣ",
   "notifications.clear": "Sfeḍ tilɣa",
   "notifications.clear_confirmation": "Tebɣiḍ s tidet ad tekkseḍ akk tilɣa-inek·em i lebda?",
   "notifications.column_settings.alert": "Tilɣa n tnarit",
@@ -322,23 +329,24 @@
   "notifications.filter.follows": "Yeṭafaṛ",
   "notifications.filter.mentions": "Abdar",
   "notifications.filter.polls": "Igemmaḍ n usenqed",
-  "notifications.filter.statuses": "Updates from people you follow",
-  "notifications.grant_permission": "Grant permission.",
+  "notifications.filter.statuses": "Ileqman n yimdanen i teṭṭafareḍ",
+  "notifications.grant_permission": "Mudd tasiregt.",
   "notifications.group": "{count} n tilɣa",
-  "notifications.mark_as_read": "Mark every notification as read",
+  "notifications.mark_as_read": "Creḍ meṛṛa iilɣa am wakken ttwaɣran",
   "notifications.permission_denied": "D awezɣi ad yili wermad n yilɣa n tnarit axateṛ turagt tettwagdel.",
   "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
   "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
-  "notifications_permission_banner.enable": "Enable desktop notifications",
+  "notifications_permission_banner.enable": "Rmed talɣutin n tnarit",
   "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
-  "notifications_permission_banner.title": "Never miss a thing",
-  "picture_in_picture.restore": "Put it back",
+  "notifications_permission_banner.title": "Ur zeggel acemma",
+  "picture_in_picture.restore": "Err-it amkan-is",
   "poll.closed": "Ifukk",
   "poll.refresh": "Smiren",
   "poll.total_people": "{count, plural, one {# n wemdan} other {# n yemdanen}}",
   "poll.total_votes": "{count, plural, one {# n udɣaṛ} other {# n yedɣaṛen}}",
   "poll.vote": "Dɣeṛ",
   "poll.voted": "Tdeɣṛeḍ ɣef tririt-ayi",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Rnu asenqed",
   "poll_button.remove_poll": "Kkes asenqed",
   "privacy.change": "Seggem tabaḍnit n yizen",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Glem-d i yemdanen i yesɛan ugur deg tmesliwt neɣ deg yiẓri",
   "upload_modal.analyzing_picture": "Tasleḍt n tugna tetteddu…",
   "upload_modal.apply": "Snes",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Fren tugna",
   "upload_modal.description_placeholder": "Aberraɣ arurad ineggez nnig n uqjun amuṭṭis",
   "upload_modal.detect_text": "Sefru-d aḍris seg tugna",
diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json
index 95abf9381..1407a0991 100644
--- a/app/javascript/mastodon/locales/kk.json
+++ b/app/javascript/mastodon/locales/kk.json
@@ -47,11 +47,16 @@
   "account.unmute": "@{name} ескертпелерін қосу",
   "account.unmute_notifications": "@{name} ескертпелерін көрсету",
   "account_note.placeholder": "Жазба қалдыру үшін бас",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Қайтадан көріңіз  {retry_time, time, medium} кейін.",
   "alert.rate_limited.title": "Бағалау шектеулі",
   "alert.unexpected.message": "Бір нәрсе дұрыс болмады.",
   "alert.unexpected.title": "Өй!",
   "announcement.announcement": "Хабарландыру",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} аптасына",
   "boost_modal.combo": "Келесіде өткізіп жіберу үшін басыңыз {combo}",
   "bundle_column_error.body": "Бұл компонентті жүктеген кезде бір қате пайда болды.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Бұл жазбаны өшіресіз бе?",
   "confirmations.delete_list.confirm": "Өшіру",
   "confirmations.delete_list.message": "Бұл тізімді жоясыз ба шынымен?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Бұл доменді бұғатта",
   "confirmations.domain_block.message": "Бұл домендегі {domain} жазбаларды шынымен бұғаттайсыз ба? Кейде үнсіз қылып тастау да жеткілікті.",
   "confirmations.logout.confirm": "Шығу",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# дауыс} other {# дауыс}}",
   "poll.vote": "Дауыс беру",
   "poll.voted": "Бұл сұраққа жауап бердіңіз",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Сауалнама қосу",
   "poll_button.remove_poll": "Сауалнаманы өшіру",
   "privacy.change": "Құпиялылықты реттеу",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Есту немесе көру қабілеті нашар адамдарға сипаттама беріңіз",
   "upload_modal.analyzing_picture": "Суретті анализ жасау…",
   "upload_modal.apply": "Қолдану",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Сурет таңдау",
   "upload_modal.description_placeholder": "Щучинск съезіндегі өрт пе? Вагон-үй, аэромобиль һәм ұшақ фюзеляжы цехінен ғой",
   "upload_modal.detect_text": "Суреттен мәтін анықтау",
diff --git a/app/javascript/mastodon/locales/kmr.json b/app/javascript/mastodon/locales/kmr.json
new file mode 100644
index 000000000..8f862606d
--- /dev/null
+++ b/app/javascript/mastodon/locales/kmr.json
@@ -0,0 +1,484 @@
+{
+  "account.account_note_header": "Nîşe",
+  "account.add_or_remove_from_list": "Tevlî bike an rake ji rêzokê",
+  "account.badges.bot": "Bot",
+  "account.badges.group": "Kom",
+  "account.block": "@{name} asteng bike",
+  "account.block_domain": "{domain} navpar asteng bike",
+  "account.blocked": "Astengkirî",
+  "account.browse_more_on_origin_server": "Li pelên resen bêhtir bigere",
+  "account.cancel_follow_request": "Daxwaza şopandinê rake",
+  "account.direct": "Peyamekê bişîne @{name}",
+  "account.disable_notifications": "Êdî min agahdar neke gava @{name} diweşîne",
+  "account.domain_blocked": "Navper hate astengkirin",
+  "account.edit_profile": "Profîl serrast bike",
+  "account.enable_notifications": "Min agahdar bike gava @{name} diweşîne",
+  "account.endorse": "Taybetiyên li ser profîl",
+  "account.follow": "Bişopîne",
+  "account.followers": "Şopîner",
+  "account.followers.empty": "Kesekî hin ev bikarhêner neşopandiye.",
+  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
+  "account.following_counter": "{count, plural, one {{counter} Dişopîne} other {{counter} Dişopîne}}",
+  "account.follows.empty": "Ev bikarhêner hin kesekî heya niha neşopandiye.",
+  "account.follows_you": "Te dişopîne",
+  "account.hide_reblogs": "Boost ên ji @{name} veşêre",
+  "account.joined": "Tevlîbû di {date} de",
+  "account.last_status": "Çalakiya dawî",
+  "account.link_verified_on": "Xwedaniya li vê girêdanê di {date} de hatiye kontrolkirin",
+  "account.locked_info": "Rewşa vê ajimêrê wek kilît kirî hatiye saz kirin. Xwedî yê ajimêrê, kesên vê bişopîne bi dest vekolin dike.",
+  "account.media": "Medya",
+  "account.mention": "Qal @{name} bike",
+  "account.moved_to": "{name} hate livandin bo:",
+  "account.mute": "@{name} Bêdeng bike",
+  "account.mute_notifications": "Agahdariyan ji @{name} bêdeng bike",
+  "account.muted": "Bêdengkirî",
+  "account.never_active": "Tu car",
+  "account.posts": "Şandî",
+  "account.posts_with_replies": "Toot û bersiv",
+  "account.report": "@{name} Ragihîne",
+  "account.requested": "Li benda erêkirinê ye. Ji bo betal kirina daxwazê pêl bikin",
+  "account.share": "Profîla @{name} parve bike",
+  "account.show_reblogs": "Boostên @{name} nîşan bike",
+  "account.statuses_counter": "{count, plural,one {{counter} şandî}other {{counter} şandî}}",
+  "account.unblock": "Astengê li ser @{name} rake",
+  "account.unblock_domain": "Astengê li ser navperê {domain} rake",
+  "account.unendorse": "Li ser profîl nîşan neke",
+  "account.unfollow": "Neşopîne",
+  "account.unmute": "@{name} Bêdeng bike",
+  "account.unmute_notifications": "Agahdariyan ji @{name} bêdeng bike",
+  "account_note.placeholder": "Bitikîne bo nîşeyekê tevlî bikî",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
+  "alert.rate_limited.message": "Jkx dîsa biceribîne piştî {retry_time, time, medium}.\n \n",
+  "alert.rate_limited.title": "Rêje sînorkirî ye",
+  "alert.unexpected.message": "Çewtiyeke bêhêvî çê bû.",
+  "alert.unexpected.title": "Wey li min!",
+  "announcement.announcement": "Daxuyanî",
+  "attachments_list.unprocessed": "(unprocessed)",
+  "autosuggest_hashtag.per_week": "Her hefte {count}",
+  "boost_modal.combo": "Ji bo derbas bî carekî din de pêlê {combo} bike",
+  "bundle_column_error.body": "Di dema barkirina vê hêmanê de tiştek çewt çê bû.",
+  "bundle_column_error.retry": "Dîsa biceribîne",
+  "bundle_column_error.title": "Çewtiya torê",
+  "bundle_modal_error.close": "Bigire",
+  "bundle_modal_error.message": "Di dema barkirina vê hêmanê de tiştek çewt çê bû.",
+  "bundle_modal_error.retry": "Dîsa bicerbîne",
+  "column.blocks": "Bikarhênerên astengkirî",
+  "column.bookmarks": "Şûnpel",
+  "column.community": "Demnameya herêmî",
+  "column.direct": "Peyamên taybet",
+  "column.directory": "Li profîlan bigere",
+  "column.domain_blocks": "Navperên astengkirî",
+  "column.favourites": "Bijarte",
+  "column.follow_requests": "Daxwazên şopandinê",
+  "column.home": "Serrûpel",
+  "column.lists": "Rêzok",
+  "column.mutes": "Bikarhênerên bêdengkirî",
+  "column.notifications": "Agahdarî",
+  "column.pins": "Toot a derzîkirî",
+  "column.public": "Demnameyê federalîkirî",
+  "column_back_button.label": "Veger",
+  "column_header.hide_settings": "Sazkariyan veşêre",
+  "column_header.moveLeft_settings": "Stûnê bilivîne bo çepê",
+  "column_header.moveRight_settings": "Stûnê bilivîne bo rastê",
+  "column_header.pin": "Bi derzî bike",
+  "column_header.show_settings": "Sazkariyan nîşan bide",
+  "column_header.unpin": "Bi derzî neke",
+  "column_subheading.settings": "Sazkarî",
+  "community.column_settings.local_only": "Tenê herêmî",
+  "community.column_settings.media_only": "Tenê media",
+  "community.column_settings.remote_only": "Tenê ji dûr ve",
+  "compose_form.direct_message_warning": "Ev toot tenê ji bikarhênerên behskirî re were şandin.",
+  "compose_form.direct_message_warning_learn_more": "Bêtir fêr bibe",
+  "compose_form.hashtag_warning": "Ev şandî ji ber ku nehatiye tomarkirin dê di binê hashtagê de neyê tomar kirin. Tenê peyamên gelemperî dikarin bi hashtagê werin lêgerîn.",
+  "compose_form.lock_disclaimer": "Ajimêrê te {locked} nîne. Herkes dikare te bişopîne da ku şandiyên te yên tenê şopînerên te ra xûya dibin bibînin.",
+  "compose_form.lock_disclaimer.lock": "girtî ye",
+  "compose_form.placeholder": "Çi di hişê te derbas dibe?",
+  "compose_form.poll.add_option": "Hilbijarekî tevlî bike",
+  "compose_form.poll.duration": "Dema rapirsî yê",
+  "compose_form.poll.option_placeholder": "{number} Hilbijêre",
+  "compose_form.poll.remove_option": "Vê hilbijarê rake",
+  "compose_form.poll.switch_to_multiple": "Rapirsî yê biguherînin da ku destûr bidin vebijarkên pirjimar",
+  "compose_form.poll.switch_to_single": "Rapirsîyê biguherîne da ku mafê bidî tenê vebijêrkek",
+  "compose_form.publish": "Toot",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "{count, plural, one {Medya wekî hestiyar nîşan bide} other {Medya wekî hestiyar nîşan bide}}",
+  "compose_form.sensitive.marked": "{count, plural, one {Medya wekî hestiyar hate nîşan} other {Medya wekî hestiyar nîşan}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {Medya wekî hestiyar nehatiye nîşan} other {Medya wekî hestiyar nehatiye nîşan}}",
+  "compose_form.spoiler.marked": "Hişyariya naverokê rake",
+  "compose_form.spoiler.unmarked": "Hişyariya naverokê tevlî bike",
+  "compose_form.spoiler_placeholder": "Li vir hişyariya xwe binivîse",
+  "confirmation_modal.cancel": "Dev jê berde",
+  "confirmations.block.block_and_report": "Asteng bike & ragihîne",
+  "confirmations.block.confirm": "Asteng bike",
+  "confirmations.block.message": "Ma tu dixwazî ku {name} asteng bikî?",
+  "confirmations.delete.confirm": "Jê bibe",
+  "confirmations.delete.message": "Ma tu dixwazî vê şandiyê jê bibî?",
+  "confirmations.delete_list.confirm": "Jê bibe",
+  "confirmations.delete_list.message": "Ma tu dixwazî bi awayekî herdemî vê rêzokê jê bibî?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
+  "confirmations.domain_block.confirm": "Hemî navperê asteng bike",
+  "confirmations.domain_block.message": "Tu ji xwe bawerî, bi rastî tu dixwazî hemû {domain} asteng bikî? Di gelek rewşan de asteng kirin an jî bêdeng kirin têrê dike û tê tercîh kirin. Tu nikarî naveroka vê navperê di demnameyê an jî agahdariyên xwe de bibînî. Şopînerên te yê di vê navperê were jêbirin.",
+  "confirmations.logout.confirm": "Derkeve",
+  "confirmations.logout.message": "Ma tu dixwazî ku derkevî?",
+  "confirmations.mute.confirm": "Bêdeng bike",
+  "confirmations.mute.explanation": "Ev ê şandinên ji wan tê û şandinên ku behsa wan dike veşêre, lê hê jî maf dide ku ew şandinên te bibînin û te bişopînin.",
+  "confirmations.mute.message": "Bi rastî tu dixwazî {name} bêdeng bikî?",
+  "confirmations.redraft.confirm": "Jê bibe & ji nû ve serrast bike",
+  "confirmations.redraft.message": "Bi rastî tu dixwazî şandî ye jê bibî û nûve reşnivîsek çê bikî? Bijare û şandîyên wenda bibin û bersivên ji bo şandiye orîjînal sêwî bimînin.",
+  "confirmations.reply.confirm": "Bersivê bide",
+  "confirmations.reply.message": "Bersiva niha li ser peyama ku tu niha berhev dikî dê binivsîne. Ma pê bawer î ku tu dixwazî bidomînî?",
+  "confirmations.unfollow.confirm": "Neşopîne",
+  "confirmations.unfollow.message": "Ma tu dixwazî ku dev ji şopa {name} berdî?",
+  "conversation.delete": "Axaftinê jê bibe",
+  "conversation.mark_as_read": "Wekî xwendî nîşan bide",
+  "conversation.open": "Axaftinê nîşan bide",
+  "conversation.with": "Bi {names} re",
+  "directory.federated": "Ji fediversên naskirî",
+  "directory.local": "Tenê ji {domain}",
+  "directory.new_arrivals": "Kesên ku nû hatine",
+  "directory.recently_active": "Di demên dawî de çalak",
+  "embed.instructions": "Bi jêgirtina koda jêrîn vê şandiyê li ser malpera xwe bicîh bikin.",
+  "embed.preview": "Wa ye wê wusa xuya bike:",
+  "emoji_button.activity": "Çalakî",
+  "emoji_button.custom": "Kesanekirî",
+  "emoji_button.flags": "Nîşankirî",
+  "emoji_button.food": "Xwarin û vexwarin",
+  "emoji_button.label": "Emoji têxe",
+  "emoji_button.nature": "Sirûştî",
+  "emoji_button.not_found": "Hestokên lihevhatî nehate dîtin",
+  "emoji_button.objects": "Tişt",
+  "emoji_button.people": "Mirov",
+  "emoji_button.recent": "Pir caran tê bikaranîn",
+  "emoji_button.search": "Bigere...",
+  "emoji_button.search_results": "Encamên lêgerînê",
+  "emoji_button.symbols": "Sembol",
+  "emoji_button.travel": "Geşt û şûn",
+  "empty_column.account_suspended": "Ajimêr hatiye rawestandin",
+  "empty_column.account_timeline": "Li vir şandî tune!",
+  "empty_column.account_unavailable": "Profîl nayê peydakirin",
+  "empty_column.blocks": "Te tu bikarhêner asteng nekiriye.",
+  "empty_column.bookmarked_statuses": "Hîn tu peyamên şûnpelkirî tuneye. Gava ku hûn yek şûnpel bikin, ew ê li vir xûya bike.",
+  "empty_column.community": "Demnameya herêmî vala ye. Tiştek ji raya giştî re binivsînin da ku rûpel biherike!",
+  "empty_column.direct": "Hêj peyameke te yê rasterast tuneye. Gava ku tu yekî bişeynî an jî bigirî, ew ê li vir xûya bike.",
+  "empty_column.domain_blocks": "Hê jî navperên hatine asteng kirin tune ne.",
+  "empty_column.favourited_statuses": "Hîn tu peyamên te yên bijare tunene. Gava ku te yekî bijart, ew ê li vir xûya bike.",
+  "empty_column.favourites": "Hîn tu kes vê peyamê nebijartiye. Gava ku hin kes bijartin, ew ê li vir xûya bikin.",
+  "empty_column.follow_recommendations": "Wusa dixuye ku ji bo we tu pêşniyar nehatine çêkirin. Hûn dikarin lêgerînê bikarbînin da ku li kesên ku hûn nas dikin bigerin an hashtagên trendî bigerin.",
+  "empty_column.follow_requests": "Hê jî daxwaza şopandinê tunne ye. Dema daxwazek hat, yê li vir were nîşan kirin.",
+  "empty_column.hashtag": "Di vê hashtagê de hêj tiştekî tune.",
+  "empty_column.home": "Demnameya mala we vala ye! Ji bona tijîkirinê bêtir mirovan bişopînin. {suggestions}",
+  "empty_column.home.suggestions": "Hinek pêşniyaran bibîne",
+  "empty_column.list": "Di vê rêzokê de hîn tiştek tune ye. Gava ku endamên vê rêzokê peyamên nû biweşînin, ew ê li vir xuya bibin.",
+  "empty_column.lists": "Hêj qet rêzokê te tunne ye. Dema yek peyda bû, yê li vir were nîşan kirin.",
+  "empty_column.mutes": "Te tu bikarhêner bêdeng nekiriye.",
+  "empty_column.notifications": "Hêj hişyariyên te tunene. Dema ku mirovên din bi we re têkilî danîn, hûn ê wê li vir bibînin.",
+  "empty_column.public": "Li vir tiştekî tuneye! Ji raya giştî re tiştekî binivîsîne, an ji bo tijîkirinê ji rajekerên din bikarhêneran bi destan bişopînin",
+  "error.unexpected_crash.explanation": "Ji ber xeletîyeke di koda me da an jî ji ber mijara lihevhatina gerokan, ev rûpel rast nehat nîşandan.",
+  "error.unexpected_crash.explanation_addons": "Ev rûpel bi awayekî rast nehat nîşandan. Ev çewtî mimkûn e ji ber lêzêdekirina gerokan an jî amûrên wergera xweberî pêk tê.",
+  "error.unexpected_crash.next_steps": "Nûkirina rûpelê biceribîne. Heke ev bi kêr neyê, dibe ku te hîn jî bi rêya gerokeke cuda an jî sepana xwecîhê Mastodonê bi kar bîne.",
+  "error.unexpected_crash.next_steps_addons": "Ne çalak kirin û nûkirina rûpelê biceribîne. Heke ev bi kêr neyê, dibe ku te hîn jî bi rêya gerokeke cuda an jî sepana xwecîhê Mastodonê bi kar bîne.",
+  "errors.unexpected_crash.copy_stacktrace": "Şopa gemara (stacktrace) tûrikê ra jê bigire",
+  "errors.unexpected_crash.report_issue": "Pirsgirêkekê ragihîne",
+  "follow_recommendations.done": "Qediya",
+  "follow_recommendations.heading": "Mirovên ku tu dixwazî ji wan peyaman bibînî bişopîne! Hin pêşnîyar li vir in.",
+  "follow_recommendations.lead": "Li gorî rêza kronolojîkî peyamên mirovên ku tu dişopînî dê demnameya te de xûya bike. Ji xeletiyan netirse, bi awayekî hêsan her wextî tu dikarî dev ji şopandinê berdî!",
+  "follow_request.authorize": "Mafê bide",
+  "follow_request.reject": "Nepejir",
+  "follow_requests.unlocked_explanation": "Tevlî ku ajimêra te ne kilît kiriye, karmendên {domain} digotin qey tu dixwazî ku pêşdîtina daxwazên şopandinê bi destan bike.",
+  "generic.saved": "Tomarkirî",
+  "getting_started.developers": "Pêşdebir",
+  "getting_started.directory": "Rêgeha profîlê",
+  "getting_started.documentation": "Pelbend",
+  "getting_started.heading": "Destpêkirin",
+  "getting_started.invite": "Mirovan Vexwîne",
+  "getting_started.open_source_notice": "Mastodon nermalava çavkaniya vekirî ye. Tu dikarî pirsgirêkan li ser GitHub-ê ragihînî di {github} de an jî dikarî tevkariyê bikî.",
+  "getting_started.security": "Sazkariyên ajimêr",
+  "getting_started.terms": "Mercên karûberan",
+  "hashtag.column_header.tag_mode.all": "û {additional}",
+  "hashtag.column_header.tag_mode.any": "an {additional}",
+  "hashtag.column_header.tag_mode.none": "bêyî {additional}",
+  "hashtag.column_settings.select.no_options_message": "Ti pêşniyar nehatin dîtin",
+  "hashtag.column_settings.select.placeholder": "Têkeve hashtagê…",
+  "hashtag.column_settings.tag_mode.all": "Van hemûyan",
+  "hashtag.column_settings.tag_mode.any": "Yek ji van",
+  "hashtag.column_settings.tag_mode.none": "Ne yek ji van",
+  "hashtag.column_settings.tag_toggle": "Ji bo vê stûnê hin pêvekan tevlî bike",
+  "home.column_settings.basic": "Bingehîn",
+  "home.column_settings.show_reblogs": "Boost'an nîşan bike",
+  "home.column_settings.show_replies": "Bersivan nîşan bide",
+  "home.hide_announcements": "Reklaman veşêre",
+  "home.show_announcements": "Reklaman nîşan bide",
+  "intervals.full.days": "{number, plural, one {# roj} other {# roj}}",
+  "intervals.full.hours": "{number, plural, one {# demjimêr} other {# demjimêr}}\n \n",
+  "intervals.full.minutes": "{number, plural, one {# xulek} other {# xulek}}",
+  "keyboard_shortcuts.back": "Vegere paşê",
+  "keyboard_shortcuts.blocked": "Rêzoka bikarhênerên astengkirî veke",
+  "keyboard_shortcuts.boost": "Şandiya parve (boost) bike",
+  "keyboard_shortcuts.column": "Stûna balkişandinê",
+  "keyboard_shortcuts.compose": "Bal bikşîne cîhê nivîsê/textarea",
+  "keyboard_shortcuts.description": "Danasîn",
+  "keyboard_shortcuts.direct": "Ji stûnê peyamên rasterast veke",
+  "keyboard_shortcuts.down": "Di rêzokê de dakêşe jêr",
+  "keyboard_shortcuts.enter": "Şandiyê veke",
+  "keyboard_shortcuts.favourite": "Şandiya bijarte",
+  "keyboard_shortcuts.favourites": "Rêzokên bijarte veke",
+  "keyboard_shortcuts.federated": "Demnameyê federalîkirî veke",
+  "keyboard_shortcuts.heading": "Kurterêyên klavyeyê",
+  "keyboard_shortcuts.home": "Demnameyê veke",
+  "keyboard_shortcuts.hotkey": "Bişkoka kurterê",
+  "keyboard_shortcuts.legend": "Vê çîrokê nîşan bike",
+  "keyboard_shortcuts.local": "Demnameya herêmî veke",
+  "keyboard_shortcuts.mention": "Qala nivîskarî/ê bike",
+  "keyboard_shortcuts.muted": "Rêzoka bikarhênerên bêdeng kirî veke",
+  "keyboard_shortcuts.my_profile": "Profîla xwe veke",
+  "keyboard_shortcuts.notifications": "Stûnê agahdariyan veke",
+  "keyboard_shortcuts.open_media": "Medya veke",
+  "keyboard_shortcuts.pinned": "Şandiyên derzîkirî veke",
+  "keyboard_shortcuts.profile": "Profîla nivîskaran veke",
+  "keyboard_shortcuts.reply": "Bersivê bide şandiyê",
+  "keyboard_shortcuts.requests": "Rêzoka daxwazên şopandinê veke",
+  "keyboard_shortcuts.search": "Bal bide şivika lêgerînê",
+  "keyboard_shortcuts.spoilers": "Zeviya hişyariya naverokê nîşan bide/veşêre",
+  "keyboard_shortcuts.start": "Stûna \"destpêkê\" veke",
+  "keyboard_shortcuts.toggle_hidden": "Nivîsa paş hişyariya naverokê nîşan bide/veşêre",
+  "keyboard_shortcuts.toggle_sensitivity": "Medyayê nîşan bide/veşêre",
+  "keyboard_shortcuts.toot": "Dest bi şandiyeke nû bike",
+  "keyboard_shortcuts.unfocus": "Bal nede cîhê nivîsê /lêgerînê",
+  "keyboard_shortcuts.up": "Di rêzokê de rake jor",
+  "lightbox.close": "Bigire",
+  "lightbox.compress": "Qutîya wêneya nîşan dike bitepisîne",
+  "lightbox.expand": "Qutîya wêneya nîşan dike fireh bike",
+  "lightbox.next": "Pêş",
+  "lightbox.previous": "Paş",
+  "lists.account.add": "Tevlî rêzokê bike",
+  "lists.account.remove": "Ji rêzokê rake",
+  "lists.delete": "Rêzokê jê bibe",
+  "lists.edit": "Rêzokê serrast bike",
+  "lists.edit.submit": "Sernavê biguherîne",
+  "lists.new.create": "Rêzokê tevlî bike",
+  "lists.new.title_placeholder": "Sernavê rêzoka nû",
+  "lists.replies_policy.followed": "Bikarhênereke şopandî",
+  "lists.replies_policy.list": "Endamên rêzokê",
+  "lists.replies_policy.none": "Ne yek",
+  "lists.replies_policy.title": "Bersivan nîşan bide:",
+  "lists.search": "Di navbera kesên ku te dişopînin bigere",
+  "lists.subheading": "Rêzokên te",
+  "load_pending": "{count, plural, one {# hêmaneke nû} other {#hêmaneke nû}}",
+  "loading_indicator.label": "Tê barkirin...",
+  "media_gallery.toggle_visible": "{number, plural, one {Wêneyê veşêre} other {Wêneyan veşêre}}",
+  "missing_indicator.label": "Nehate dîtin",
+  "missing_indicator.sublabel": "Ev çavkanî nehat dîtin",
+  "mute_modal.duration": "Dem",
+  "mute_modal.hide_notifications": "Agahdariyan ji ev bikarhêner veşêre?",
+  "mute_modal.indefinite": "Nediyar",
+  "navigation_bar.apps": "Sepana mobîl",
+  "navigation_bar.blocks": "Bikarhênerên astengkirî",
+  "navigation_bar.bookmarks": "Şûnpel",
+  "navigation_bar.community_timeline": "Demnameya herêmî",
+  "navigation_bar.compose": "Şandiyeke nû binivsîne",
+  "navigation_bar.direct": "Peyamên rasterast",
+  "navigation_bar.discover": "Vekolê",
+  "navigation_bar.domain_blocks": "Navparên astengkirî",
+  "navigation_bar.edit_profile": "Profîl serrast bike",
+  "navigation_bar.favourites": "Bijarte",
+  "navigation_bar.filters": "Peyvên bêdengkirî",
+  "navigation_bar.follow_requests": "Daxwazên şopandinê",
+  "navigation_bar.follows_and_followers": "Yên tê şopandin û şopîner",
+  "navigation_bar.info": "Derbarê vî rajekarî",
+  "navigation_bar.keyboard_shortcuts": "Bişkoka kurterê",
+  "navigation_bar.lists": "Rêzok",
+  "navigation_bar.logout": "Derkeve",
+  "navigation_bar.mutes": "Bikarhênerên bêdengkirî",
+  "navigation_bar.personal": "Kesanî",
+  "navigation_bar.pins": "Toot a derzîkirî",
+  "navigation_bar.preferences": "Hilbijarte",
+  "navigation_bar.public_timeline": "Demnameyê federalîkirî",
+  "navigation_bar.security": "Ewlehî",
+  "notification.favourite": "{name} şandiya te hez kir",
+  "notification.follow": "{name} te şopand",
+  "notification.follow_request": "{name} dixwazê te bişopîne",
+  "notification.mention": "{name} qale te kir",
+  "notification.own_poll": "Rapirsîya te qediya",
+  "notification.poll": "Rapirsiyeke ku te deng daye qediya",
+  "notification.reblog": "{name} şandiya te belav kir/ boost kir",
+  "notification.status": "{name} niha şand",
+  "notifications.clear": "Agahdariyan pak bike",
+  "notifications.clear_confirmation": "Bi rastî tu dixwazî bi awayekî dawî hemû agahdariyên xwe pak bikî?",
+  "notifications.column_settings.alert": "Agahdariyên sermaseyê",
+  "notifications.column_settings.favourite": "Bijarte:",
+  "notifications.column_settings.filter_bar.advanced": "Hemû beşan nîşan bide",
+  "notifications.column_settings.filter_bar.category": "Şivika parzûna bilêz",
+  "notifications.column_settings.filter_bar.show": "Nîşan bike",
+  "notifications.column_settings.follow": "Şopînerên nû:",
+  "notifications.column_settings.follow_request": "Daxwazên şopandinê nû:",
+  "notifications.column_settings.mention": "Qalkirin:",
+  "notifications.column_settings.poll": "Encamên rapirsiyê:",
+  "notifications.column_settings.push": "Agahdarîyên yekser",
+  "notifications.column_settings.reblog": "Bilindkirî:",
+  "notifications.column_settings.show": "Di nav stûnê de nîşan bike",
+  "notifications.column_settings.sound": "Deng lêxe",
+  "notifications.column_settings.status": "Şandiyên nû:",
+  "notifications.column_settings.unread_markers.category": "Nîşankerê agahdariyên nexwendî",
+  "notifications.filter.all": "Hemû",
+  "notifications.filter.boosts": "Bilindkirî",
+  "notifications.filter.favourites": "Bijarte",
+  "notifications.filter.follows": "Şopîner",
+  "notifications.filter.mentions": "Qalkirin",
+  "notifications.filter.polls": "Encamên rapirsiyê",
+  "notifications.filter.statuses": "Ji kesên tu dişopînî re rojanekirin",
+  "notifications.grant_permission": "Destûrê bide.",
+  "notifications.group": "{count} agahdarî",
+  "notifications.mark_as_read": "Hemî agahdarîya wek xwendî nîşan bike",
+  "notifications.permission_denied": "Agahdarîyên sermaseyê naxebite ji ber ku berê de daxwazî ya destûr dayîna gerokê hati bû red kirin",
+  "notifications.permission_denied_alert": "Agahdarîyên sermaseyê nay çalak kirin, ji ber ku destûr kirina gerokê pêşî de hati bû red kirin",
+  "notifications.permission_required": "Agahdarîyên sermaseyê naxebite çunkî mafê pêwîst dike nehatiye dayîn.",
+  "notifications_permission_banner.enable": "Agahdarîyên sermaseyê çalak bike",
+  "notifications_permission_banner.how_to_control": "Da ku agahdariyên mastodon bistînî gava ne vekirî be. Agahdariyên sermaseyê çalak bike\n Tu dikarî agahdariyên sermaseyê bi rê ve bibî ku bi hemû cureyên çalakiyên ên ku agahdariyan rû didin ku bi riya tikandînê li ser bişkoka {icon} çalak dibe.",
+  "notifications_permission_banner.title": "Tu tiştî bîr neke",
+  "picture_in_picture.restore": "Vegerîne paş",
+  "poll.closed": "Girtî",
+  "poll.refresh": "Nû bike",
+  "poll.total_people": "{count, plural, one {# kes} other {# kes}}",
+  "poll.total_votes": "{count, plural, one {# deng} other {# deng}}",
+  "poll.vote": "Deng bide",
+  "poll.voted": "Te dengê xwe da vê bersivê",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
+  "poll_button.add_poll": "Rapirsîyek zêde bike",
+  "poll_button.remove_poll": "Rapirsî yê rake",
+  "privacy.change": "Nepênîtiya şandiyan biguherîne",
+  "privacy.direct.long": "Tenê ji bo bikarhênerên qalkirî tê dîtin",
+  "privacy.direct.short": "Taybet",
+  "privacy.private.long": "Tenê bo şopîneran xuyabar e",
+  "privacy.private.short": "Tenê şopîneran",
+  "privacy.public.long": "Ji bo herkesî li berçav e, di demnameyên gelemperî de dê xûyakirin",
+  "privacy.public.short": "Gelemperî",
+  "privacy.unlisted.long": "Ji herkesî ra tê xûya, lê demnameyê gelemperî ra nay xûyakirin",
+  "privacy.unlisted.short": "Nerêzok",
+  "refresh": "Nû bike",
+  "regeneration_indicator.label": "Tê barkirin…",
+  "regeneration_indicator.sublabel": "Mala te da tê amedekirin!",
+  "relative_time.days": "{number}r",
+  "relative_time.hours": "{number}d",
+  "relative_time.just_now": "niha",
+  "relative_time.minutes": "{number}x",
+  "relative_time.seconds": "{number}ç",
+  "relative_time.today": "îro",
+  "reply_indicator.cancel": "Dev jê berde",
+  "report.forward": "Biçe bo {target}",
+  "report.forward_hint": "Ajimêr ji rajekarek din da ne. Tu kopîyeka anonîm ya raporê bişînî li wur?",
+  "report.hint": "Ev rapor yê rajekarê lihevkarên te ra were şandin. Tu dikarî şiroveyekê pêşkêş bikî bê ka tu çima vê ajimêrê jor radigîhînî:",
+  "report.placeholder": "Şiroveyên zêde",
+  "report.submit": "Bişîne",
+  "report.target": "Ragihandin {target}",
+  "search.placeholder": "Bigere",
+  "search_popout.search_format": "Dirûva lêgerîna pêşketî",
+  "search_popout.tips.full_text": "Nivîsên hêsan, şandiyên ku te nivîsandiye, bijare kiriye, bilind kiriye an jî yên behsa te kirine û her wiha navê bikarhêneran, navên xûya dike û hashtagan vedigerîne.",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "şandî",
+  "search_popout.tips.text": "Nivîsên hêsan, navên xûya ên ku li hev hatî, bikarhêner û hashtagan vedigerîne",
+  "search_popout.tips.user": "bikarhêner",
+  "search_results.accounts": "Mirov",
+  "search_results.hashtags": "Hashtag",
+  "search_results.statuses": "Şandî",
+  "search_results.statuses_fts_disabled": "Di vê rajekara Mastodonê da lêgerîna şandîyên li gorî naveroka wan ne çalak e.",
+  "search_results.total": "{count, number} {count, plural, one {encam} other {encam}}",
+  "status.admin_account": "Ji bo @{name} navrûya venihêrtinê veke",
+  "status.admin_status": "Vê şandîyê di navrûya venihêrtinê de veke",
+  "status.block": "@{name} asteng bike",
+  "status.bookmark": "Şûnpel",
+  "status.cancel_reblog_private": "Bilind neke",
+  "status.cannot_reblog": "Ev şandî nayê bilindkirin",
+  "status.copy": "Girêdanê jê bigire bo weşankirinê",
+  "status.delete": "Jê bibe",
+  "status.detailed_status": "Dîtina axaftina berfireh",
+  "status.direct": "Peyama rasterast @{name}",
+  "status.embed": "Hedimandî",
+  "status.favourite": "Bijarte",
+  "status.filtered": "Parzûnkirî",
+  "status.load_more": "Bêtir bar bike",
+  "status.media_hidden": "Medya veşartî ye",
+  "status.mention": "Qal @{name} bike",
+  "status.more": "Bêtir",
+  "status.mute": "@{name} Bêdeng bike",
+  "status.mute_conversation": "Axaftinê bêdeng bike",
+  "status.open": "Vê şandiyê berferh bike",
+  "status.pin": "Li ser profîlê derzî bike",
+  "status.pinned": "Şandiya derzîkirî",
+  "status.read_more": "Bêtir bixwîne",
+  "status.reblog": "Bilindkirî",
+  "status.reblog_private": "Bi dîtina resen bilind bike",
+  "status.reblogged_by": "{name} bilind kir",
+  "status.reblogs.empty": "Kesekî hin ev şandî bilind nekiriye. Gava kesek bilind bike, ew ên li vir werin xuyakirin.",
+  "status.redraft": "Jê bibe & ji nû ve reşnivîs bike",
+  "status.remove_bookmark": "Şûnpêlê jê rake",
+  "status.reply": "Bersivê bide",
+  "status.replyAll": "Mijarê bibersivîne",
+  "status.report": "{name} gilî bike",
+  "status.sensitive_warning": "Naveroka hestiyarî",
+  "status.share": "Parve bike",
+  "status.show_less": "Kêmtir nîşan bide",
+  "status.show_less_all": "Ji bo hemîyan kêmtir nîşan bide",
+  "status.show_more": "Hêj zehftir nîşan bide",
+  "status.show_more_all": "Bêtir nîşan bide bo hemûyan",
+  "status.show_thread": "Mijarê nîşan bide",
+  "status.uncached_media_warning": "Tune ye",
+  "status.unmute_conversation": "Axaftinê bêdeng neke",
+  "status.unpin": "Şandiya derzîkirî ji profîlê rake",
+  "suggestions.dismiss": "Pêşniyarê paşguh bike",
+  "suggestions.header": "Dibe ku bala te bikşîne…",
+  "tabs_bar.federated_timeline": "Giştî",
+  "tabs_bar.home": "Serrûpel",
+  "tabs_bar.local_timeline": "Herêmî",
+  "tabs_bar.notifications": "Agahdarî",
+  "tabs_bar.search": "Bigere",
+  "time_remaining.days": "{number, plural, one {# roj} other {# roj}} mayî",
+  "time_remaining.hours": "{number, plural, one {# demjimêr} other {# demjimêr}} mayî",
+  "time_remaining.minutes": "{number, plural, one {# xulek} other {# xulek}} mayî",
+  "time_remaining.moments": "Demên mayî",
+  "time_remaining.seconds": "{number, plural, one {# çirke} other {# çirke}} maye",
+  "timeline_hint.remote_resource_not_displayed": "{resource} Ji rajekerên din nayê dîtin.",
+  "timeline_hint.resources.followers": "Şopîner",
+  "timeline_hint.resources.follows": "Şopîner",
+  "timeline_hint.resources.statuses": "Şandiyên kevn",
+  "trends.counter_by_accounts": "{count, plural, one {{counter} kes} other {{counter} kes}} diaxivin",
+  "trends.trending_now": "Rojev",
+  "ui.beforeunload": "Ger ji Mastodonê veketi wê reşnivîsa te jî winda bibe.",
+  "units.short.billion": "{count}B",
+  "units.short.million": "{count}M",
+  "units.short.thousand": "{count}H",
+  "upload_area.title": "Ji bo barkirinê kaş bike û deyne",
+  "upload_button.label": "Wêne, vîdeoyek an jî pelê dengî tevlî bike",
+  "upload_error.limit": "Sînora barkirina pelan derbas bû.",
+  "upload_error.poll": "Di rapirsîyan de mafê barkirina pelan nayê dayîn.",
+  "upload_form.audio_description": "Ji bona kesên kêm dibihîsin re pênase bike",
+  "upload_form.description": "Ji bona astengdarên dîtinê re vebêje",
+  "upload_form.edit": "Serrast bike",
+  "upload_form.thumbnail": "Wêneyê biçûk biguherîne",
+  "upload_form.undo": "Jê bibe",
+  "upload_form.video_description": "Ji bo kesên kerr û lalan pênase bike",
+  "upload_modal.analyzing_picture": "Wêne tê analîzkirin…",
+  "upload_modal.apply": "Bisepîne",
+  "upload_modal.applying": "Applying…",
+  "upload_modal.choose_image": "Wêneyê hilbijêre",
+  "upload_modal.description_placeholder": "Rovîyek qehweyî û bilez li ser kûçikê tîral banz dide",
+  "upload_modal.detect_text": "Ji nivîsa wêneyê re serwext be",
+  "upload_modal.edit_media": "Medyayê sererast bike",
+  "upload_modal.hint": "Ji bo hilbijartina xala navendê her tim dîmenê piçûk de pêşdîtina çerxê bitikîne an jî kaş bike.",
+  "upload_modal.preparing_ocr": "OCR dihê amadekirin…",
+  "upload_modal.preview_label": "Pêşdîtin ({ratio})",
+  "upload_progress.label": "Tê barkirin...",
+  "video.close": "Vîdyoyê bigire",
+  "video.download": "Pelê daxe",
+  "video.exit_fullscreen": "Ji dîmendera tijî derkeve",
+  "video.expand": "Vîdyoyê berferh bike",
+  "video.fullscreen": "Dimendera tijî",
+  "video.hide": "Vîdyo veşêre",
+  "video.mute": "Dengê qut bike",
+  "video.pause": "Rawestîne",
+  "video.play": "Vêxe",
+  "video.unmute": "Dengê qut neke"
+}
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index 2f0dd9fd0..72b8f7f0a 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "ಅಯ್ಯೋ!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 32e5d60f6..4252141e7 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -13,7 +13,7 @@
   "account.domain_blocked": "도메인 차단됨",
   "account.edit_profile": "프로필 편집",
   "account.enable_notifications": "@{name} 의 게시물 알림 켜기",
-  "account.endorse": "프로필에 보이기",
+  "account.endorse": "프로필에 추천하기",
   "account.follow": "팔로우",
   "account.followers": "팔로워",
   "account.followers.empty": "아직 아무도 이 유저를 팔로우하고 있지 않습니다.",
@@ -39,45 +39,50 @@
   "account.requested": "승인 대기 중. 클릭해서 취소하기",
   "account.share": "@{name}의 프로필 공유",
   "account.show_reblogs": "@{name}의 부스트 보기",
-  "account.statuses_counter": "{counter} 툿",
+  "account.statuses_counter": "{counter} 게시물",
   "account.unblock": "차단 해제",
-  "account.unblock_domain": "{domain} 차단 해제",
-  "account.unendorse": "프로필에 나타내지 않기",
+  "account.unblock_domain": "도메인 {domain} 차단 해제",
+  "account.unendorse": "프로필에 추천하지 않기",
   "account.unfollow": "팔로우 해제",
-  "account.unmute": "뮤트 해제",
+  "account.unmute": "@{name} 뮤트 해제",
   "account.unmute_notifications": "@{name}의 알림 뮤트 해제",
   "account_note.placeholder": "클릭해서 노트 추가",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "{retry_time, time, medium}에 다시 시도해 주세요.",
-  "alert.rate_limited.title": "빈도 제한",
+  "alert.rate_limited.title": "빈도 제한됨",
   "alert.unexpected.message": "예측하지 못한 에러가 발생했습니다.",
   "alert.unexpected.title": "앗!",
   "announcement.announcement": "공지사항",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "주간 {count}회",
-  "boost_modal.combo": "{combo}를 누르면 다음부터 이 과정을 건너뛸 수 있습니다",
+  "boost_modal.combo": "다음엔 {combo}를 눌러서 이 과정을 건너뛸 수 있습니다",
   "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
   "bundle_column_error.retry": "다시 시도",
   "bundle_column_error.title": "네트워크 에러",
   "bundle_modal_error.close": "닫기",
   "bundle_modal_error.message": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
   "bundle_modal_error.retry": "다시 시도",
-  "column.blocks": "차단 중인 사용자",
+  "column.blocks": "차단된 사용자",
   "column.bookmarks": "보관함",
   "column.community": "로컬 타임라인",
   "column.direct": "다이렉트 메시지",
   "column.directory": "프로필 둘러보기",
-  "column.domain_blocks": "숨겨진 도메인",
+  "column.domain_blocks": "차단된 도메인",
   "column.favourites": "즐겨찾기",
   "column.follow_requests": "팔로우 요청",
   "column.home": "홈",
   "column.lists": "리스트",
-  "column.mutes": "뮤트 중인 사용자",
+  "column.mutes": "뮤트된 사용자",
   "column.notifications": "알림",
-  "column.pins": "고정된 툿",
+  "column.pins": "고정된 게시물",
   "column.public": "연합 타임라인",
   "column_back_button.label": "돌아가기",
   "column_header.hide_settings": "설정 숨기기",
-  "column_header.moveLeft_settings": "왼쪽으로 이동",
-  "column_header.moveRight_settings": "오른쪽으로 이동",
+  "column_header.moveLeft_settings": "컬럼을 왼쪽으로 이동",
+  "column_header.moveRight_settings": "컬럼을 오른쪽으로 이동",
   "column_header.pin": "고정하기",
   "column_header.show_settings": "설정 보이기",
   "column_header.unpin": "고정 해제",
@@ -102,19 +107,21 @@
   "compose_form.sensitive.hide": "미디어를 민감함으로 설정하기",
   "compose_form.sensitive.marked": "미디어가 열람주의로 설정되어 있습니다",
   "compose_form.sensitive.unmarked": "미디어가 열람주의로 설정 되어 있지 않습니다",
-  "compose_form.spoiler.marked": "열람주의가 설정되어 있습니다",
-  "compose_form.spoiler.unmarked": "열람주의가 설정 되어 있지 않습니다",
-  "compose_form.spoiler_placeholder": "경고",
+  "compose_form.spoiler.marked": "열람 주의 제거",
+  "compose_form.spoiler.unmarked": "열람 주의가 설정 되어 있지 않습니다",
+  "compose_form.spoiler_placeholder": "경고 문구를 여기에 작성하세요",
   "confirmation_modal.cancel": "취소",
   "confirmations.block.block_and_report": "차단하고 신고하기",
   "confirmations.block.confirm": "차단",
   "confirmations.block.message": "정말로 {name}를 차단하시겠습니까?",
   "confirmations.delete.confirm": "삭제",
-  "confirmations.delete.message": "정말로 삭제하시겠습니까?",
+  "confirmations.delete.message": "정말로 이 게시물을 삭제하시겠습니까?",
   "confirmations.delete_list.confirm": "삭제",
   "confirmations.delete_list.message": "정말로 이 리스트를 영구적으로 삭제하시겠습니까?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "도메인 전체를 차단",
-  "confirmations.domain_block.message": "정말로 {domain} 전체를 차단하시겠습니까? 대부분의 경우 개별 차단이나 뮤트로 충분합니다. 모든 공개 타임라인과 알림에서 해당 도메인에서 작성된 컨텐츠를 보지 못합니다. 해당 도메인 팔로워와의 관계가 사라집니다.",
+  "confirmations.domain_block.message": "정말로 {domain} 전체를 차단하시겠습니까? 대부분의 경우 개별 차단이나 뮤트로 충분합니다. 모든 공개 타임라인과 알림에서 해당 도메인에서 작성된 컨텐츠를 보지 못합니다. 해당 도메인에 속한 팔로워와의 관계가 사라집니다.",
   "confirmations.logout.confirm": "로그아웃",
   "confirmations.logout.message": "정말로 로그아웃 하시겠습니까?",
   "confirmations.mute.confirm": "뮤트",
@@ -138,28 +145,28 @@
   "embed.preview": "다음과 같이 표시됩니다:",
   "emoji_button.activity": "활동",
   "emoji_button.custom": "커스텀",
-  "emoji_button.flags": "국기",
-  "emoji_button.food": "음식",
+  "emoji_button.flags": "깃발",
+  "emoji_button.food": "음식과 마실것",
   "emoji_button.label": "에모지를 추가",
   "emoji_button.nature": "자연",
-  "emoji_button.not_found": "없어!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "맞는 에모지가 없습니다",
   "emoji_button.objects": "물건",
   "emoji_button.people": "사람들",
-  "emoji_button.recent": "자주 사용 됨",
+  "emoji_button.recent": "자주 사용됨",
   "emoji_button.search": "검색...",
   "emoji_button.search_results": "검색 결과",
   "emoji_button.symbols": "기호",
   "emoji_button.travel": "여행과 장소",
   "empty_column.account_suspended": "계정 정지됨",
-  "empty_column.account_timeline": "여긴 툿이 없어요!",
+  "empty_column.account_timeline": "여긴 게시물이 없어요!",
   "empty_column.account_unavailable": "프로필 사용 불가",
   "empty_column.blocks": "아직 아무도 차단하지 않았습니다.",
-  "empty_column.bookmarked_statuses": "아직 보관한 툿이 없습니다. 툿을 보관하면 여기에 나타납니다.",
+  "empty_column.bookmarked_statuses": "아직 보관한 게시물이 없습니다. 게시물을 보관하면 여기에 나타납니다.",
   "empty_column.community": "로컬 타임라인에 아무 것도 없습니다. 아무거나 적어 보세요!",
   "empty_column.direct": "아직 다이렉트 메시지가 없습니다. 다이렉트 메시지를 보내거나 받은 경우, 여기에 표시 됩니다.",
-  "empty_column.domain_blocks": "아직 숨겨진 도메인이 없습니다.",
-  "empty_column.favourited_statuses": "아직 즐겨찾기 한 툿이 없습니다. 툿을 즐겨찾기 하면 여기에 나타납니다.",
-  "empty_column.favourites": "아직 아무도 이 툿을 즐겨찾기 하지 않았습니다. 누군가 즐겨찾기를 하면 여기에 그들이 나타납니다.",
+  "empty_column.domain_blocks": "아직 차단된 도메인이 없습니다.",
+  "empty_column.favourited_statuses": "아직 즐겨찾기 한 게시물이 없습니다. 게시물을 즐겨찾기 하면 여기에 나타납니다.",
+  "empty_column.favourites": "아직 아무도 이 게시물을 즐겨찾기 하지 않았습니다. 누군가 즐겨찾기를 하면 여기에 나타납니다.",
   "empty_column.follow_recommendations": "당신을 위한 제안이 생성될 수 없는 것 같습니다. 알 수도 있는 사람을 검색하거나 유행하는 해시태그를 둘러볼 수 있습니다.",
   "empty_column.follow_requests": "아직 팔로우 요청이 없습니다. 요청을 받았을 때 여기에 나타납니다.",
   "empty_column.hashtag": "이 해시태그는 아직 사용되지 않았습니다.",
@@ -189,18 +196,18 @@
   "getting_started.heading": "시작",
   "getting_started.invite": "초대",
   "getting_started.open_source_notice": "Mastodon은 오픈 소스 소프트웨어입니다. 누구나 GitHub({github})에서 개발에 참여하거나, 문제를 보고할 수 있습니다.",
-  "getting_started.security": "보안",
+  "getting_started.security": "계정 설정",
   "getting_started.terms": "이용 약관",
   "hashtag.column_header.tag_mode.all": "그리고 {additional}",
   "hashtag.column_header.tag_mode.any": "또는 {additional}",
-  "hashtag.column_header.tag_mode.none": "({additional}를 제외)",
-  "hashtag.column_settings.select.no_options_message": "추천 할 내용이 없습니다",
+  "hashtag.column_header.tag_mode.none": "{additional}를 제외하고",
+  "hashtag.column_settings.select.no_options_message": "추천할 내용이 없습니다",
   "hashtag.column_settings.select.placeholder": "해시태그를 입력하세요…",
   "hashtag.column_settings.tag_mode.all": "모두",
   "hashtag.column_settings.tag_mode.any": "아무것이든",
   "hashtag.column_settings.tag_mode.none": "이것들을 제외하고",
   "hashtag.column_settings.tag_toggle": "추가 해시태그를 이 컬럼에 추가합니다",
-  "home.column_settings.basic": "기본 설정",
+  "home.column_settings.basic": "기본",
   "home.column_settings.show_reblogs": "부스트 표시",
   "home.column_settings.show_replies": "답글 표시",
   "home.hide_announcements": "공지사항 숨기기",
@@ -218,7 +225,7 @@
   "keyboard_shortcuts.down": "리스트에서 아래로 이동",
   "keyboard_shortcuts.enter": "게시물 열기",
   "keyboard_shortcuts.favourite": "관심글 지정",
-  "keyboard_shortcuts.favourites": "즐겨찾기 리스트 열기",
+  "keyboard_shortcuts.favourites": "즐겨찾기 목록 열기",
   "keyboard_shortcuts.federated": "연합 타임라인 열기",
   "keyboard_shortcuts.heading": "키보드 단축키",
   "keyboard_shortcuts.home": "홈 타임라인 열기",
@@ -272,10 +279,10 @@
   "navigation_bar.blocks": "차단한 사용자",
   "navigation_bar.bookmarks": "보관함",
   "navigation_bar.community_timeline": "로컬 타임라인",
-  "navigation_bar.compose": "새 툿 작성",
+  "navigation_bar.compose": "새 게시물 작성",
   "navigation_bar.direct": "다이렉트 메시지",
   "navigation_bar.discover": "발견하기",
-  "navigation_bar.domain_blocks": "숨겨진 도메인",
+  "navigation_bar.domain_blocks": "차단된 도메인",
   "navigation_bar.edit_profile": "프로필 편집",
   "navigation_bar.favourites": "즐겨찾기",
   "navigation_bar.filters": "뮤트된 단어",
@@ -287,7 +294,7 @@
   "navigation_bar.logout": "로그아웃",
   "navigation_bar.mutes": "뮤트 중인 사용자",
   "navigation_bar.personal": "개인용",
-  "navigation_bar.pins": "고정된 툿",
+  "navigation_bar.pins": "고정된 게시물",
   "navigation_bar.preferences": "사용자 설정",
   "navigation_bar.public_timeline": "연합 타임라인",
   "navigation_bar.security": "보안",
@@ -314,7 +321,7 @@
   "notifications.column_settings.reblog": "부스트:",
   "notifications.column_settings.show": "컬럼에 표시",
   "notifications.column_settings.sound": "효과음 재생",
-  "notifications.column_settings.status": "새 툿:",
+  "notifications.column_settings.status": "새 게시물:",
   "notifications.column_settings.unread_markers.category": "읽지 않음 알림 마커",
   "notifications.filter.all": "모두",
   "notifications.filter.boosts": "부스트",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count} 표",
   "poll.vote": "투표",
   "poll.voted": "이 답변에 투표했습니다",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "투표 추가",
   "poll_button.remove_poll": "투표 삭제",
   "privacy.change": "포스트의 프라이버시 설정을 변경",
@@ -399,14 +407,14 @@
   "status.mute_conversation": "이 대화를 뮤트",
   "status.open": "상세 정보 표시",
   "status.pin": "고정",
-  "status.pinned": "고정 된 게시물",
+  "status.pinned": "고정된 게시물",
   "status.read_more": "더 보기",
   "status.reblog": "부스트",
   "status.reblog_private": "원래의 수신자들에게 부스트",
   "status.reblogged_by": "{name} 님이 부스트 했습니다",
   "status.reblogs.empty": "아직 아무도 이 게시물을 부스트하지 않았습니다. 부스트 한 사람들이 여기에 표시 됩니다.",
   "status.redraft": "지우고 다시 쓰기",
-  "status.remove_bookmark": "보관한 툿 삭제",
+  "status.remove_bookmark": "보관한 게시물 삭제",
   "status.reply": "답장",
   "status.replyAll": "전원에게 답장",
   "status.report": "신고",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "청각, 시각 장애인을 위한 설명",
   "upload_modal.analyzing_picture": "이미지 분석 중…",
   "upload_modal.apply": "적용",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "이미지 선택",
   "upload_modal.description_placeholder": "다람쥐 헌 쳇바퀴 타고파",
   "upload_modal.detect_text": "이미지에서 텍스트 추출",
diff --git a/app/javascript/mastodon/locales/ku.json b/app/javascript/mastodon/locales/ku.json
index fadc6b3a6..2f0c4e511 100644
--- a/app/javascript/mastodon/locales/ku.json
+++ b/app/javascript/mastodon/locales/ku.json
@@ -47,11 +47,16 @@
   "account.unmute": "بێدەنگکردنی @{name}",
   "account.unmute_notifications": "بێدەنگکردنی هۆشیارییەکان لە @{name}",
   "account_note.placeholder": "کرتەبکە بۆ زیادکردنی تێبینی",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "تکایە هەوڵبدەرەوە دوای {retry_time, time, medium}.",
   "alert.rate_limited.title": "ڕێژەی سنووردار",
   "alert.unexpected.message": "هەڵەیەکی چاوەڕوان نەکراو ڕوویدا.",
   "alert.unexpected.title": "تەححح!",
   "announcement.announcement": "بانگەواز",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} هەرهەفتە",
   "boost_modal.combo": "دەتوانیت دەست بنێی بە سەر {combo} بۆ بازدان لە جاری داهاتوو",
   "bundle_column_error.body": "هەڵەیەک ڕوویدا لەکاتی بارکردنی ئەم پێکهاتەیە.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "ئایا دڵنیایت لەوەی دەتەوێت ئەم توتە بسڕیتەوە?",
   "confirmations.delete_list.confirm": "سڕینەوە",
   "confirmations.delete_list.message": "ئایا دڵنیایت لەوەی دەتەوێت بە هەمیشەیی ئەم لیستە بسڕیتەوە?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "بلۆککردنی هەموو دۆمەینەکە",
   "confirmations.domain_block.message": "ئایا بەڕاستی، بەڕاستی تۆ دەتەوێت هەموو {domain} بلۆک بکەیت؟ لە زۆربەی حاڵەتەکاندا چەند بلۆکێکی ئامانجدار یان بێدەنگەکان پێویست و پەسەندن. تۆ ناوەڕۆک ێک نابینیت لە دۆمەینەکە لە هیچ هێڵی کاتی گشتی یان ئاگانامەکانت. شوێنکەوتوانی تۆ لەو دۆمەینەوە لادەبرێن.",
   "confirmations.logout.confirm": "چوونە دەرەوە",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# دەنگ} other {# دەنگ}}\n",
   "poll.vote": "دەنگ",
   "poll.voted": "تۆ دەنگت بەو وەڵامە دا",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "ڕاپرسییەک زیاد بکە",
   "poll_button.remove_poll": "ده‌نگدان بسڕه‌وه‌‌",
   "privacy.change": "ڕێکخستنی تایبەتمەندی توت",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "بۆ کەم بینایان و کەم بیستان وەسفی بکە",
   "upload_modal.analyzing_picture": "شیکردنەوەی وێنە…",
   "upload_modal.apply": "جێبەجێ کردن",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "وێنە هەڵبژێرە",
   "upload_modal.description_placeholder": "بە دڵ کەین با بە نەشئەی مەی غوباری میحنەتی دونیا",
   "upload_modal.detect_text": "دەقی وێنەکە بدۆزیەوە",
diff --git a/app/javascript/mastodon/locales/kw.json b/app/javascript/mastodon/locales/kw.json
index 812215cc7..14dcd9618 100644
--- a/app/javascript/mastodon/locales/kw.json
+++ b/app/javascript/mastodon/locales/kw.json
@@ -47,11 +47,16 @@
   "account.unmute": "Antawhe @{name}",
   "account.unmute_notifications": "Antawhe gwarnyansow a @{name}",
   "account_note.placeholder": "Klyckya dhe geworra noten",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Assayewgh arta mar pleg wosa {retry_time, time, medium}.",
   "alert.rate_limited.title": "Kevradh finwethys",
   "alert.unexpected.message": "Gwall anwaytyadow re dharva.",
   "alert.unexpected.title": "Oups!",
   "announcement.announcement": "Deklaryans",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} an seythen",
   "boost_modal.combo": "Hwi a yll gwaska {combo} dhe woheles hemma an nessa tro",
   "bundle_column_error.body": "Neppyth eth yn kamm ow karga'n elven ma.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Owgh hwi sur a vynnes dilea'n post ma?",
   "confirmations.delete_list.confirm": "Dilea",
   "confirmations.delete_list.message": "Owgh hwi sur a vynnes dilea'n rol ma yn fast?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Lettya gorfarth dhien",
   "confirmations.domain_block.message": "Owgh hwi wir, wir sur a vynnes lettya'n {domain} dhien? Y'n brassa rann a gasow, boghes lettyansow medrys po tawheansow yw lowr ha gwell. Ny wrewgh hwi gweles dalgh a'n worfarth na yn py amserlin boblek pynag po yn agas gwarnyansow. Agas holyoryon an worfarth na a vydh diles.",
   "confirmations.logout.confirm": "Digelmi",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# raglev} other {# raglev}}",
   "poll.vote": "Ragleva",
   "poll.voted": "Hwi a wrug ragleva'n gorthyp ma",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Keworra sondyans",
   "poll_button.remove_poll": "Dilea sondyans",
   "privacy.change": "Chanjya privetter an post",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Deskrifa rag tus vodharek po dallek",
   "upload_modal.analyzing_picture": "Ow tytratya skeusen…",
   "upload_modal.apply": "Gweytha",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Dewis aven",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Kilela tekst a skeusen",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index 22fa09706..0c1a46de3 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oi!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index 0b56c6f13..4d65daa83 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -4,54 +4,59 @@
   "account.badges.bot": "Bots",
   "account.badges.group": "Grupa",
   "account.block": "Bloķēt @{name}",
-  "account.block_domain": "Slēpt visu no {domain}",
+  "account.block_domain": "Bloķēt domēnu {domain}",
   "account.blocked": "Bloķēts",
   "account.browse_more_on_origin_server": "Pārlūkot vairāk sākotnējā profilā",
-  "account.cancel_follow_request": "Atcelt pieprasījumu",
-  "account.direct": "Privātā ziņa @{name}",
-  "account.disable_notifications": "Stop notifying me when @{name} posts",
-  "account.domain_blocked": "Domēns ir paslēpts",
-  "account.edit_profile": "Labot profilu",
+  "account.cancel_follow_request": "Atcelt sekošanas pieprasījumu",
+  "account.direct": "Privāta ziņa @{name}",
+  "account.disable_notifications": "Pārtraukt man paziņot, kad @{name} publicē ierakstu",
+  "account.domain_blocked": "Domēns ir bloķēts",
+  "account.edit_profile": "Rediģēt profilu",
   "account.enable_notifications": "Man paziņot, kad @{name} publicē ierakstu",
   "account.endorse": "Izcelts profilā",
   "account.follow": "Sekot",
   "account.followers": "Sekotāji",
-  "account.followers.empty": "Šim lietotājam nav sekotāju.",
-  "account.followers_counter": "{count, plural, zero {{counter} sekotāju} one {{counter} sekotājs} other {{counter} sekotāji}}",
-  "account.following_counter": "{count, plural, zero {Seko {counter} kontiem} one {Seko {counter} kontam} other {Seko {counter} kontiem}}",
+  "account.followers.empty": "Šim lietotājam patreiz nav sekotāju.",
+  "account.followers_counter": "{count, plural, one {{counter} Sekotājs} other {{counter} Sekotāji}}",
+  "account.following_counter": "{count, plural, one {{counter} Sekojošs} other {{counter} Sekojoši}}",
   "account.follows.empty": "Šis lietotājs pagaidām nevienam neseko.",
   "account.follows_you": "Seko tev",
   "account.hide_reblogs": "Paslēpt paceltos ierakstus no lietotāja @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Pievienojās {date}",
   "account.last_status": "Pēdējā aktivitāte",
   "account.link_verified_on": "Šīs saites piederība ir pārbaudīta {date}",
-  "account.locked_info": "Šī konta privātuma status ir iestatīts slēgts. Īpašnieks izskatīs un izvēlēsies kas viņam drīkst sekot.",
-  "account.media": "Mēdiji",
+  "account.locked_info": "Šī konta privātuma statuss ir slēgts. Īpašnieks izskatīs, kurš viņam drīkst sekot.",
+  "account.media": "Mediji",
   "account.mention": "Piemin @{name}",
-  "account.moved_to": "{name} ir pārvācies uz:",
+  "account.moved_to": "{name} ir pārcelts uz:",
   "account.mute": "Apklusināt @{name}",
   "account.mute_notifications": "Nerādīt paziņojumus no @{name}",
   "account.muted": "Apklusināts",
   "account.never_active": "Nekad",
-  "account.posts": "Ieraksti",
-  "account.posts_with_replies": "Ieraksti un atbildes",
+  "account.posts": "Ziņas",
+  "account.posts_with_replies": "Ziņas un atbildes",
   "account.report": "Ziņot par lietotāju @{name}",
   "account.requested": "Gaidām apstiprinājumu. Nospied lai atceltu sekošanas pieparasījumu",
-  "account.share": "Dalīties ar lietotāja @{name}'s profilu",
-  "account.show_reblogs": "Parādīt lietotāja @{name} paceltos ierakstus",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
+  "account.share": "Dalīties ar @{name} profilu",
+  "account.show_reblogs": "Parādīt @{name} paaugstinātās ziņas",
+  "account.statuses_counter": "{count, plural, one {{counter} ziņa} other {{counter} ziņas}}",
   "account.unblock": "Atbloķēt lietotāju @{name}",
   "account.unblock_domain": "Atbloķēt domēnu {domain}",
-  "account.unendorse": "Neizcelt profilā",
-  "account.unfollow": "Nesekot",
-  "account.unmute": "Noņemt apklusinājumu no lietotāja @{name}",
+  "account.unendorse": "Neattēlot profilā",
+  "account.unfollow": "Pārstāt sekot",
+  "account.unmute": "Noņemt apklusinājumu @{name}",
   "account.unmute_notifications": "Rādīt paziņojumus no lietotāja @{name}",
   "account_note.placeholder": "Noklikšķiniet, lai pievienotu piezīmi",
-  "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
-  "alert.rate_limited.title": "Rate limited",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
+  "alert.rate_limited.message": "Lūdzu, mēģini vēlreiz pāc {retry_time, time, medium}.",
+  "alert.rate_limited.title": "Biežums ierobežots",
   "alert.unexpected.message": "Negaidīta kļūda.",
   "alert.unexpected.title": "Ups!",
   "announcement.announcement": "Paziņojums",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} nedēļā",
   "boost_modal.combo": "Nospied {combo} lai izlaistu šo nākamreiz",
   "bundle_column_error.body": "Kaut kas nogāja greizi ielādējot šo komponenti.",
@@ -62,18 +67,18 @@
   "bundle_modal_error.retry": "Mēģini vēlreiz",
   "column.blocks": "Bloķētie lietotāji",
   "column.bookmarks": "Grāmatzīmes",
-  "column.community": "Lokālā laika līnija",
+  "column.community": "Vietējā ziņu līnija",
   "column.direct": "Privātās ziņas",
   "column.directory": "Pārlūkot profilus",
-  "column.domain_blocks": "Paslēptie domēni",
-  "column.favourites": "Favorīti",
-  "column.follow_requests": "Sekotāju pieprasījumi",
+  "column.domain_blocks": "Bloķētie domēni",
+  "column.favourites": "Izlase",
+  "column.follow_requests": "Sekošanas pieprasījumi",
   "column.home": "Sākums",
   "column.lists": "Saraksti",
   "column.mutes": "Apklusinātie lietotāji",
   "column.notifications": "Paziņojumi",
   "column.pins": "Piespraustie ziņojumi",
-  "column.public": "Federatīvā laika līnija",
+  "column.public": "Apvienotā ziņu lenta",
   "column_back_button.label": "Atpakaļ",
   "column_header.hide_settings": "Paslēpt iestatījumus",
   "column_header.moveLeft_settings": "Pārvietot kolonu pa kreisi",
@@ -83,66 +88,68 @@
   "column_header.unpin": "Atspraust",
   "column_subheading.settings": "Iestatījumi",
   "community.column_settings.local_only": "Tikai vietējie",
-  "community.column_settings.media_only": "Tikai mēdiji",
-  "community.column_settings.remote_only": "Tikai tālvadības",
+  "community.column_settings.media_only": "Tikai mediji",
+  "community.column_settings.remote_only": "Tikai attālinātie",
   "compose_form.direct_message_warning": "Šis ziņojums tiks nosūtīts tikai pieminētajiem lietotājiem.",
-  "compose_form.direct_message_warning_learn_more": "Papildus informācija",
+  "compose_form.direct_message_warning_learn_more": "Uzzināt vairāk",
   "compose_form.hashtag_warning": "Ziņojumu nebūs iespējams atrast zem haštagiem jo tas nav publisks. Tikai publiskos ziņojumus ir iespējams meklēt pēc tiem.",
   "compose_form.lock_disclaimer": "Tavs konts nav {locked}. Ikviens var Tev sekot lai apskatītu tikai sekotājiem paredzētos ziņojumus.",
   "compose_form.lock_disclaimer.lock": "slēgts",
-  "compose_form.placeholder": "Ko vēlies publicēt?",
+  "compose_form.placeholder": "Kas tev padomā?",
   "compose_form.poll.add_option": "Pievienot izvēli",
   "compose_form.poll.duration": "Aptaujas ilgums",
   "compose_form.poll.option_placeholder": "Izvēle Nr. {number}",
   "compose_form.poll.remove_option": "Noņemt šo izvēli",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
-  "compose_form.publish": "Publicēt",
+  "compose_form.poll.switch_to_multiple": "Maini aptaujas veidu, lai atļautu vairākas izvēles",
+  "compose_form.poll.switch_to_single": "Maini aptaujas veidu, lai atļautu vienu izvēli",
+  "compose_form.publish": "Taurēt",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
-  "compose_form.sensitive.marked": "Mēdijs ir atzīmēts kā sensitīvs",
-  "compose_form.sensitive.unmarked": "Mēdijs nav atzīmēts kā sensitīvs",
-  "compose_form.spoiler.marked": "Teksts ir paslēpts aiz brīdinājuma",
-  "compose_form.spoiler.unmarked": "Teksts nav paslēpts",
-  "compose_form.spoiler_placeholder": "Ieraksti Savu brīdinājuma tekstu šeit",
+  "compose_form.sensitive.hide": "{count, plural, one {Atzīmēt mediju kā sensitīvu} other {Atzīmēt medijus kā sensitīvus}}",
+  "compose_form.sensitive.marked": "{count, plural, one {Medijs ir atzīmēts kā sensitīvs} other {Mediji ir atzīmēti kā sensitīvi}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {Medijs nav atzīmēts kā sensitīvs} other {Mediji nav atzīmēti kā sensitīvi}}",
+  "compose_form.spoiler.marked": "Noņemt satura brīdinājumu",
+  "compose_form.spoiler.unmarked": "Pievienot satura brīdinājumu",
+  "compose_form.spoiler_placeholder": "Ieraksti savu brīdinājumu šeit",
   "confirmation_modal.cancel": "Atcelt",
-  "confirmations.block.block_and_report": "Block & Report",
+  "confirmations.block.block_and_report": "Bloķēt un ziņot",
   "confirmations.block.confirm": "Bloķēt",
   "confirmations.block.message": "Vai tiešām vēlies bloķēt lietotāju {name}?",
   "confirmations.delete.confirm": "Dzēst",
-  "confirmations.delete.message": "Vai tiešām vēlies dzēst šo ierakstu?",
+  "confirmations.delete.message": "Vai tiešām vēlaties dzēst šo ziņu?",
   "confirmations.delete_list.confirm": "Dzēst",
   "confirmations.delete_list.message": "Vai tiešam vēlies neatgriezeniski dzēst šo sarakstu?",
-  "confirmations.domain_block.confirm": "Paslēpt visu domēnu",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
+  "confirmations.domain_block.confirm": "Bloķēt visu domēnu",
   "confirmations.domain_block.message": "Vai tu tiešām, tiešam vēlies bloķēt visu domēnu {domain}? Lielākajā daļā gadījumu pietiek ja nobloķē vai apklusini kādu. Tu neredzēsi saturu vai paziņojumus no šī domēna nevienā laika līnijā. Tavi sekotāji no šī domēna tiks noņemti.",
-  "confirmations.logout.confirm": "Log out",
-  "confirmations.logout.message": "Are you sure you want to log out?",
+  "confirmations.logout.confirm": "Iziet",
+  "confirmations.logout.message": "Vai tiešām vēlies izrakstīties?",
   "confirmations.mute.confirm": "Apklusināt",
-  "confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
+  "confirmations.mute.explanation": "Šādi no viņiem tiks slēptas ziņas un ziņas, kurās viņi tiek pieminēti, taču viņi joprojām varēs redzēt tavas ziņas un sekot tev.",
   "confirmations.mute.message": "Vai Tu tiešām velies apklusināt {name}?",
   "confirmations.redraft.confirm": "Dzēst un pārrakstīt",
   "confirmations.redraft.message": "Vai tiešām vēlies dzēst un pārrakstīt šo ierakstu? Favorīti un paceltie ieraksti tiks dzēsti, kā arī atbildes tiks atsaistītas no šī ieraksta.",
   "confirmations.reply.confirm": "Atbildēt",
   "confirmations.reply.message": "Atbildot tagad tava ziņa ko šobrīd raksti tiks pārrakstīta. Vai tiešām vēlies turpināt?",
-  "confirmations.unfollow.confirm": "Nesekot",
+  "confirmations.unfollow.confirm": "Pārstāt sekot",
   "confirmations.unfollow.message": "Vai tiešam vairs nevēlies sekot lietotājam {name}?",
-  "conversation.delete": "Delete conversation",
-  "conversation.mark_as_read": "Mark as read",
-  "conversation.open": "View conversation",
-  "conversation.with": "With {names}",
-  "directory.federated": "From known fediverse",
-  "directory.local": "From {domain} only",
-  "directory.new_arrivals": "New arrivals",
-  "directory.recently_active": "Recently active",
-  "embed.instructions": "Iegul šo ziņojumu savā mājaslapā kopējot kodu zemāk.",
+  "conversation.delete": "Dzēst sarunu",
+  "conversation.mark_as_read": "Atzīmēt kā izlasītu",
+  "conversation.open": "Skatīt sarunu",
+  "conversation.with": "Ar {names}",
+  "directory.federated": "No pazīstamas federācijas",
+  "directory.local": "Tikai no {domain}",
+  "directory.new_arrivals": "Jaunpienācēji",
+  "directory.recently_active": "Nesen aktīvs",
+  "embed.instructions": "Iestrādā šo ziņu savā mājaslapā, kopējot zemāk redzmo kodu.",
   "embed.preview": "Tas izskatīsies šādi:",
   "emoji_button.activity": "Aktivitāte",
   "emoji_button.custom": "Pielāgots",
   "emoji_button.flags": "Karogi",
   "emoji_button.food": "Ēdieni un dzērieni",
-  "emoji_button.label": "Ielikt emoji smaidiņu",
+  "emoji_button.label": "Ievietot emocijzīmi",
   "emoji_button.nature": "Daba",
-  "emoji_button.not_found": "Nekādu emodžīšu!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Neviena atbilstoša emocijzīme netika atrasta",
   "emoji_button.objects": "Objekti",
   "emoji_button.people": "Cilvēki",
   "emoji_button.recent": "Biežāk lietotie",
@@ -150,326 +157,328 @@
   "emoji_button.search_results": "Meklēšanas rezultāti",
   "emoji_button.symbols": "Simboli",
   "emoji_button.travel": "Ceļošana & Vietas",
-  "empty_column.account_suspended": "Account suspended",
+  "empty_column.account_suspended": "Konta darbība ir apturēta",
   "empty_column.account_timeline": "Šeit ziņojumu nav!",
-  "empty_column.account_unavailable": "Profile unavailable",
-  "empty_column.blocks": "Tu neesi vēl nevienu bloķējis.",
-  "empty_column.bookmarked_statuses": "You don't have any bookmarked toots yet. When you bookmark one, it will show up here.",
-  "empty_column.community": "Lokālā laika līnija ir tukša. :/ Ieraksti kaut ko lai sākas rosība!",
-  "empty_column.direct": "Tev nav privāto ziņu. Tiklīdz saņemsi tās šeit parādīsies.",
-  "empty_column.domain_blocks": "Slēpto domēnu vēl nav.",
-  "empty_column.favourited_statuses": "Tev vēl nav iemīļoto ziņojumu. Kad Tev tādu būs tie šeit parādīsies.",
-  "empty_column.favourites": "Neviens šo ziņojumu nav pievienojis favorītiem. Kad tādu būs tie šeit parādīsies.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.account_unavailable": "Profils nav pieejams",
+  "empty_column.blocks": "Patreiz tu neesi nevienu bloķējis.",
+  "empty_column.bookmarked_statuses": "Patreiz tev nav neviena grāmatzīmēm pievienota ieraksta. Kad tādu pievienosi, tas parādīsies šeit.",
+  "empty_column.community": "Vietējā ziņu lenta ir tukša. Uzraksti kaut ko publiski, lai viss notiktu!",
+  "empty_column.direct": "Patrez tev nav privātu ziņu. Tiklīdz tādu nosūtīsi vai saņemsi, tās parādīsies šeit.",
+  "empty_column.domain_blocks": "Vēl nav neviena bloķēta domēna.",
+  "empty_column.favourited_statuses": "Patreiz tev nav neviena izceltā ieraksta. Kad kādu izcelsi, tas parādīsies šeit.",
+  "empty_column.favourites": "Neviens šo ziņojumu vel nav izcēlis. Kad būs, tie parādīsies šeit.",
+  "empty_column.follow_recommendations": "Šķiet, ka tev nevarēja ģenerēt ieteikumus. Vari mēģināt izmantot meklēšanu, lai meklētu cilvēkus, kurus tu varētu pazīt, vai izpētīt populārākās atsauces.",
   "empty_column.follow_requests": "Šobrīd neviens nav pieteicies tev sekot. Kad kāds pieteiksies tas parādīsies šeit.",
-  "empty_column.hashtag": "Ar šo haštagu nekas nav atrodams.",
-  "empty_column.home": "Tava laika līnija ir tukša! Apmeklē federatīvo laika līniju vai uzmeklē kādu meklētājā lai satiktu citus.",
-  "empty_column.home.suggestions": "See some suggestions",
-  "empty_column.list": "Šis saraksts ir tukšs. Kad šī saraksta dalībnieki atjaunos statusu tas parādīsies šeit.",
-  "empty_column.lists": "Tev nav neviena saraksta. Kad tādu būs tie parādīsies šeit.",
-  "empty_column.mutes": "Tu neesi nevienu apklusinājis.",
-  "empty_column.notifications": "Tev nav paziņojumu. Iesaisties sarunās ar citiem.",
-  "empty_column.public": "Šeit nekā nav, tukšums! Ieraksti kaut ko publiski, vai uzmeklē un seko kādam no citas instances",
-  "error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
-  "error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
-  "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
-  "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
-  "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
-  "errors.unexpected_crash.report_issue": "Report issue",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "empty_column.hashtag": "Ar šo tēmturi nekas nav atrodams.",
+  "empty_column.home": "Tava vietējā ziņu lenta ir tukša! Lai to aizpildītu, piesekojies vairāk cilvēkiem. {suggestions}",
+  "empty_column.home.suggestions": "Apskatīt dažus ieteikumus",
+  "empty_column.list": "Šis saraksts patreiz ir tukšs. Kad šī saraksta dalībnieki publicēs jaunas ziņas, tās parādīsies šeit.",
+  "empty_column.lists": "Patreiz tev nav neviena saraksta. Kad tādu izveidosi, tas parādīsies šeit.",
+  "empty_column.mutes": "Neviens lietotājs vēl nav apklusināts.",
+  "empty_column.notifications": "Tev vēl nav paziņojumu. Kad citi cilvēki ar tevi mijiedarbosies, tu to redzēsi šeit.",
+  "empty_column.public": "Šeit vēl nekā nav! Ieraksti ko publiski vai sāc sekot lietotājiem no citiem serveriem, lai veidotu saturu",
+  "error.unexpected_crash.explanation": "Koda kļūdas vai pārlūkprogrammas saderības problēmas dēļ šo lapu nevarēja parādīt pareizi.",
+  "error.unexpected_crash.explanation_addons": "Šo lapu nevarēja parādīt pareizi. Šo kļūdu, iespējams, izraisīja pārlūkprogrammas papildinājums vai automātiskās tulkošanas rīki.",
+  "error.unexpected_crash.next_steps": "Mēģini atsvaidzināt lapu. Ja tas nepalīdz, iespējams, varēsi lietot Mastodon, izmantojot citu pārlūkprogrammu vai vietējo lietotni.",
+  "error.unexpected_crash.next_steps_addons": "Mēģini tos atspējot un atsvaidzināt lapu. Ja tas nepalīdz, iespējams, varēsi lietot Mastodon, izmantojot citu pārlūkprogrammu vai vietējo lietotni.",
+  "errors.unexpected_crash.copy_stacktrace": "Iekopēt starpliktuvē",
+  "errors.unexpected_crash.report_issue": "Ziņot par problēmu",
+  "follow_recommendations.done": "Izpildīts",
+  "follow_recommendations.heading": "Seko cilvēkiem, no kuriem vēlies redzēt ziņas! Šeit ir daži ieteikumi.",
+  "follow_recommendations.lead": "Ziņas no cilvēkiem, kuriem seko, mājas plūsmā tiks parādītas hronoloģiskā secībā. Nebaidies kļūdīties, tu tikpat viegli vari pārtraukt sekot cilvēkiem jebkurā laikā!",
   "follow_request.authorize": "Autorizēt",
   "follow_request.reject": "Noraidīt",
-  "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
-  "generic.saved": "Saved",
-  "getting_started.developers": "Developers",
-  "getting_started.directory": "Profile directory",
-  "getting_started.documentation": "Documentation",
-  "getting_started.heading": "Getting started",
-  "getting_started.invite": "Invite people",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
-  "getting_started.security": "Security",
-  "getting_started.terms": "Terms of service",
-  "hashtag.column_header.tag_mode.all": "and {additional}",
-  "hashtag.column_header.tag_mode.any": "or {additional}",
-  "hashtag.column_header.tag_mode.none": "without {additional}",
-  "hashtag.column_settings.select.no_options_message": "No suggestions found",
-  "hashtag.column_settings.select.placeholder": "Enter hashtags…",
-  "hashtag.column_settings.tag_mode.all": "All of these",
-  "hashtag.column_settings.tag_mode.any": "Any of these",
-  "hashtag.column_settings.tag_mode.none": "None of these",
-  "hashtag.column_settings.tag_toggle": "Include additional tags in this column",
-  "home.column_settings.basic": "Basic",
-  "home.column_settings.show_reblogs": "Show boosts",
-  "home.column_settings.show_replies": "Show replies",
-  "home.hide_announcements": "Hide announcements",
-  "home.show_announcements": "Show announcements",
-  "intervals.full.days": "{number, plural, one {# day} other {# days}}",
-  "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
-  "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
-  "keyboard_shortcuts.back": "to navigate back",
-  "keyboard_shortcuts.blocked": "to open blocked users list",
-  "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
-  "keyboard_shortcuts.compose": "to focus the compose textarea",
-  "keyboard_shortcuts.description": "Description",
-  "keyboard_shortcuts.direct": "to open direct messages column",
-  "keyboard_shortcuts.down": "to move down in the list",
-  "keyboard_shortcuts.enter": "to open status",
-  "keyboard_shortcuts.favourite": "to favourite",
-  "keyboard_shortcuts.favourites": "to open favourites list",
-  "keyboard_shortcuts.federated": "to open federated timeline",
-  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
-  "keyboard_shortcuts.home": "to open home timeline",
-  "keyboard_shortcuts.hotkey": "Hotkey",
-  "keyboard_shortcuts.legend": "to display this legend",
-  "keyboard_shortcuts.local": "to open local timeline",
-  "keyboard_shortcuts.mention": "to mention author",
-  "keyboard_shortcuts.muted": "to open muted users list",
-  "keyboard_shortcuts.my_profile": "to open your profile",
-  "keyboard_shortcuts.notifications": "to open notifications column",
-  "keyboard_shortcuts.open_media": "to open media",
-  "keyboard_shortcuts.pinned": "to open pinned toots list",
-  "keyboard_shortcuts.profile": "to open author's profile",
-  "keyboard_shortcuts.reply": "to reply",
-  "keyboard_shortcuts.requests": "to open follow requests list",
-  "keyboard_shortcuts.search": "to focus search",
-  "keyboard_shortcuts.spoilers": "to show/hide CW field",
-  "keyboard_shortcuts.start": "to open \"get started\" column",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
-  "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
-  "keyboard_shortcuts.toot": "to start a brand new toot",
-  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
-  "keyboard_shortcuts.up": "to move up in the list",
-  "lightbox.close": "Close",
-  "lightbox.compress": "Compress image view box",
-  "lightbox.expand": "Expand image view box",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
-  "lists.account.add": "Add to list",
-  "lists.account.remove": "Remove from list",
-  "lists.delete": "Delete list",
-  "lists.edit": "Edit list",
-  "lists.edit.submit": "Change title",
-  "lists.new.create": "Add list",
-  "lists.new.title_placeholder": "New list title",
-  "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
-  "lists.replies_policy.title": "Show replies to:",
-  "lists.search": "Search among people you follow",
-  "lists.subheading": "Your lists",
-  "load_pending": "{count, plural, one {# new item} other {# new items}}",
-  "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "{number, plural, one {Hide image} other {Hide images}}",
-  "missing_indicator.label": "Not found",
-  "missing_indicator.sublabel": "This resource could not be found",
-  "mute_modal.duration": "Duration",
-  "mute_modal.hide_notifications": "Hide notifications from this user?",
-  "mute_modal.indefinite": "Indefinite",
-  "navigation_bar.apps": "Mobile apps",
-  "navigation_bar.blocks": "Blocked users",
-  "navigation_bar.bookmarks": "Bookmarks",
-  "navigation_bar.community_timeline": "Local timeline",
-  "navigation_bar.compose": "Compose new toot",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.discover": "Discover",
-  "navigation_bar.domain_blocks": "Hidden domains",
-  "navigation_bar.edit_profile": "Edit profile",
-  "navigation_bar.favourites": "Favourites",
-  "navigation_bar.filters": "Muted words",
-  "navigation_bar.follow_requests": "Follow requests",
-  "navigation_bar.follows_and_followers": "Follows and followers",
-  "navigation_bar.info": "About this instance",
-  "navigation_bar.keyboard_shortcuts": "Hotkeys",
-  "navigation_bar.lists": "Lists",
-  "navigation_bar.logout": "Logout",
-  "navigation_bar.mutes": "Muted users",
-  "navigation_bar.personal": "Personal",
-  "navigation_bar.pins": "Pinned toots",
-  "navigation_bar.preferences": "Preferences",
-  "navigation_bar.public_timeline": "Federated timeline",
-  "navigation_bar.security": "Security",
-  "notification.favourite": "{name} favourited your status",
-  "notification.follow": "{name} followed you",
-  "notification.follow_request": "{name} has requested to follow 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 status",
-  "notification.status": "{name} just posted",
-  "notifications.clear": "Clear notifications",
-  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
-  "notifications.column_settings.alert": "Desktop notifications",
-  "notifications.column_settings.favourite": "Favourites:",
-  "notifications.column_settings.filter_bar.advanced": "Display all categories",
-  "notifications.column_settings.filter_bar.category": "Quick filter bar",
-  "notifications.column_settings.filter_bar.show": "Show",
-  "notifications.column_settings.follow": "New followers:",
-  "notifications.column_settings.follow_request": "New follow requests:",
-  "notifications.column_settings.mention": "Mentions:",
-  "notifications.column_settings.poll": "Poll results:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.reblog": "Boosts:",
-  "notifications.column_settings.show": "Show in column",
-  "notifications.column_settings.sound": "Play sound",
-  "notifications.column_settings.status": "New toots:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
-  "notifications.filter.all": "All",
-  "notifications.filter.boosts": "Boosts",
-  "notifications.filter.favourites": "Favourites",
-  "notifications.filter.follows": "Follows",
-  "notifications.filter.mentions": "Mentions",
-  "notifications.filter.polls": "Poll results",
-  "notifications.filter.statuses": "Updates from people you follow",
-  "notifications.grant_permission": "Grant permission.",
-  "notifications.group": "{count} notifications",
-  "notifications.mark_as_read": "Mark every notification as read",
-  "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
-  "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
-  "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
-  "notifications_permission_banner.enable": "Enable desktop notifications",
-  "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
-  "notifications_permission_banner.title": "Never miss a thing",
-  "picture_in_picture.restore": "Put it back",
-  "poll.closed": "Closed",
-  "poll.refresh": "Refresh",
-  "poll.total_people": "{count, plural, one {# person} other {# people}}",
-  "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
-  "poll.vote": "Vote",
-  "poll.voted": "You voted for this answer",
-  "poll_button.add_poll": "Add a poll",
-  "poll_button.remove_poll": "Remove poll",
-  "privacy.change": "Adjust status privacy",
-  "privacy.direct.long": "Post to mentioned users only",
-  "privacy.direct.short": "Direct",
-  "privacy.private.long": "Post to followers only",
-  "privacy.private.short": "Followers-only",
-  "privacy.public.long": "Post to public timelines",
-  "privacy.public.short": "Public",
-  "privacy.unlisted.long": "Do not show in public timelines",
-  "privacy.unlisted.short": "Unlisted",
-  "refresh": "Refresh",
-  "regeneration_indicator.label": "Loading…",
-  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "follow_requests.unlocked_explanation": "Lai gan tavs konts nav bloķēts, {domain} darbinieki iedomājās, ka, iespējams, vēlēsies pārskatīt pieprasījumus no šiem kontiem.",
+  "generic.saved": "Saglabāts",
+  "getting_started.developers": "Izstrādātāji",
+  "getting_started.directory": "Profila direktorija",
+  "getting_started.documentation": "Dokumentācija",
+  "getting_started.heading": "Darba sākšana",
+  "getting_started.invite": "Uzaiciniet cilvēkus",
+  "getting_started.open_source_notice": "Mastodon ir atvērtā koda programmatūra. Tu vari dot savu ieguldījumu vai arī ziņot par problēmām {github}.",
+  "getting_started.security": "Konta iestatījumi",
+  "getting_started.terms": "Pakalpojuma noteikumi",
+  "hashtag.column_header.tag_mode.all": "un {additional}",
+  "hashtag.column_header.tag_mode.any": "vai {additional}",
+  "hashtag.column_header.tag_mode.none": "bez {additional}",
+  "hashtag.column_settings.select.no_options_message": "Ieteikumi netika atrasti",
+  "hashtag.column_settings.select.placeholder": "Ievadīt mirkļbirkas…",
+  "hashtag.column_settings.tag_mode.all": "Visi no šiem",
+  "hashtag.column_settings.tag_mode.any": "Kāds no šiem",
+  "hashtag.column_settings.tag_mode.none": "Neviens no šiem",
+  "hashtag.column_settings.tag_toggle": "Iekļaut šai kolonnai papildu tagus",
+  "home.column_settings.basic": "Pamata",
+  "home.column_settings.show_reblogs": "Rādīt palielinājumus",
+  "home.column_settings.show_replies": "Rādīt atbildes",
+  "home.hide_announcements": "Slēpt paziņojumus",
+  "home.show_announcements": "Rādīt paziņojumus",
+  "intervals.full.days": "{number, plural, one {# diena} other {# dienas}}",
+  "intervals.full.hours": "{number, plural, one {# stunda} other {# stundas}}",
+  "intervals.full.minutes": "{number, plural, one {# minūte} other {# minūtes}}",
+  "keyboard_shortcuts.back": "Pāriet atpakaļ",
+  "keyboard_shortcuts.blocked": "Atvērt bloķēto lietotāju sarakstu",
+  "keyboard_shortcuts.boost": "Palielināt ziņu",
+  "keyboard_shortcuts.column": "Fokusēt kolonnu",
+  "keyboard_shortcuts.compose": "Fokusēt veidojamā teksta lauku",
+  "keyboard_shortcuts.description": "Apraksts",
+  "keyboard_shortcuts.direct": "Atvērt privāto ziņojumu kolonnu",
+  "keyboard_shortcuts.down": "Pārvietot sarakstā uz leju",
+  "keyboard_shortcuts.enter": "Atvērt ziņu",
+  "keyboard_shortcuts.favourite": "Pievienot izlasei",
+  "keyboard_shortcuts.favourites": "Atvērt izlašu sarakstu",
+  "keyboard_shortcuts.federated": "Atvērt apvienoto ziņu lenti",
+  "keyboard_shortcuts.heading": "Klaviatūras saīsnes",
+  "keyboard_shortcuts.home": "Atvērt vietējo ziņu lenti",
+  "keyboard_shortcuts.hotkey": "Ātrais taustiņš",
+  "keyboard_shortcuts.legend": "Parādīt šo leģendu",
+  "keyboard_shortcuts.local": "Atvērt vietējo ziņu lenti",
+  "keyboard_shortcuts.mention": "Minējuma autors",
+  "keyboard_shortcuts.muted": "Atvērt apklusināto lietotāju sarakstu",
+  "keyboard_shortcuts.my_profile": "Atvērt manu profilu",
+  "keyboard_shortcuts.notifications": "Atvērt paziņojumu kolonnu",
+  "keyboard_shortcuts.open_media": "Atvērt mediju",
+  "keyboard_shortcuts.pinned": "Atvērt piesprausto ziņu sarakstu",
+  "keyboard_shortcuts.profile": "Atvērt autora profilu",
+  "keyboard_shortcuts.reply": "Atbildēt",
+  "keyboard_shortcuts.requests": "Atvērt sekošanas pieprasījumu sarakstu",
+  "keyboard_shortcuts.search": "Fokusēt meklēšanas joslu",
+  "keyboard_shortcuts.spoilers": "Rādīt/slēpt CW lauku",
+  "keyboard_shortcuts.start": "Atvērt kolonnu “Darba sākšana”",
+  "keyboard_shortcuts.toggle_hidden": "Rādīt/slēpt tekstu aiz CW",
+  "keyboard_shortcuts.toggle_sensitivity": "Rādīt/slēpt mediju",
+  "keyboard_shortcuts.toot": "Sākt jaunu ziņu",
+  "keyboard_shortcuts.unfocus": "Atfokusēt teksta veidošanu/meklēšanu",
+  "keyboard_shortcuts.up": "Pārvietot sarakstā uz augšu",
+  "lightbox.close": "Aizvērt",
+  "lightbox.compress": "Saspiest attēla ietvaru",
+  "lightbox.expand": "Paplašināt attēla ietvaru",
+  "lightbox.next": "Tālāk",
+  "lightbox.previous": "Iepriekš",
+  "lists.account.add": "Pievienot sarakstam",
+  "lists.account.remove": "Noņemt no saraksta",
+  "lists.delete": "Dzēst sarakstu",
+  "lists.edit": "Rediģēt sarakstu",
+  "lists.edit.submit": "Mainīt virsrakstu",
+  "lists.new.create": "Pievienot sarakstu",
+  "lists.new.title_placeholder": "Jaunais saraksta nosaukums",
+  "lists.replies_policy.followed": "Jebkuram lietotājam, kuram seko",
+  "lists.replies_policy.list": "Saraksta dalībnieki",
+  "lists.replies_policy.none": "Nevienam",
+  "lists.replies_policy.title": "Rādīt atbildes:",
+  "lists.search": "Meklēt starp cilvēkiem, kuriem tu seko",
+  "lists.subheading": "Tavi saraksti",
+  "load_pending": "{count, plural, one {# jauna lieta} other {# jaunas lietas}}",
+  "loading_indicator.label": "Ielādē...",
+  "media_gallery.toggle_visible": "{number, plural, one {Slēpt # attēlu} other {Slēpt # attēlus}}",
+  "missing_indicator.label": "Nav atrasts",
+  "missing_indicator.sublabel": "Šo resursu nevarēja atrast",
+  "mute_modal.duration": "Ilgums",
+  "mute_modal.hide_notifications": "Slēpt paziņojumus no šī lietotāja?",
+  "mute_modal.indefinite": "Nenoteikts",
+  "navigation_bar.apps": "Mobilās lietotnes",
+  "navigation_bar.blocks": "Bloķētie lietotāji",
+  "navigation_bar.bookmarks": "Grāmatzīmes",
+  "navigation_bar.community_timeline": "Vietējā ziņu lenta",
+  "navigation_bar.compose": "Veidot jaunu ziņu",
+  "navigation_bar.direct": "Privātās ziņas",
+  "navigation_bar.discover": "Atklāt",
+  "navigation_bar.domain_blocks": "Bloķētie domēni",
+  "navigation_bar.edit_profile": "Rediģēt profilu",
+  "navigation_bar.favourites": "Izlases",
+  "navigation_bar.filters": "Klusināti vārdi",
+  "navigation_bar.follow_requests": "Sekošanas pieprasījumi",
+  "navigation_bar.follows_and_followers": "Man seko un sekotāji",
+  "navigation_bar.info": "Par šo serveri",
+  "navigation_bar.keyboard_shortcuts": "Ātrie taustiņi",
+  "navigation_bar.lists": "Saraksti",
+  "navigation_bar.logout": "Iziet",
+  "navigation_bar.mutes": "Apklusinātie lietotāji",
+  "navigation_bar.personal": "Personīgi",
+  "navigation_bar.pins": "Piespraustās ziņas",
+  "navigation_bar.preferences": "Iestatījumi",
+  "navigation_bar.public_timeline": "Apvienotā ziņu lenta",
+  "navigation_bar.security": "Drošība",
+  "notification.favourite": "{name} izcēla tavu ziņu",
+  "notification.follow": "{name} uzsāka tev sekot",
+  "notification.follow_request": "{name} vēlas tev sekot",
+  "notification.mention": "{name} pieminēja tevi",
+  "notification.own_poll": "Tava aptauja ir pabeigta",
+  "notification.poll": "Aprauja, kurā tu piedalījies, ir pabeigta",
+  "notification.reblog": "{name} paaugstināja tavu ziņu",
+  "notification.status": "{name} tikko publicēja",
+  "notifications.clear": "Notīrīt paziņojumus",
+  "notifications.clear_confirmation": "Vai tiešām vēlies neatgriezeniski notīrīt visus savus paziņojumus?",
+  "notifications.column_settings.alert": "Darbvirsmas paziņojumi",
+  "notifications.column_settings.favourite": "Izlases:",
+  "notifications.column_settings.filter_bar.advanced": "Rādīt visas kategorijas",
+  "notifications.column_settings.filter_bar.category": "Ātro filtru josla",
+  "notifications.column_settings.filter_bar.show": "Parādīt",
+  "notifications.column_settings.follow": "Jauni sekotāji:",
+  "notifications.column_settings.follow_request": "Jauni sekotāju pieprasījumi:",
+  "notifications.column_settings.mention": "Pieminējumi:",
+  "notifications.column_settings.poll": "Aptaujas rezultāti:",
+  "notifications.column_settings.push": "Uznirstošie paziņojumi",
+  "notifications.column_settings.reblog": "Palielinājumi:",
+  "notifications.column_settings.show": "Rādīt kolonnā",
+  "notifications.column_settings.sound": "Atskaņot skaņu",
+  "notifications.column_settings.status": "Jaunas ziņas:",
+  "notifications.column_settings.unread_markers.category": "Nelasīto paziņojumu marķieri",
+  "notifications.filter.all": "Visi",
+  "notifications.filter.boosts": "Palielinājumi",
+  "notifications.filter.favourites": "Izlases",
+  "notifications.filter.follows": "Seko",
+  "notifications.filter.mentions": "Pieminējumi",
+  "notifications.filter.polls": "Aptaujas rezultāti",
+  "notifications.filter.statuses": "Jaunumi no cilvēkiem, kuriem tu seko",
+  "notifications.grant_permission": "Piešķirt atļauju.",
+  "notifications.group": "{count} paziņojumi",
+  "notifications.mark_as_read": "Atzīmēt katru paziņojumu kā izlasītu",
+  "notifications.permission_denied": "Darbvirsmas paziņojumi nav pieejami, jo iepriekš tika noraidīts pārlūka atļauju pieprasījums",
+  "notifications.permission_denied_alert": "Darbvirsmas paziņojumus nevar iespējot, jo pārlūkprogrammai atļauja tika iepriekš atteikta",
+  "notifications.permission_required": "Darbvirsmas paziņojumi nav pieejami, jo nav piešķirta nepieciešamā atļauja.",
+  "notifications_permission_banner.enable": "Iespējot darbvirsmas paziņojumus",
+  "notifications_permission_banner.how_to_control": "Lai saņemtu paziņojumus, kad Mastodon nav atvērts, iespējo darbvirsmas paziņojumus. Vari precīzi kontrolēt, kāda veida mijiedarbības ģenerē darbvirsmas paziņojumus, izmantojot iepriekš redzamo pogu {icon}, ja tie ir iespējoti.",
+  "notifications_permission_banner.title": "Nekad nepalaid neko garām",
+  "picture_in_picture.restore": "Novietot apakšā",
+  "poll.closed": "Pabeigta",
+  "poll.refresh": "Atsvaidzināt",
+  "poll.total_people": "{count, plural, one {# persona} other {# cilvēki}}",
+  "poll.total_votes": "{count, plural, one {# balsojums} other {# balsojumi}}",
+  "poll.vote": "Balsot",
+  "poll.voted": "Tu balsoji par šo atbildi",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
+  "poll_button.add_poll": "Pievienot aptauju",
+  "poll_button.remove_poll": "Noņemt aptauju",
+  "privacy.change": "Mainīt ziņas privātumu",
+  "privacy.direct.long": "Redzams tikai pieminētajiem lietotājiem",
+  "privacy.direct.short": "Tiešs",
+  "privacy.private.long": "Redzams tikai sekotājiem",
+  "privacy.private.short": "Tikai sekotājiem",
+  "privacy.public.long": "Redzams visiem, rāda publiskajās ziņu lentās",
+  "privacy.public.short": "Publisks",
+  "privacy.unlisted.long": "Redzams visiem, bet ne publiskajās ziņu lentās",
+  "privacy.unlisted.short": "Neminētie",
+  "refresh": "Atsvaidzināt",
+  "regeneration_indicator.label": "Ielādē…",
+  "regeneration_indicator.sublabel": "Tiek gatavota tava plūsma!",
   "relative_time.days": "{number}d",
-  "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
+  "relative_time.hours": "{number}st",
+  "relative_time.just_now": "tagad",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
-  "relative_time.today": "today",
-  "reply_indicator.cancel": "Cancel",
-  "report.forward": "Forward to {target}",
-  "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
-  "report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
-  "report.placeholder": "Additional comments",
-  "report.submit": "Submit",
-  "report.target": "Report {target}",
-  "search.placeholder": "Search",
-  "search_popout.search_format": "Advanced search format",
-  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
-  "search_popout.tips.hashtag": "hashtag",
-  "search_popout.tips.status": "status",
-  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
-  "search_popout.tips.user": "user",
-  "search_results.accounts": "People",
-  "search_results.hashtags": "Hashtags",
-  "search_results.statuses": "Toots",
-  "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
-  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "status.admin_account": "Open moderation interface for @{name}",
-  "status.admin_status": "Open this status in the moderation interface",
-  "status.block": "Block @{name}",
-  "status.bookmark": "Bookmark",
-  "status.cancel_reblog_private": "Unboost",
-  "status.cannot_reblog": "This post cannot be boosted",
-  "status.copy": "Copy link to status",
-  "status.delete": "Delete",
-  "status.detailed_status": "Detailed conversation view",
-  "status.direct": "Direct message @{name}",
-  "status.embed": "Embed",
-  "status.favourite": "Favourite",
-  "status.filtered": "Filtered",
-  "status.load_more": "Load more",
-  "status.media_hidden": "Media hidden",
-  "status.mention": "Mention @{name}",
-  "status.more": "More",
-  "status.mute": "Mute @{name}",
-  "status.mute_conversation": "Mute conversation",
-  "status.open": "Expand this status",
-  "status.pin": "Pin on profile",
-  "status.pinned": "Pinned toot",
-  "status.read_more": "Read more",
-  "status.reblog": "Boost",
-  "status.reblog_private": "Boost with original visibility",
-  "status.reblogged_by": "{name} boosted",
-  "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
-  "status.redraft": "Delete & re-draft",
-  "status.remove_bookmark": "Remove bookmark",
-  "status.reply": "Reply",
-  "status.replyAll": "Reply to thread",
-  "status.report": "Report @{name}",
-  "status.sensitive_warning": "Sensitive content",
-  "status.share": "Share",
-  "status.show_less": "Show less",
-  "status.show_less_all": "Show less for all",
-  "status.show_more": "Show more",
-  "status.show_more_all": "Show more for all",
-  "status.show_thread": "Show thread",
-  "status.uncached_media_warning": "Not available",
-  "status.unmute_conversation": "Unmute conversation",
-  "status.unpin": "Unpin from profile",
-  "suggestions.dismiss": "Dismiss suggestion",
-  "suggestions.header": "You might be interested in…",
-  "tabs_bar.federated_timeline": "Federated",
-  "tabs_bar.home": "Home",
-  "tabs_bar.local_timeline": "Local",
-  "tabs_bar.notifications": "Notifications",
-  "tabs_bar.search": "Search",
-  "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
-  "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
-  "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
-  "time_remaining.moments": "Moments remaining",
-  "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
-  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
-  "timeline_hint.resources.followers": "Followers",
-  "timeline_hint.resources.follows": "Follows",
-  "timeline_hint.resources.statuses": "Older toots",
-  "trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
-  "trends.trending_now": "Trending now",
-  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
-  "units.short.billion": "{count}B",
+  "relative_time.today": "šodien",
+  "reply_indicator.cancel": "Atcelt",
+  "report.forward": "Pārsūtīt {target}",
+  "report.forward_hint": "Konts ir no cita servera. Vai nosūtīt anonimizētu ziņojuma kopiju arī tam?",
+  "report.hint": "Pārskats tiks nosūtīts tava servera moderatoriem. Tu vari pievienot paskaidrojumu, kādēļ tu ziņo par kontu:",
+  "report.placeholder": "Papildu komentāri",
+  "report.submit": "Iesniegt",
+  "report.target": "Ziņošana par: {target}",
+  "search.placeholder": "Meklēšana",
+  "search_popout.search_format": "Paplašināts meklēšanas formāts",
+  "search_popout.tips.full_text": "Vienkāršs teksts atgriež ziņas, kuras esi rakstījis, iecienījis, paaugstinājis vai pieminējis, kā arī atbilstošie lietotājvārdi, parādāmie vārdi un tēmturi.",
+  "search_popout.tips.hashtag": "mirkļbirka",
+  "search_popout.tips.status": "ziņa",
+  "search_popout.tips.text": "Vienkāršs teksts atgriež atbilstošus parādāmos vārdus, lietotājvārdus un mirkļbirkas",
+  "search_popout.tips.user": "lietotājs",
+  "search_results.accounts": "Cilvēki",
+  "search_results.hashtags": "Mirkļbirkas",
+  "search_results.statuses": "Ziņas",
+  "search_results.statuses_fts_disabled": "Šajā Mastodon serverī nav iespējota ziņu meklēšana pēc to satura.",
+  "search_results.total": "{count, number} {count, plural, one {rezultāts} other {rezultāti}}",
+  "status.admin_account": "Atvērt @{name} moderēšanas saskarni",
+  "status.admin_status": "Atvērt šo ziņu moderācijas saskarnē",
+  "status.block": "Bloķēt @{name}",
+  "status.bookmark": "Grāmatzīme",
+  "status.cancel_reblog_private": "Nepaaugstināt",
+  "status.cannot_reblog": "Nevar paaugstināt ziņu",
+  "status.copy": "Kopēt saiti uz ziņu",
+  "status.delete": "Dzēst",
+  "status.detailed_status": "Detalizēts sarunas skats",
+  "status.direct": "Privāta ziņa @{name}",
+  "status.embed": "Iestrādāt",
+  "status.favourite": "Iecienītā",
+  "status.filtered": "Filtrēts",
+  "status.load_more": "Ielādēt vairāk",
+  "status.media_hidden": "Medijs ir paslēpts",
+  "status.mention": "Pieminēt @{name}",
+  "status.more": "Vairāk",
+  "status.mute": "Apklusināt @{name}",
+  "status.mute_conversation": "Apklusināt sarunu",
+  "status.open": "Paplašināt šo ziņu",
+  "status.pin": "Piespraust profilam",
+  "status.pinned": "Piespraustā ziņa",
+  "status.read_more": "Lasīt vairāk",
+  "status.reblog": "Paaugstināt",
+  "status.reblog_private": "Paaugstināt ar sākotnējo izskatu",
+  "status.reblogged_by": "{name} paaugstināts",
+  "status.reblogs.empty": "Neviens šo ziņojumu vel nav paaugstinājis. Kad būs, tie parādīsies šeit.",
+  "status.redraft": "Dzēst un pārrakstīt",
+  "status.remove_bookmark": "Noņemt grāmatzīmi",
+  "status.reply": "Atbildēt",
+  "status.replyAll": "Atbildēt uz tematu",
+  "status.report": "Atskaite @{name}",
+  "status.sensitive_warning": "Sensitīvs saturs",
+  "status.share": "Kopīgot",
+  "status.show_less": "Rādīt mazāk",
+  "status.show_less_all": "Rādīt mazāk visiem",
+  "status.show_more": "Rādīt vairāk",
+  "status.show_more_all": "Rādīt vairāk visiem",
+  "status.show_thread": "Rādīt tematu",
+  "status.uncached_media_warning": "Nav pieejams",
+  "status.unmute_conversation": "Atvērt sarunu",
+  "status.unpin": "Noņemt no profila",
+  "suggestions.dismiss": "Noraidīt ieteikumu",
+  "suggestions.header": "Jūs varētu interesēt arī…",
+  "tabs_bar.federated_timeline": "Apvienotā",
+  "tabs_bar.home": "Sākums",
+  "tabs_bar.local_timeline": "Vietējā",
+  "tabs_bar.notifications": "Paziņojumi",
+  "tabs_bar.search": "Meklēt",
+  "time_remaining.days": "Atlikušas {number, plural, one {# diena} other {# dienas}}",
+  "time_remaining.hours": "Atlikušas {number, plural, one {# stunda} other {# stundas}}",
+  "time_remaining.minutes": "Atlikušas {number, plural, one {# minūte} other {# minūtes}}",
+  "time_remaining.moments": "Atlikuši daži mirkļi",
+  "time_remaining.seconds": "Atlikušas {number, plural, one {# sekunde} other {# sekundes}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} no citiem serveriem nav parādīti.",
+  "timeline_hint.resources.followers": "Sekotāji",
+  "timeline_hint.resources.follows": "Seko",
+  "timeline_hint.resources.statuses": "Vecākas ziņas",
+  "trends.counter_by_accounts": "Sarunājas {count, plural, one {{counter} persona} other {{counter} cilvēki}}",
+  "trends.trending_now": "Šobrīd tendences",
+  "ui.beforeunload": "Ja pametīsit Mastodonu, jūsu melnraksts tiks zaudēts.",
+  "units.short.billion": "{count}M",
   "units.short.million": "{count}M",
-  "units.short.thousand": "{count}K",
-  "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add images, a video or an audio file",
-  "upload_error.limit": "File upload limit exceeded.",
-  "upload_error.poll": "File upload not allowed with polls.",
-  "upload_form.audio_description": "Describe for people with hearing loss",
-  "upload_form.description": "Describe for the visually impaired",
-  "upload_form.edit": "Edit",
-  "upload_form.thumbnail": "Change thumbnail",
-  "upload_form.undo": "Delete",
-  "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
-  "upload_modal.analyzing_picture": "Analyzing picture…",
-  "upload_modal.apply": "Apply",
-  "upload_modal.choose_image": "Choose image",
-  "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
-  "upload_modal.detect_text": "Detect text from picture",
-  "upload_modal.edit_media": "Edit media",
-  "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
-  "upload_modal.preparing_ocr": "Preparing OCR…",
-  "upload_modal.preview_label": "Preview ({ratio})",
-  "upload_progress.label": "Uploading…",
-  "video.close": "Close video",
-  "video.download": "Download file",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
-  "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "units.short.thousand": "{count}Tk",
+  "upload_area.title": "Velc un nomet, lai augšupielādētu",
+  "upload_button.label": "Pievienot bildi, video vai audio datni",
+  "upload_error.limit": "Sasniegts datņu augšupielādes ierobežojums.",
+  "upload_error.poll": "Datņu augšupielādes aptaujās nav atļautas.",
+  "upload_form.audio_description": "Aprakstiet cilvēkiem ar dzirdes zudumu",
+  "upload_form.description": "Aprakstiet vājredzīgajiem",
+  "upload_form.edit": "Rediģēt",
+  "upload_form.thumbnail": "Nomainīt sīktēlu",
+  "upload_form.undo": "Dzēst",
+  "upload_form.video_description": "Aprakstiet cilvēkiem ar dzirdes vai redzes traucējumiem",
+  "upload_modal.analyzing_picture": "Analizē attēlu…",
+  "upload_modal.apply": "Apstiprināt",
+  "upload_modal.applying": "Applying…",
+  "upload_modal.choose_image": "Izvēlēties attēlu",
+  "upload_modal.description_placeholder": "Raibais runcis rīgā ratu rumbā rūc",
+  "upload_modal.detect_text": "Noteikt tekstu no attēla",
+  "upload_modal.edit_media": "Rediģēt mediju",
+  "upload_modal.hint": "Noklikšķiniet vai velciet apli priekšskatījumā, lai izvēlētos fokusa punktu, kas vienmēr būs redzams visos sīktēlos.",
+  "upload_modal.preparing_ocr": "Sagatavo OCR…",
+  "upload_modal.preview_label": "Priekšskatīt ({ratio})",
+  "upload_progress.label": "Augšupielādē...",
+  "video.close": "Aizvērt video",
+  "video.download": "Lejupielādēt datni",
+  "video.exit_fullscreen": "Iziet no pilnekrāna",
+  "video.expand": "Paplašināt video",
+  "video.fullscreen": "Pilnekrāns",
+  "video.hide": "Slēpt video",
+  "video.mute": "Izslēgt skaņu",
+  "video.pause": "Pauze",
+  "video.play": "Atskaņot",
+  "video.unmute": "Ieslēgt skaņu"
 }
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index 2e1497f6a..36ea33bb8 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -47,11 +47,16 @@
   "account.unmute": "Зачути го @{name}",
   "account.unmute_notifications": "Исклучи известувања од @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Обидете се повторно после {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Неочекувана грешка.",
   "alert.unexpected.title": "Упс!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} неделно",
   "boost_modal.combo": "Кликни {combo} за да го прескокниш ова нареден пат",
   "bundle_column_error.body": "Се случи проблем при вчитувањето.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Сигурни сте дека го бришите статусот?",
   "confirmations.delete_list.confirm": "Избриши",
   "confirmations.delete_list.message": "Дали сте сигурни дека сакате да го избришете списоков?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Сокриј цел домеин",
   "confirmations.domain_block.message": "Дали скроз сте сигурни дека ќе блокирате сѐ од {domain}? Во повеќето случаеви неколку таргетирани блокирања или заќутувања се доволни и предложени. Нема да ја видите содржината од тој домеин во никој јавен времеплов или вашите нотификации. Вашите следбеници од тој домеин ќе бидат остранети.",
   "confirmations.logout.confirm": "Одјави се",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# глас} other {# гласа}}",
   "poll.vote": "Гласај",
   "poll.voted": "Вие гласавте за овој одговор",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Додадете нова анкета",
   "poll_button.remove_poll": "Избришете анкета",
   "privacy.change": "Штеловај статус на приватност",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 488484290..cb320a661 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -47,11 +47,16 @@
   "account.unmute": "നിശ്ശബ്ദമാക്കുന്നത് നിർത്തുക @{name}",
   "account.unmute_notifications": "@{name} യിൽ നിന്നുള്ള അറിയിപ്പുകൾ പ്രസിദ്ധപ്പെടുത്തുക",
   "account_note.placeholder": "കുറിപ്പ് ചേർക്കാൻ ക്ലിക്കുചെയ്യുക",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "{retry_time, time, medium} നു ശേഷം വീണ്ടും ശ്രമിക്കുക.",
   "alert.rate_limited.title": "തോത് പരിമിതപ്പെടുത്തിയിരിക്കുന്നു",
   "alert.unexpected.message": "അപ്രതീക്ഷിതമായി എന്തോ സംഭവിച്ചു.",
   "alert.unexpected.title": "ശ്ശോ!",
   "announcement.announcement": "അറിയിപ്പ്",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "ആഴ്ച തോറും {count}",
   "boost_modal.combo": "അടുത്ത തവണ ഇത് ഒഴിവാക്കുവാൻ {combo} ഞെക്കാവുന്നതാണ്",
   "bundle_column_error.body": "ഈ ഘടകം പ്രദശിപ്പിക്കുമ്പോൾ എന്തോ കുഴപ്പം സംഭവിച്ചു.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "ഈ ടൂട്ട് ഇല്ലാതാക്കണം എന്ന് നിങ്ങൾക്ക് ഉറപ്പാണോ?",
   "confirmations.delete_list.confirm": "മായ്ക്കുക",
   "confirmations.delete_list.message": "ഈ പട്ടിക എന്നെന്നേക്കുമായി നീക്കം ചെയ്യാൻ നിങ്ങൾ ആഗ്രഹിക്കുന്നുണ്ടോ?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "മുഴുവൻ ഡൊമെയ്‌നും തടയുക",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "പുറത്തുകടക്കുക",
@@ -176,7 +183,7 @@
   "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
   "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
   "errors.unexpected_crash.report_issue": "പ്രശ്നം അറിയിക്കുക",
-  "follow_recommendations.done": "Done",
+  "follow_recommendations.done": "പൂര്‍ത്തിയായീ",
   "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
   "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
   "follow_request.authorize": "ചുമതലപ്പെടുത്തുക",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "വോട്ട് ചെയ്യുക",
   "poll.voted": "ഈ ഉത്തരത്തിനായി നിങ്ങൾ വോട്ട് ചെയ്തു",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "ഒരു പോൾ ചേർക്കുക",
   "poll_button.remove_poll": "പോൾ നീക്കംചെയ്യുക",
   "privacy.change": "ടൂട്ട് സ്വകാര്യത ക്രമീകരിക്കുക",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "ചിത്രം വിശകലനം ചെയ്യുന്നു…",
   "upload_modal.apply": "പ്രയോഗിക്കുക",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "ചിത്രം തിരഞ്ഞെടുക്കുക",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "ചിത്രത്തിൽ നിന്ന് വാചകം കണ്ടെത്തുക",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index 7fc59119d..979f910ee 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "अरेरे!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} प्रतिसप्ताह",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "हा घटक लोड करतांना काहीतरी चुकले आहे.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "हे स्टेटस तुम्हाला नक्की हटवायचंय?",
   "confirmations.delete_list.confirm": "हटवा",
   "confirmations.delete_list.message": "ही यादी तुम्हाला नक्की कायमची हटवायचीय?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "संपूर्ण डोमेन लपवा",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 56cf7ab10..50526f74c 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -4,15 +4,15 @@
   "account.badges.bot": "Bot",
   "account.badges.group": "Kumpulan",
   "account.block": "Sekat @{name}",
-  "account.block_domain": "Hide everything from {domain}",
+  "account.block_domain": "Sekat domain {domain}",
   "account.blocked": "Disekat",
-  "account.browse_more_on_origin_server": "Layari selebihnya di profil original",
-  "account.cancel_follow_request": "Batalkan permintaan mengikuti",
-  "account.direct": "Mesej langsung @{name}",
-  "account.disable_notifications": "Berhenti memaklumi saya apabila @{name} mengirim",
-  "account.domain_blocked": "Domain hidden",
+  "account.browse_more_on_origin_server": "Layari selebihnya di profil asal",
+  "account.cancel_follow_request": "Batalkan permintaan ikutan",
+  "account.direct": "Mesej terus @{name}",
+  "account.disable_notifications": "Berhenti memaklumi saya apabila @{name} mengirim hantaran",
+  "account.domain_blocked": "Domain disekat",
   "account.edit_profile": "Sunting profil",
-  "account.enable_notifications": "Maklumi saya apabila @{name} mengirim",
+  "account.enable_notifications": "Maklumi saya apabila @{name} mengirim hantaran",
   "account.endorse": "Tampilkan di profil",
   "account.follow": "Ikuti",
   "account.followers": "Pengikut",
@@ -22,7 +22,7 @@
   "account.follows.empty": "Pengguna ini belum mengikuti sesiapa.",
   "account.follows_you": "Mengikuti anda",
   "account.hide_reblogs": "Sembunyikan galakan daripada @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Sertai pada {date}",
   "account.last_status": "Terakhir aktif",
   "account.link_verified_on": "Pemilikan pautan ini telah disemak pada {date}",
   "account.locked_info": "Status privasi akaun ini dikunci. Pemiliknya menyaring sendiri siapa yang boleh mengikutinya.",
@@ -33,64 +33,69 @@
   "account.mute_notifications": "Bisukan pemberitahuan daripada @{name}",
   "account.muted": "Dibisukan",
   "account.never_active": "Jangan sesekali",
-  "account.posts": "Toots",
-  "account.posts_with_replies": "Toots and replies",
+  "account.posts": "Hantaran",
+  "account.posts_with_replies": "Hantaran dan balasan",
   "account.report": "Laporkan @{name}",
-  "account.requested": "Awaiting approval",
+  "account.requested": "Menunggu kelulusan. Klik untuk batalkan permintaan ikutan",
   "account.share": "Kongsi profil @{name}",
   "account.show_reblogs": "Tunjukkan galakan daripada @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
+  "account.statuses_counter": "{count, plural, one {{counter} Hantaran} other {{counter} Hantaran}}",
   "account.unblock": "Nyahsekat @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "Nyahsekat domain {domain}",
   "account.unendorse": "Jangan tampilkan di profil",
   "account.unfollow": "Nyahikut",
   "account.unmute": "Nyahbisukan @{name}",
   "account.unmute_notifications": "Nyahbisukan pemberitahuan daripada @{name}",
-  "account_note.placeholder": "Click to add a note",
+  "account_note.placeholder": "Klik untuk tambah catatan",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Sila cuba semula selepas {retry_time, time, medium}.",
   "alert.rate_limited.title": "Kadar terhad",
   "alert.unexpected.message": "Berlaku ralat di luar jangkaan.",
   "alert.unexpected.title": "Alamak!",
   "announcement.announcement": "Pengumuman",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} seminggu",
-  "boost_modal.combo": "Anda boleh mengetik {combo} untuk melangkauinya lain kali",
+  "boost_modal.combo": "Anda boleh tekan {combo} untuk melangkauinya pada waktu lain",
   "bundle_column_error.body": "Terdapat kesilapan ketika memuatkan komponen ini.",
   "bundle_column_error.retry": "Cuba lagi",
-  "bundle_column_error.title": "Masalah rangkaian",
+  "bundle_column_error.title": "Ralat rangkaian",
   "bundle_modal_error.close": "Tutup",
   "bundle_modal_error.message": "Ada yang tidak kena semasa memuatkan komponen ini.",
   "bundle_modal_error.retry": "Cuba lagi",
   "column.blocks": "Pengguna yang disekat",
-  "column.bookmarks": "Penanda buku",
+  "column.bookmarks": "Tanda buku",
   "column.community": "Garis masa tempatan",
-  "column.direct": "Mesej langsung",
-  "column.directory": "Buka profil",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Mesej terus",
+  "column.directory": "Layari profil",
+  "column.domain_blocks": "Domain disekat",
   "column.favourites": "Kegemaran",
   "column.follow_requests": "Permintaan ikutan",
   "column.home": "Laman Utama",
   "column.lists": "Senarai",
   "column.mutes": "Pengguna yang dibisukan",
   "column.notifications": "Pemberitahuan",
-  "column.pins": "Pinned toot",
+  "column.pins": "Hantaran disemat",
   "column.public": "Garis masa bersekutu",
   "column_back_button.label": "Kembali",
   "column_header.hide_settings": "Sembunyikan tetapan",
-  "column_header.moveLeft_settings": "Alih lajur ke kiri",
-  "column_header.moveRight_settings": "Alih lajur ke kanan",
+  "column_header.moveLeft_settings": "Pindah lajur ke kiri",
+  "column_header.moveRight_settings": "Pindah lajur ke kanan",
   "column_header.pin": "Sematkan",
   "column_header.show_settings": "Tunjukkan tetapan",
   "column_header.unpin": "Nyahsemat",
   "column_subheading.settings": "Tetapan",
   "community.column_settings.local_only": "Tempatan sahaja",
-  "community.column_settings.media_only": "Media only",
+  "community.column_settings.media_only": "Media sahaja",
   "community.column_settings.remote_only": "Jauh sahaja",
-  "compose_form.direct_message_warning": "This toot will only be sent to all the mentioned users.",
+  "compose_form.direct_message_warning": "Hantaran ini hanya akan dihantar kepada pengguna yang disebut.",
   "compose_form.direct_message_warning_learn_more": "Ketahui lebih lanjut",
-  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
-  "compose_form.lock_disclaimer": "Akaun anda tidak {locked}. Sesiapapun boleh mengikuti anda untuk melihat kiriman pengikut-sahaja anda.",
+  "compose_form.hashtag_warning": "Hantaran ini tidak akan disenaraikan di bawah mana-mana tanda pagar kerana ia tidak tersenarai. Hanya hantaran awam sahaja boleh dicari menggunakan tanda pagar.",
+  "compose_form.lock_disclaimer": "Akaun anda tidak {locked}. Sesiapa pun boleh mengikuti anda untuk melihat hantaran pengikut-sahaja anda.",
   "compose_form.lock_disclaimer.lock": "dikunci",
-  "compose_form.placeholder": "What is on your mind?",
+  "compose_form.placeholder": "Apakah yang sedang anda fikirkan?",
   "compose_form.poll.add_option": "Tambah pilihan",
   "compose_form.poll.duration": "Tempoh undian",
   "compose_form.poll.option_placeholder": "Pilihan {number}",
@@ -102,30 +107,32 @@
   "compose_form.sensitive.hide": "{count, plural, one {Tandakan media sbg sensitif} other {Tandakan media sbg sensitif}}",
   "compose_form.sensitive.marked": "{count, plural, one {Media telah ditanda sbg sensitif} other {Media telah ditanda sbg sensitif}}",
   "compose_form.sensitive.unmarked": "{count, plural, one {Media tidak ditanda sbg sensitif} other {Media tidak ditanda sbg sensitif}}",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.spoiler.marked": "Buang amaran kandungan",
+  "compose_form.spoiler.unmarked": "Tambah amaran kandungan",
   "compose_form.spoiler_placeholder": "Tulis amaran anda di sini",
   "confirmation_modal.cancel": "Batal",
   "confirmations.block.block_and_report": "Sekat & Lapor",
   "confirmations.block.confirm": "Sekat",
-  "confirmations.block.message": "Anda pasti mahu menyekat {name}?",
+  "confirmations.block.message": "Adakah anda pasti anda ingin menyekat {name}?",
   "confirmations.delete.confirm": "Padam",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.delete.message": "Adakah anda pasti anda ingin memadam hantaran ini?",
   "confirmations.delete_list.confirm": "Padam",
-  "confirmations.delete_list.message": "Anda pasti mahu memadam senarai ini selama-lamanya?",
-  "confirmations.domain_block.confirm": "Hide entire domain",
-  "confirmations.domain_block.message": "Anda betul-betul, sungguh-sungguh pasti mahu menyekat keseluruhan {domain}? Biasanya sekatan dan bisuan tersasar sudah memadai dan baik. Anda tidak akan dapat melihat kandungan dari 'domain' di sebarang garis masa awam mahupun pemberitahuan anda. Pengikut anda dari 'domain' itu juga akan dikeluarkan.",
+  "confirmations.delete_list.message": "Adakah anda pasti anda ingin memadam senarai ini secara kekal?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
+  "confirmations.domain_block.confirm": "Sekat keseluruhan domain",
+  "confirmations.domain_block.message": "Adakah anda betul-betul, sungguh-sungguh pasti anda ingin menyekat keseluruhan {domain}? Selalunya, beberapa sekatan atau pembisuan tersasar sudah memadai dan lebih diutamakan. Anda tidak akan nampak kandungan daripada domain tersebut di mana-mana garis masa awam mahupun pemberitahuan anda. Pengikut anda daripada domain tersebut juga akan dibuang.",
   "confirmations.logout.confirm": "Log keluar",
-  "confirmations.logout.message": "Anda pasti mahu log keluar?",
+  "confirmations.logout.message": "Adakah anda pasti anda ingin log keluar?",
   "confirmations.mute.confirm": "Bisukan",
-  "confirmations.mute.explanation": "Ini akan menyembunyikan kiriman-kiriman daripada mereka, juga kiriman yang menyebut mereka, tapi masih membenarkan mereka melihat kiriman-kiriman anda dan mengikuti anda.",
-  "confirmations.mute.message": "Anda pasti mahu membisukan {name}?",
+  "confirmations.mute.explanation": "Ini akan menyembunyikan hantaran daripada mereka dan juga hantaran yang menyebut mereka, tetapi ia masih membenarkan mereka melihat hantaran anda dan mengikuti anda.",
+  "confirmations.mute.message": "Adakah anda pasti anda ingin membisukan {name}?",
   "confirmations.redraft.confirm": "Padam & rangka semula",
-  "confirmations.redraft.message": "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.",
+  "confirmations.redraft.message": "Adakah anda pasti anda ingin memadam hantaran ini dan merangkanya semula? Kegemaran dan galakan akan hilang, dan balasan ke hantaran asal akan menjadi yatim.",
   "confirmations.reply.confirm": "Balas",
-  "confirmations.reply.message": "Membalas sekarang akan menghapuskan mesej yang anda sedang karang. Anda pasti mahu teruskan?",
+  "confirmations.reply.message": "Membalas sekarang akan menulis ganti mesej yang anda sedang karang. Adakah anda pasti anda ingin teruskan?",
   "confirmations.unfollow.confirm": "Nyahikut",
-  "confirmations.unfollow.message": "Anda pasti mahu nyahikuti {name}?",
+  "confirmations.unfollow.message": "Adakah anda pasti anda ingin nyahikuti {name}?",
   "conversation.delete": "Padam perbualan",
   "conversation.mark_as_read": "Tanda sudah dibaca",
   "conversation.open": "Lihat perbualan",
@@ -134,15 +141,15 @@
   "directory.local": "Dari {domain} sahaja",
   "directory.new_arrivals": "Ketibaan baharu",
   "directory.recently_active": "Aktif baru-baru ini",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.instructions": "Benam hantaran ini di laman sesawang anda dengan menyalin kod berikut.",
   "embed.preview": "Begini rupanya nanti:",
   "emoji_button.activity": "Aktiviti",
-  "emoji_button.custom": "Tersendiri",
+  "emoji_button.custom": "Tersuai",
   "emoji_button.flags": "Bendera",
   "emoji_button.food": "Makanan & Minuman",
   "emoji_button.label": "Masukkan emoji",
-  "emoji_button.nature": "Alam Semulajadi",
-  "emoji_button.not_found": "Tiada emojo!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.nature": "Alam Semula Jadi",
+  "emoji_button.not_found": "Tiada emoji sepadan dijumpai",
   "emoji_button.objects": "Objek",
   "emoji_button.people": "Orang",
   "emoji_button.recent": "Kerap digunakan",
@@ -151,78 +158,78 @@
   "emoji_button.symbols": "Simbol",
   "emoji_button.travel": "Kembara & Tempat",
   "empty_column.account_suspended": "Akaun digantung",
-  "empty_column.account_timeline": "No toots here!",
+  "empty_column.account_timeline": "Tiada hantaran di sini!",
   "empty_column.account_unavailable": "Profil tidak tersedia",
   "empty_column.blocks": "Anda belum menyekat sesiapa.",
-  "empty_column.bookmarked_statuses": "You don't have any bookmarked toots yet. When you bookmark one, it will show up here.",
+  "empty_column.bookmarked_statuses": "Anda belum ada hantaran yang ditanda buku. Apabila anda menanda buku sesuatu, ia akan muncul di sini.",
   "empty_column.community": "Garis masa tempatan kosong. Tulislah secara awam untuk memulakan sesuatu!",
-  "empty_column.direct": "Anda belum mempunyai mesej langsung. Ia akan terpapar di sini apabila anda menghantar atau menerimanya.",
-  "empty_column.domain_blocks": "There are no hidden domains yet.",
-  "empty_column.favourited_statuses": "You don't have any favourite toots yet. When you favourite one, it will show up here.",
-  "empty_column.favourites": "No one has favourited this toot yet. When someone does, they will show up here.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.direct": "Anda tidak mempunyai mesej terus. Apabila anda menghantar atau menerimanya, ia akan muncul di sini.",
+  "empty_column.domain_blocks": "Belum ada domain yang disekat.",
+  "empty_column.favourited_statuses": "Anda belum ada hantaran yang digemari. Apabila anda menggemari sesuatu, ia akan muncul di sini.",
+  "empty_column.favourites": "Tiada sesiapa yang menggemari hantaran ini. Apabila ada yang menggemari, ia akan muncul di sini.",
+  "empty_column.follow_recommendations": "Nampaknya tiada cadangan yang boleh dijana untuk anda. Anda boleh cuba gunakan gelintar untuk mencari orang yang anda mungkin kenal atau jelajahi tanda pagar sohor kini.",
   "empty_column.follow_requests": "Anda belum mempunyai permintaan ikutan. Ia akan terpapar di sini apabila ada nanti.",
-  "empty_column.hashtag": "Belum ada apa-apa dengan hashtag ini.",
-  "empty_column.home": "Garis masa halaman utama anda kosong! Lawati {public} atau lakukan carian untuk bermula dan berjumpa para pengguna lain.",
-  "empty_column.home.suggestions": "See some suggestions",
-  "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
-  "empty_column.lists": "Anda belum mempunyai sebarang senarai. Ia akan terpapar di sini apabila anda merekanya.",
+  "empty_column.hashtag": "Belum ada apa-apa dengan tanda pagar ini.",
+  "empty_column.home": "Garis masa laman utama anda kosong! Ikuti lebih ramai orang untuk mengisinya. {suggestions}",
+  "empty_column.home.suggestions": "Lihat cadangan",
+  "empty_column.list": "Tiada apa-apa di senarai ini lagi. Apabila ahli senarai ini menerbitkan hantaran baharu, ia akan dipaparkan di sini.",
+  "empty_column.lists": "Anda belum ada sebarang senarai. Apabila anda menciptanya, ia akan muncul di sini.",
   "empty_column.mutes": "Anda belum membisukan sesiapa.",
-  "empty_column.notifications": "Anda belum ada sebarang pemberitahuan. Berhubunglah dengan yang lain untuk memulakan perbualan.",
-  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
-  "error.unexpected_crash.explanation": "Disebabkan 'bug' pada kod kami ataupun isu kesesuaian penyemak imbas, halaman ini tidak dapat dipaparkan dengan tepat.",
-  "error.unexpected_crash.explanation_addons": "Laman ini tidak dapat dipaparkan dengan tepat. Kesilapan ini mungkin berpunca daripada add-on ataupun peralatan terjemahan automatik penyemak imbas.",
-  "error.unexpected_crash.next_steps": "Cuba segarkan semula halaman. Jika tidak jadi juga, anda boleh menggunakan Mastodon dengan penyemak imbas lain ataupun aplikasi jatinya.",
-  "error.unexpected_crash.next_steps_addons": "Cuba menyahdayakannya dan segarkan semula halaman. Jika tidak jadi juga, anda boleh menggunakan Mastodon dengan penyemak imbas lain ataupun aplikasi jatinya.",
-  "errors.unexpected_crash.copy_stacktrace": "Salin 'stacktrace' ke papan klip",
+  "empty_column.notifications": "Anda belum ada sebarang pemberitahuan. Apabila orang lain berinteraksi dengan anda, ia akan muncul di sini.",
+  "empty_column.public": "Tiada apa-apa di sini! Tulis sesuatu secara awam, atau ikuti pengguna daripada pelayan lain secara manual untuk mengisinya",
+  "error.unexpected_crash.explanation": "Disebabkan pepijat dalam kod kami atau masalah keserasian pelayar, halaman ini tidak dapat dipaparkan dengan betulnya.",
+  "error.unexpected_crash.explanation_addons": "Halaman ini tidak dapat dipaparkan dengan betulnya. Ralat ini mungkin disebabkan oleh pemalam pelayar atau alatan penterjemahan automatik.",
+  "error.unexpected_crash.next_steps": "Cuba segarkan semula halaman. Jika itu tidak membantu, anda masih boleh menggunakan Mastodon dengan pelayar yang berlainan atau aplikasi natif.",
+  "error.unexpected_crash.next_steps_addons": "Cuba nyahdaya pemalam dan segarkan semula halaman. Jika itu tidak membantu, anda masih boleh menggunakan Mastodon dengan pelayar yang berlainan atau aplikasi natif.",
+  "errors.unexpected_crash.copy_stacktrace": "Salin surih tindanan ke papan keratan",
   "errors.unexpected_crash.report_issue": "Laporkan masalah",
   "follow_recommendations.done": "Selesai",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
-  "follow_request.authorize": "Authorize",
-  "follow_request.reject": "Reject",
-  "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
-  "generic.saved": "Saved",
-  "getting_started.developers": "Developers",
-  "getting_started.directory": "Profile directory",
-  "getting_started.documentation": "Documentation",
-  "getting_started.heading": "Getting started",
-  "getting_started.invite": "Invite people",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
-  "getting_started.security": "Security",
-  "getting_started.terms": "Terms of service",
-  "hashtag.column_header.tag_mode.all": "and {additional}",
-  "hashtag.column_header.tag_mode.any": "or {additional}",
-  "hashtag.column_header.tag_mode.none": "without {additional}",
-  "hashtag.column_settings.select.no_options_message": "No suggestions found",
-  "hashtag.column_settings.select.placeholder": "Enter hashtags…",
-  "hashtag.column_settings.tag_mode.all": "All of these",
-  "hashtag.column_settings.tag_mode.any": "Any of these",
-  "hashtag.column_settings.tag_mode.none": "None of these",
-  "hashtag.column_settings.tag_toggle": "Include additional tags in this column",
-  "home.column_settings.basic": "Basic",
-  "home.column_settings.show_reblogs": "Show boosts",
-  "home.column_settings.show_replies": "Show replies",
-  "home.hide_announcements": "Hide announcements",
-  "home.show_announcements": "Show announcements",
-  "intervals.full.days": "{number, plural, one {# day} other {# days}}",
-  "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
-  "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
+  "follow_recommendations.heading": "Ikuti orang yang anda ingin lihat hantarannya! Di sini ada beberapa cadangan.",
+  "follow_recommendations.lead": "Hantaran daripada orang yang anda ikuti akan muncul dalam susunan kronologi di suapan rumah anda. Jangan takut melakukan kesilapan, anda boleh nyahikuti orang dengan mudah pada bila-bila masa!",
+  "follow_request.authorize": "Benarkan",
+  "follow_request.reject": "Tolak",
+  "follow_requests.unlocked_explanation": "Walaupun akaun anda tidak dikunci, kakitangan {domain} merasakan anda mungkin ingin menyemak permintaan ikutan daripada akaun ini secara manual.",
+  "generic.saved": "Disimpan",
+  "getting_started.developers": "Pembangun",
+  "getting_started.directory": "Direktori profil",
+  "getting_started.documentation": "Pendokumenan",
+  "getting_started.heading": "Mari bermula",
+  "getting_started.invite": "Undang orang",
+  "getting_started.open_source_notice": "Mastodon itu perisian bersumber terbuka. Anda boleh menyumbang atau melaporkan masalah di GitHub menerusi {github}.",
+  "getting_started.security": "Tetapan akaun",
+  "getting_started.terms": "Terma perkhidmatan",
+  "hashtag.column_header.tag_mode.all": "dan {additional}",
+  "hashtag.column_header.tag_mode.any": "atau {additional}",
+  "hashtag.column_header.tag_mode.none": "tanpa {additional}",
+  "hashtag.column_settings.select.no_options_message": "Tiada cadangan dijumpai",
+  "hashtag.column_settings.select.placeholder": "Masukkan tanda pagar…",
+  "hashtag.column_settings.tag_mode.all": "Kesemua ini",
+  "hashtag.column_settings.tag_mode.any": "Mana-mana daripada yang ini",
+  "hashtag.column_settings.tag_mode.none": "Tiada apa pun daripada yang ini",
+  "hashtag.column_settings.tag_toggle": "Sertakan tag tambahan untuk lajur ini",
+  "home.column_settings.basic": "Asas",
+  "home.column_settings.show_reblogs": "Tunjukkan galakan",
+  "home.column_settings.show_replies": "Tunjukkan balasan",
+  "home.hide_announcements": "Sembunyikan pengumuman",
+  "home.show_announcements": "Tunjukkan pengumuman",
+  "intervals.full.days": "{number, plural, other {# hari}}",
+  "intervals.full.hours": "{number, plural, other {# jam}}",
+  "intervals.full.minutes": "{number, plural, other {# minit}}",
   "keyboard_shortcuts.back": "to navigate back",
   "keyboard_shortcuts.blocked": "to open blocked users list",
   "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.column": "Tumpu pada lajur",
   "keyboard_shortcuts.compose": "to focus the compose textarea",
-  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.description": "Keterangan",
   "keyboard_shortcuts.direct": "to open direct messages column",
   "keyboard_shortcuts.down": "to move down in the list",
-  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.enter": "Buka hantaran",
   "keyboard_shortcuts.favourite": "to favourite",
   "keyboard_shortcuts.favourites": "to open favourites list",
   "keyboard_shortcuts.federated": "to open federated timeline",
-  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.heading": "Pintasan papan kekunci",
   "keyboard_shortcuts.home": "to open home timeline",
-  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.hotkey": "Kekunci pantas",
   "keyboard_shortcuts.legend": "to display this legend",
   "keyboard_shortcuts.local": "to open local timeline",
   "keyboard_shortcuts.mention": "to mention author",
@@ -230,25 +237,25 @@
   "keyboard_shortcuts.my_profile": "to open your profile",
   "keyboard_shortcuts.notifications": "to open notifications column",
   "keyboard_shortcuts.open_media": "to open media",
-  "keyboard_shortcuts.pinned": "to open pinned toots list",
+  "keyboard_shortcuts.pinned": "Buka senarai hantaran tersemat",
   "keyboard_shortcuts.profile": "to open author's profile",
   "keyboard_shortcuts.reply": "to reply",
-  "keyboard_shortcuts.requests": "untuk membuka senarai permintaan ikutan",
-  "keyboard_shortcuts.search": "untuk carian bertumpu",
-  "keyboard_shortcuts.spoilers": "untuk memapar/menyembunyikan bidang CW",
-  "keyboard_shortcuts.start": "untuk membuka lajur \"bermula\"",
-  "keyboard_shortcuts.toggle_hidden": "untuk memapar/menyembunyikan teks di belakang CW",
-  "keyboard_shortcuts.toggle_sensitivity": "untuk memapar/menyembunyikan media",
-  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.requests": "Buka senarai permintaan ikutan",
+  "keyboard_shortcuts.search": "Tumpu pada bar gelintar",
+  "keyboard_shortcuts.spoilers": "Tunjuk/sembunyi medan CW",
+  "keyboard_shortcuts.start": "Buka lajur “mari bermula”",
+  "keyboard_shortcuts.toggle_hidden": "Tunjuk/sembunyi teks di sebalik CW",
+  "keyboard_shortcuts.toggle_sensitivity": "Tunjuk/sembunyi media",
+  "keyboard_shortcuts.toot": "Mula hantaran baharu",
   "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
-  "keyboard_shortcuts.up": "untuk ke atas dalam senarai",
+  "keyboard_shortcuts.up": "Pindah ke atas dalam senarai",
   "lightbox.close": "Tutup",
-  "lightbox.compress": "Kecilkan kotak tengok gambar",
-  "lightbox.expand": "Besarkan kotak tengok gambar",
+  "lightbox.compress": "Kecilkan kotak paparan imej",
+  "lightbox.expand": "Besarkan kotak paparan imej",
   "lightbox.next": "Seterusnya",
   "lightbox.previous": "Sebelumnya",
   "lists.account.add": "Tambah ke senarai",
-  "lists.account.remove": "Keluarkan dari senarai",
+  "lists.account.remove": "Buang daripada senarai",
   "lists.delete": "Padam senarai",
   "lists.edit": "Sunting senarai",
   "lists.edit.submit": "Ubah tajuk",
@@ -262,59 +269,59 @@
   "lists.subheading": "Senarai anda",
   "load_pending": "{count, plural, one {# item baharu} other {# item baharu}}",
   "loading_indicator.label": "Memuatkan...",
-  "media_gallery.toggle_visible": "Sembunyikan {number, plural, one {gambar} other {gambar}}",
+  "media_gallery.toggle_visible": "{number, plural, other {Sembunyikan imej}}",
   "missing_indicator.label": "Tidak dijumpai",
-  "missing_indicator.sublabel": "Sumber ini gagal ditemukan",
+  "missing_indicator.sublabel": "Sumber ini tidak dijumpai",
   "mute_modal.duration": "Tempoh",
   "mute_modal.hide_notifications": "Sembunyikan pemberitahuan daripada pengguna ini?",
-  "mute_modal.indefinite": "Tak tentu",
+  "mute_modal.indefinite": "Tidak tentu",
   "navigation_bar.apps": "Aplikasi mudah alih",
   "navigation_bar.blocks": "Pengguna yang disekat",
-  "navigation_bar.bookmarks": "Penanda buku",
+  "navigation_bar.bookmarks": "Tanda buku",
   "navigation_bar.community_timeline": "Garis masa tempatan",
-  "navigation_bar.compose": "Compose new toot",
-  "navigation_bar.direct": "Mesej langsung",
+  "navigation_bar.compose": "Karang hantaran baharu",
+  "navigation_bar.direct": "Mesej terus",
   "navigation_bar.discover": "Teroka",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.domain_blocks": "Domain disekat",
   "navigation_bar.edit_profile": "Sunting profil",
   "navigation_bar.favourites": "Kegemaran",
   "navigation_bar.filters": "Perkataan yang dibisukan",
   "navigation_bar.follow_requests": "Permintaan ikutan",
   "navigation_bar.follows_and_followers": "Ikutan dan pengikut",
-  "navigation_bar.info": "About this instance",
-  "navigation_bar.keyboard_shortcuts": "Kekunci Pantas",
+  "navigation_bar.info": "Perihal pelayan ini",
+  "navigation_bar.keyboard_shortcuts": "Kekunci pantas",
   "navigation_bar.lists": "Senarai",
-  "navigation_bar.logout": "Log Keluar",
+  "navigation_bar.logout": "Log keluar",
   "navigation_bar.mutes": "Pengguna yang dibisukan",
   "navigation_bar.personal": "Peribadi",
-  "navigation_bar.pins": "Pinned toots",
-  "navigation_bar.preferences": "Aturan",
+  "navigation_bar.pins": "Hantaran disemat",
+  "navigation_bar.preferences": "Keutamaan",
   "navigation_bar.public_timeline": "Garis masa bersekutu",
   "navigation_bar.security": "Keselamatan",
-  "notification.favourite": "{name} favourited your status",
+  "notification.favourite": "{name} menggemari hantaran anda",
   "notification.follow": "{name} mengikuti anda",
   "notification.follow_request": "{name} meminta untuk mengikuti anda",
   "notification.mention": "{name} menyebut anda",
   "notification.own_poll": "Undian anda telah tamat",
   "notification.poll": "Sebuah undian yang anda undi telah tamat",
-  "notification.reblog": "{name} boosted your status",
-  "notification.status": "{name} baru sahaja membuat kiriman",
-  "notifications.clear": "Bersihkan pemberitahuan",
-  "notifications.clear_confirmation": "Anda pasti mahu membuang semua pemberitahuan anda selama-lamanya?",
-  "notifications.column_settings.alert": "Pemberitahuan desktop",
+  "notification.reblog": "{name} menggalak hantaran anda",
+  "notification.status": "{name} baru sahaja mengirim hantaran",
+  "notifications.clear": "Buang pemberitahuan",
+  "notifications.clear_confirmation": "Adakah anda pasti anda ingin membuang semua pemberitahuan anda secara kekal?",
+  "notifications.column_settings.alert": "Pemberitahuan atas meja",
   "notifications.column_settings.favourite": "Kegemaran:",
   "notifications.column_settings.filter_bar.advanced": "Papar semua kategori",
   "notifications.column_settings.filter_bar.category": "Bar penapis pantas",
-  "notifications.column_settings.filter_bar.show": "Papar",
+  "notifications.column_settings.filter_bar.show": "Tunjuk",
   "notifications.column_settings.follow": "Pengikut baharu:",
   "notifications.column_settings.follow_request": "Permintaan ikutan baharu:",
   "notifications.column_settings.mention": "Sebutan:",
   "notifications.column_settings.poll": "Keputusan undian:",
   "notifications.column_settings.push": "Pemberitahuan tolak",
   "notifications.column_settings.reblog": "Galakan:",
-  "notifications.column_settings.show": "Papar dalam lajur",
+  "notifications.column_settings.show": "Tunjukkan dalam lajur",
   "notifications.column_settings.sound": "Mainkan bunyi",
-  "notifications.column_settings.status": "New toots:",
+  "notifications.column_settings.status": "Hantaran baharu:",
   "notifications.column_settings.unread_markers.category": "Penanda pemberitahuan belum dibaca",
   "notifications.filter.all": "Semua",
   "notifications.filter.boosts": "Galakan",
@@ -326,97 +333,98 @@
   "notifications.grant_permission": "Beri kebenaran.",
   "notifications.group": "{count} pemberitahuan",
   "notifications.mark_as_read": "Tandakan semua pemberitahuan sebagai sudah dibaca",
-  "notifications.permission_denied": "Pemberitahuan desktop tidak tersedia kerana permintaan kebenaran penyemak imbas sebelum ini ditolak",
-  "notifications.permission_denied_alert": "Pemberitahuan desktop tidak boleh didayakan kerana kebenaran penyemak imbas ditolak sebelum ini",
-  "notifications.permission_required": "Pemberitahuan desktop tidak tersedia kerana keizinan yang diperlukan tidak diberi.",
-  "notifications_permission_banner.enable": "Dayakan pemberitahuan desktop",
-  "notifications_permission_banner.how_to_control": "Untuk mendapat pemberitahuan ketika Mastodon tidak dibuka, dayakan pemberitahuan desktop. Anda boleh mengawal jenis interaksi mana yang menjana pemberitahuan desktop melalui butang {icon} di atas sesudah didayakan.",
-  "notifications_permission_banner.title": "Takkan terlepas apa-apa",
+  "notifications.permission_denied": "Pemberitahuan atas meja tidak tersedia kerana permintaan kebenaran pelayar sebelum ini ditolak",
+  "notifications.permission_denied_alert": "Pemberitahuan atas meja tidak boleh didayakan, kerana permintaan kebenaran pelayar sebelum ini ditolak",
+  "notifications.permission_required": "Pemberitahuan atas meja tidak tersedia kerana permintaan kebenaran masih belum diberikan.",
+  "notifications_permission_banner.enable": "Dayakan pemberitahuan atas meja",
+  "notifications_permission_banner.how_to_control": "Untuk mendapat pemberitahuan ketika Mastodon tidak dibuka, dayakan pemberitahuan atas meja. Anda boleh mengawal jenis interaksi mana yang menjana pemberitahuan atas meja melalui butang {icon} di atas setelah ia didayakan.",
+  "notifications_permission_banner.title": "Jangan terlepas apa-apa",
   "picture_in_picture.restore": "Letak semula",
   "poll.closed": "Ditutup",
-  "poll.refresh": "Muat Semula",
-  "poll.total_people": "{count, plural, one {# orang} other {# orang}}",
-  "poll.total_votes": "{count, plural, one {# undian} other {# undian}}",
+  "poll.refresh": "Muat semula",
+  "poll.total_people": "{count, plural, other {# orang}}",
+  "poll.total_votes": "{count, plural, other {# undian}}",
   "poll.vote": "Undi",
   "poll.voted": "Anda mengundi jawapan ini",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Tambah undian",
   "poll_button.remove_poll": "Buang undian",
-  "privacy.change": "Adjust status privacy",
-  "privacy.direct.long": "Post to mentioned users only",
-  "privacy.direct.short": "Langsung",
-  "privacy.private.long": "Post to followers only",
+  "privacy.change": "Ubah privasi hantaran",
+  "privacy.direct.long": "Hanya boleh dilihat oleh pengguna disebut",
+  "privacy.direct.short": "Terus",
+  "privacy.private.long": "Hanya boleh dilihat oleh pengikut",
   "privacy.private.short": "Pengikut sahaja",
-  "privacy.public.long": "Post to public timelines",
+  "privacy.public.long": "Boleh dilihat oleh semua orang, ditunjukkan di garis masa awam",
   "privacy.public.short": "Awam",
-  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.long": "Boleh dilihat oleh semua orang, tapi jangan tunjukkan di garis masa awam",
   "privacy.unlisted.short": "Tidak tersenarai",
-  "refresh": "Muat Semula",
+  "refresh": "Muat semula",
   "regeneration_indicator.label": "Memuatkan…",
-  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
-  "relative_time.days": "{number}d",
-  "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
+  "regeneration_indicator.sublabel": "Suapan rumah anda sedang disediakan!",
+  "relative_time.days": "{number}h",
+  "relative_time.hours": "{number}j",
+  "relative_time.just_now": "sekarang",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
-  "relative_time.today": "today",
-  "reply_indicator.cancel": "Cancel",
-  "report.forward": "Forward to {target}",
-  "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
-  "report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
-  "report.placeholder": "Additional comments",
-  "report.submit": "Submit",
-  "report.target": "Report {target}",
-  "search.placeholder": "Search",
-  "search_popout.search_format": "Advanced search format",
-  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
-  "search_popout.tips.hashtag": "hashtag",
-  "search_popout.tips.status": "status",
-  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
-  "search_popout.tips.user": "user",
-  "search_results.accounts": "People",
-  "search_results.hashtags": "Hashtags",
-  "search_results.statuses": "Toots",
-  "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
-  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "status.admin_account": "Open moderation interface for @{name}",
-  "status.admin_status": "Open this status in the moderation interface",
-  "status.block": "Block @{name}",
-  "status.bookmark": "Bookmark",
-  "status.cancel_reblog_private": "Unboost",
-  "status.cannot_reblog": "This post cannot be boosted",
-  "status.copy": "Copy link to status",
-  "status.delete": "Delete",
-  "status.detailed_status": "Detailed conversation view",
-  "status.direct": "Direct message @{name}",
-  "status.embed": "Embed",
-  "status.favourite": "Favourite",
-  "status.filtered": "Filtered",
-  "status.load_more": "Load more",
-  "status.media_hidden": "Media hidden",
-  "status.mention": "Mention @{name}",
-  "status.more": "More",
-  "status.mute": "Mute @{name}",
-  "status.mute_conversation": "Mute conversation",
-  "status.open": "Expand this status",
-  "status.pin": "Pin on profile",
-  "status.pinned": "Pinned toot",
-  "status.read_more": "Read more",
-  "status.reblog": "Boost",
-  "status.reblog_private": "Boost with original visibility",
-  "status.reblogged_by": "{name} boosted",
-  "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
-  "status.redraft": "Delete & re-draft",
-  "status.remove_bookmark": "Remove bookmark",
-  "status.reply": "Reply",
-  "status.replyAll": "Reply to thread",
-  "status.report": "Report @{name}",
-  "status.sensitive_warning": "Sensitive content",
-  "status.share": "Share",
-  "status.show_less": "Papar sedikit",
-  "status.show_less_all": "Papar sedikit untuk semua",
-  "status.show_more": "Papar lagi",
-  "status.show_more_all": "Papar lebih untuk semua",
-  "status.show_thread": "Tunjukkan perbincangan",
+  "relative_time.today": "hari ini",
+  "reply_indicator.cancel": "Batal",
+  "report.forward": "Panjangkan ke {target}",
+  "report.forward_hint": "Akaun ini daripada pelayan lain. Hantar salinan laporan yang ditanpanamakan ke sana juga?",
+  "report.hint": "Laporan akan dihantar ke penyederhana pelayan anda. Anda boleh sertakan penerangan kenapa anda laporkan akaun ini di bawah:",
+  "report.placeholder": "Ulasan tambahan",
+  "report.submit": "Serah",
+  "report.target": "Melaporkan {target}",
+  "search.placeholder": "Cari",
+  "search_popout.search_format": "Format gelintar lanjutan",
+  "search_popout.tips.full_text": "Teks ringkas mengembalikan hantaran yang anda telah tulis, menggemari, menggalak, atau telah disebutkan, dan juga nama pengguna, nama paparan, dan tanda pagar yang dipadankan.",
+  "search_popout.tips.hashtag": "tanda pagar",
+  "search_popout.tips.status": "hantaran",
+  "search_popout.tips.text": "Teks ringkas mengembalikan nama paparan, nama pengguna dan tanda pagar yang sepadan",
+  "search_popout.tips.user": "pengguna",
+  "search_results.accounts": "Orang",
+  "search_results.hashtags": "Tanda pagar",
+  "search_results.statuses": "Hantaran",
+  "search_results.statuses_fts_disabled": "Menggelintar hantaran menggunakan kandungannya tidak didayakan di pelayan Mastodon ini.",
+  "search_results.total": "{count, number} {count, plural, other {hasil}}",
+  "status.admin_account": "Buka antara muka penyederhanaan untuk @{name}",
+  "status.admin_status": "Buka hantaran ini dalam antara muka penyederhanaan",
+  "status.block": "Sekat @{name}",
+  "status.bookmark": "Tanda buku",
+  "status.cancel_reblog_private": "Nyahgalak",
+  "status.cannot_reblog": "Hantaran ini tidak boleh digalakkan",
+  "status.copy": "Salin pautan ke hantaran",
+  "status.delete": "Padam",
+  "status.detailed_status": "Paparan perbualan terperinci",
+  "status.direct": "Mesej terus @{name}",
+  "status.embed": "Benaman",
+  "status.favourite": "Kegemaran",
+  "status.filtered": "Ditapis",
+  "status.load_more": "Muatkan lagi",
+  "status.media_hidden": "Media disembunyikan",
+  "status.mention": "Sebut @{name}",
+  "status.more": "Lagi",
+  "status.mute": "Bisukan @{name}",
+  "status.mute_conversation": "Bisukan perbualan",
+  "status.open": "Kembangkan hantaran ini",
+  "status.pin": "Semat di profil",
+  "status.pinned": "Hantaran disemat",
+  "status.read_more": "Baca lagi",
+  "status.reblog": "Galakkan",
+  "status.reblog_private": "Galakkan dengan kebolehlihatan asal",
+  "status.reblogged_by": "{name} telah menggalakkan",
+  "status.reblogs.empty": "Tiada sesiapa yang menggalak hantaran ini. Apabila ada yang menggalak, ia akan muncul di sini.",
+  "status.redraft": "Padam & rangka semula",
+  "status.remove_bookmark": "Buang tanda buku",
+  "status.reply": "Balas",
+  "status.replyAll": "Balas ke bebenang",
+  "status.report": "Laporkan @{name}",
+  "status.sensitive_warning": "Kandungan sensitif",
+  "status.share": "Kongsi",
+  "status.show_less": "Tunjukkan kurang",
+  "status.show_less_all": "Tunjukkan kurang untuk semua",
+  "status.show_more": "Tunjukkan lebih",
+  "status.show_more_all": "Tunjukkan lebih untuk semua",
+  "status.show_thread": "Tunjuk bebenang",
   "status.uncached_media_warning": "Tidak tersedia",
   "status.unmute_conversation": "Nyahbisukan perbualan",
   "status.unpin": "Nyahsemat daripada profil",
@@ -427,49 +435,50 @@
   "tabs_bar.local_timeline": "Tempatan",
   "tabs_bar.notifications": "Pemberitahuan",
   "tabs_bar.search": "Cari",
-  "time_remaining.days": "tinggal {number, plural, one {# hari} other {# hari}}",
-  "time_remaining.hours": "tinggal {number, plural, one {# jam} other {# jam}}",
-  "time_remaining.minutes": "tinggal {number, plural, one {# minit} other {# minit}}",
-  "time_remaining.moments": "Masa yang tinggal",
-  "time_remaining.seconds": "tinggal {number, plural, one {# saat} other {# saat}}",
+  "time_remaining.days": "Tinggal {number, plural, other {# hari}}",
+  "time_remaining.hours": "Tinggal {number, plural, other {# jam}}",
+  "time_remaining.minutes": "Tinggal {number, plural, other {# minit}}",
+  "time_remaining.moments": "Tinggal beberapa saat",
+  "time_remaining.seconds": "Tinggal {number, plural, other {# saat}}",
   "timeline_hint.remote_resource_not_displayed": "{resource} dari pelayan lain tidak dipaparkan.",
   "timeline_hint.resources.followers": "Pengikut",
   "timeline_hint.resources.follows": "Ikutan",
-  "timeline_hint.resources.statuses": "Older toots",
-  "trends.counter_by_accounts": "{count, plural, one {{counter} orang}other {{counter} orang}} bercakap",
-  "trends.trending_now": "Trending kini",
-  "ui.beforeunload": "Draf anda akan terhapus jika anda meninggalkan Mastodon.",
+  "timeline_hint.resources.statuses": "Hantaran lebih lama",
+  "trends.counter_by_accounts": "{count, plural, other {{counter} orang}} bercakap",
+  "trends.trending_now": "Sohor kini",
+  "ui.beforeunload": "Rangka anda akan terhapus jika anda meninggalkan Mastodon.",
   "units.short.billion": "{count}B",
   "units.short.million": "{count}J",
   "units.short.thousand": "{count}R",
   "upload_area.title": "Seret & letak untuk muat naik",
-  "upload_button.label": "Tambah fail gambar, video atau audio",
+  "upload_button.label": "Tambah fail imej, video atau audio",
   "upload_error.limit": "Sudah melebihi had muat naik.",
   "upload_error.poll": "Tidak boleh memuat naik fail bersama undian.",
-  "upload_form.audio_description": "Menjelaskan untuk orang yang ada masalah pendengaran",
-  "upload_form.description": "Menjelaskan untuk orang yang ada masalah penglihatan",
+  "upload_form.audio_description": "Jelaskan untuk orang yang ada masalah pendengaran",
+  "upload_form.description": "Jelaskan untuk orang yang ada masalah penglihatan",
   "upload_form.edit": "Sunting",
   "upload_form.thumbnail": "Ubah gambar kecil",
   "upload_form.undo": "Padam",
-  "upload_form.video_description": "Menjelaskan untuk orang yang ada masalah pendengaran atau penglihatan",
-  "upload_modal.analyzing_picture": "Meneliti gambar…",
+  "upload_form.video_description": "Jelaskan untuk orang yang ada masalah pendengaran atau penglihatan",
+  "upload_modal.analyzing_picture": "Menganalisis gambar…",
   "upload_modal.apply": "Guna",
-  "upload_modal.choose_image": "Pilih gambar",
-  "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
-  "upload_modal.detect_text": "Cam perkataan daripada gambar",
+  "upload_modal.applying": "Applying…",
+  "upload_modal.choose_image": "Pilih imej",
+  "upload_modal.description_placeholder": "Seekor rubah perang pantas melompat merentasi anjing yang pemalas",
+  "upload_modal.detect_text": "Kesan teks daripada gambar",
   "upload_modal.edit_media": "Sunting media",
-  "upload_modal.hint": "Ketik atau seret ke bulatan pada pratonton untuk memilih titik tumpu yang akan kelihatan pada semua gambar kecil.",
+  "upload_modal.hint": "Klik atau seret bulatan di pratonton untuk memilih titik tumpu yang akan kelihatan pada semua gambar kecil.",
   "upload_modal.preparing_ocr": "Mempersiapkan OCR…",
   "upload_modal.preview_label": "Pratonton ({ratio})",
-  "upload_progress.label": "Uploading…",
+  "upload_progress.label": "Memuat naik...",
   "video.close": "Tutup video",
   "video.download": "Muat turun fail",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
-  "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "video.exit_fullscreen": "Keluar skrin penuh",
+  "video.expand": "Besarkan video",
+  "video.fullscreen": "Skrin penuh",
+  "video.hide": "Sembunyikan video",
+  "video.mute": "Bisukan bunyi",
+  "video.pause": "Jeda",
+  "video.play": "Main",
+  "video.unmute": "Nyahbisukan bunyi"
 }
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index ca5c86a82..7ef5a05e1 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -47,11 +47,16 @@
   "account.unmute": "@{name} niet langer negeren",
   "account.unmute_notifications": "Meldingen van @{name} niet langer negeren",
   "account_note.placeholder": "Klik om een opmerking toe te voegen",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Probeer het nog een keer na {retry_time, time, medium}.",
   "alert.rate_limited.title": "Beperkt te gebruiken",
   "alert.unexpected.message": "Er deed zich een onverwachte fout voor",
   "alert.unexpected.title": "Oeps!",
   "announcement.announcement": "Mededeling",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
   "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Weet je het zeker dat je deze toot wilt verwijderen?",
   "confirmations.delete_list.confirm": "Verwijderen",
   "confirmations.delete_list.message": "Weet je zeker dat je deze lijst definitief wilt verwijderen?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Verberg alles van deze server",
   "confirmations.domain_block.message": "Weet je het echt heel erg zeker dat je alles van {domain} wilt negeren? In de meeste gevallen is het blokkeren of negeren van een paar specifieke personen voldoende en beter. Je zult geen toots van deze server op openbare tijdlijnen zien of in jouw meldingen. Jouw volgers van deze server worden verwijderd.",
   "confirmations.logout.confirm": "Uitloggen",
@@ -142,7 +149,7 @@
   "emoji_button.food": "Eten en drinken",
   "emoji_button.label": "Emoji toevoegen",
   "emoji_button.nature": "Natuur",
-  "emoji_button.not_found": "Geen emoji’s!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Geen overeenkomende emojis gevonden",
   "emoji_button.objects": "Voorwerpen",
   "emoji_button.people": "Mensen",
   "emoji_button.recent": "Vaak gebruikt",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# stem} other {# stemmen}}",
   "poll.vote": "Stemmen",
   "poll.voted": "Je hebt hier op gestemd",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Poll toevoegen",
   "poll_button.remove_poll": "Poll verwijderen",
   "privacy.change": "Zichtbaarheid van toot aanpassen",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Omschrijf dit voor mensen met een auditieve of visuele beperking",
   "upload_modal.analyzing_picture": "Afbeelding analyseren…",
   "upload_modal.apply": "Toepassen",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Kies een afbeelding",
   "upload_modal.description_placeholder": "Pa's wijze lynx bezag vroom het fikse aquaduct",
   "upload_modal.detect_text": "Tekst in een afbeelding detecteren",
diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json
index 234b2175c..cfd9aaca9 100644
--- a/app/javascript/mastodon/locales/nn.json
+++ b/app/javascript/mastodon/locales/nn.json
@@ -22,7 +22,7 @@
   "account.follows.empty": "Denne brukaren fylgjer ikkje nokon enno.",
   "account.follows_you": "Fylgjer deg",
   "account.hide_reblogs": "Gøym fremhevingar frå @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Ble med den {date}",
   "account.last_status": "Sist aktiv",
   "account.link_verified_on": "Eigarskap for denne lenkja vart sist sjekka {date}",
   "account.locked_info": "Denne kontoen er privat. Eigaren kan sjølv velja kven som kan fylgja han.",
@@ -47,11 +47,16 @@
   "account.unmute": "Av-demp @{name}",
   "account.unmute_notifications": "Vis varsel frå @{name}",
   "account_note.placeholder": "Klikk for å leggja til merknad",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Ver venleg å prøva igjen etter {retry_time, time, medium}.",
   "alert.rate_limited.title": "Begrensa rate",
   "alert.unexpected.message": "Eit uventa problem oppstod.",
   "alert.unexpected.title": "Oi sann!",
   "announcement.announcement": "Kunngjering",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per veke",
   "boost_modal.combo": "Du kan trykkja {combo} for å hoppa over dette neste gong",
   "bundle_column_error.body": "Noko gjekk gale mens denne komponenten vart lasta ned.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Er du sikker på at du vil sletta denne statusen?",
   "confirmations.delete_list.confirm": "Slett",
   "confirmations.delete_list.message": "Er du sikker på at du vil sletta denne lista for alltid?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Gøym heile domenet",
   "confirmations.domain_block.message": "Er du heilt, heilt sikker på at du vil blokkera heile {domain}? I dei fleste tilfelle er det godt nok og føretrekt med nokre få målretta blokkeringar eller målbindingar. Du kjem ikkje til å sjå innhald frå det domenet i nokon fødererte tidsliner eller i varsla dine. Fylgjarane dine frå det domenet vert fjerna.",
   "confirmations.logout.confirm": "Logg ut",
@@ -150,7 +157,7 @@
   "emoji_button.search_results": "Søkeresultat",
   "emoji_button.symbols": "Symbol",
   "emoji_button.travel": "Reise & stader",
-  "empty_column.account_suspended": "Account suspended",
+  "empty_column.account_suspended": "Kontoen er suspendert",
   "empty_column.account_timeline": "Ingen tut her!",
   "empty_column.account_unavailable": "Profil ikkje tilgjengelig",
   "empty_column.blocks": "Du har ikkje blokkert nokon brukarar enno.",
@@ -160,11 +167,11 @@
   "empty_column.domain_blocks": "Det er ingen gøymde domene ennå.",
   "empty_column.favourited_statuses": "Du har ingen favoritt-tut ennå. Når du merkjer ein som favoritt, så dukkar det opp her.",
   "empty_column.favourites": "Ingen har merkt dette tutet som favoritt enno. Når nokon gjer det, så dukkar det opp her.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.follow_recommendations": "Ser ut som at det ikke finnes noen forslag for deg. Du kan prøve å bruke søk for å se etter folk du kan vite eller utforske trendende hashtags.",
   "empty_column.follow_requests": "Du har ingen følgjeførespurnadar ennå. Når du får ein, så vil den dukke opp her.",
   "empty_column.hashtag": "Det er ingenting i denne emneknaggen ennå.",
   "empty_column.home": "Heime-tidslinja di er tom! Besøk {public} eller søk for å starte og å møte andre brukarar.",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home.suggestions": "Se noen forslag",
   "empty_column.list": "Det er ingenting i denne lista enno. Når medlemer av denne lista legg ut nye statusar, så dukkar dei opp her.",
   "empty_column.lists": "Du har ingen lister enno. Når du lagar ei, så dukkar ho opp her.",
   "empty_column.mutes": "Du har ikkje målbunde nokon brukarar enno.",
@@ -173,12 +180,12 @@
   "error.unexpected_crash.explanation": "På grunn av ein feil i vår kode eller eit nettlesarkompatibilitetsproblem, kunne ikkje denne sida verte vist korrekt.",
   "error.unexpected_crash.explanation_addons": "Denne siden kunne ikke vises riktig. Denne feilen er sannsynligvis forårsaket av en nettleserutvidelse eller automatiske oversettelsesverktøy.",
   "error.unexpected_crash.next_steps": "Prøv å lasta inn sida på nytt. Om det ikkje hjelper så kan du framleis nytta Mastodon i ein annan nettlesar eller app.",
-  "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
+  "error.unexpected_crash.next_steps_addons": "Prøv å deaktivere dem og laste siden på nytt. Hvis det ikke hjelper, kan du fremdeles bruke Mastodon via en annen nettleser eller en annen app.",
   "errors.unexpected_crash.copy_stacktrace": "Kopier stacktrace til utklippstavla",
   "errors.unexpected_crash.report_issue": "Rapporter problem",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "follow_recommendations.done": "Utført",
+  "follow_recommendations.heading": "Følg folk du ønsker å se innlegg fra! Her er noen forslag.",
+  "follow_recommendations.lead": "Innlegg fra mennesker du følger vil vises i kronologisk rekkefølge på hjemmefeed. Ikke vær redd for å gjøre feil, du kan slutte å følge folk like enkelt som alt!",
   "follow_request.authorize": "Autoriser",
   "follow_request.reject": "Avvis",
   "follow_requests.unlocked_explanation": "Sjølv om kontoen din ikkje er låst tenkte {domain} tilsette at du ville gå gjennom førespurnadar frå desse kontoane manuelt.",
@@ -243,8 +250,8 @@
   "keyboard_shortcuts.unfocus": "for å fokusere vekk skrive-/søkefeltet",
   "keyboard_shortcuts.up": "for å flytta seg opp på lista",
   "lightbox.close": "Lukk att",
-  "lightbox.compress": "Compress image view box",
-  "lightbox.expand": "Expand image view box",
+  "lightbox.compress": "Komprimer bildevisningsboks",
+  "lightbox.expand": "Ekspander bildevisning boks",
   "lightbox.next": "Neste",
   "lightbox.previous": "Førre",
   "lists.account.add": "Legg til i liste",
@@ -254,9 +261,9 @@
   "lists.edit.submit": "Endre tittel",
   "lists.new.create": "Legg til liste",
   "lists.new.title_placeholder": "Ny listetittel",
-  "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
+  "lists.replies_policy.followed": "Enhver fulgt bruker",
+  "lists.replies_policy.list": "Medlemmer i listen",
+  "lists.replies_policy.none": "Ingen",
   "lists.replies_policy.title": "Vis svar på:",
   "lists.search": "Søk gjennom folk du følgjer",
   "lists.subheading": "Dine lister",
@@ -315,7 +322,7 @@
   "notifications.column_settings.show": "Vis i kolonne",
   "notifications.column_settings.sound": "Spel av lyd",
   "notifications.column_settings.status": "Nye tuter:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.unread_markers.category": "Ulest meldingsmarkører",
   "notifications.filter.all": "Alle",
   "notifications.filter.boosts": "Framhevingar",
   "notifications.filter.favourites": "Favorittar",
@@ -323,14 +330,14 @@
   "notifications.filter.mentions": "Nemningar",
   "notifications.filter.polls": "Røysteresultat",
   "notifications.filter.statuses": "Oppdateringer fra folk du følger",
-  "notifications.grant_permission": "Grant permission.",
+  "notifications.grant_permission": "Gi tillatelse.",
   "notifications.group": "{count} varsel",
   "notifications.mark_as_read": "Merk alle varsler som lest",
-  "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
-  "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
-  "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
+  "notifications.permission_denied": "Skrivebordsvarsler er ikke tilgjengelige på grunn av tidligere nektet nettlesertillatelser",
+  "notifications.permission_denied_alert": "Skrivebordsvarsler kan ikke aktiveres, ettersom lesertillatelse har blitt nektet før",
+  "notifications.permission_required": "Skrivebordsvarsler er utilgjengelige fordi nødvendige rettigheter ikke er gitt.",
   "notifications_permission_banner.enable": "Skru på skrivebordsvarsler",
-  "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
+  "notifications_permission_banner.how_to_control": "For å motta varsler når Mastodon ikke er åpne, aktiver desktop varsler. Du kan kontrollere nøyaktig hvilke typer interaksjoner genererer skrivebordsvarsler gjennom {icon} -knappen ovenfor når de er aktivert.",
   "notifications_permission_banner.title": "Aldri gå glipp av noe",
   "picture_in_picture.restore": "Legg den tilbake",
   "poll.closed": "Lukka",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# røyst} other {# røyster}}",
   "poll.vote": "Røyst",
   "poll.voted": "Du røysta på dette svaret",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Start ei meiningsmåling",
   "poll_button.remove_poll": "Fjern røyst",
   "privacy.change": "Juster status-synlegheit",
@@ -454,12 +462,13 @@
   "upload_form.video_description": "Greit ut for folk med nedsett høyrsel eller syn",
   "upload_modal.analyzing_picture": "Analyserer bilete…",
   "upload_modal.apply": "Bruk",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Vel bilete",
   "upload_modal.description_placeholder": "Ein rask brun rev hoppar over den late hunden",
   "upload_modal.detect_text": "Gjenkjenn tekst i biletet",
   "upload_modal.edit_media": "Rediger medium",
   "upload_modal.hint": "Klikk og dra sirkelen på førehandsvisninga for å velge fokuspunktet som alltid vil vere synleg på alle miniatyrbileta.",
-  "upload_modal.preparing_ocr": "Preparing OCR…",
+  "upload_modal.preparing_ocr": "Forbereder OCR…",
   "upload_modal.preview_label": "Førehandsvis ({ratio})",
   "upload_progress.label": "Lastar opp...",
   "video.close": "Lukk video",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 08205aed2..179bbd1bd 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -22,7 +22,7 @@
   "account.follows.empty": "Denne brukeren følger ikke noen enda.",
   "account.follows_you": "Følger deg",
   "account.hide_reblogs": "Skjul fremhevinger fra @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Ble med den {date}",
   "account.last_status": "Sist aktiv",
   "account.link_verified_on": "Eierskap av denne lenken ble sjekket {date}",
   "account.locked_info": "Denne kontoens personvernstatus er satt til låst. Eieren vurderer manuelt hvem som kan følge dem.",
@@ -47,11 +47,16 @@
   "account.unmute": "Avdemp @{name}",
   "account.unmute_notifications": "Vis varsler fra @{name}",
   "account_note.placeholder": "Klikk for å legge til et notat",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Vennligst prøv igjen etter kl. {retry_time, time, medium}.",
   "alert.rate_limited.title": "Hastighetsbegrenset",
   "alert.unexpected.message": "En uventet feil oppstod.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.title": "Oi!",
   "announcement.announcement": "Kunngjøring",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per uke",
   "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
   "bundle_column_error.body": "Noe gikk galt mens denne komponenten lastet.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Er du sikker på at du vil slette denne statusen?",
   "confirmations.delete_list.confirm": "Slett",
   "confirmations.delete_list.message": "Er du sikker på at du vil slette denne listen permanent?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Skjul alt fra domenet",
   "confirmations.domain_block.message": "Er du sikker på at du vil skjule hele domenet {domain}? I de fleste tilfeller er det bedre med målrettet blokkering eller demping.",
   "confirmations.logout.confirm": "Logg ut",
@@ -150,7 +157,7 @@
   "emoji_button.search_results": "Søkeresultat",
   "emoji_button.symbols": "Symboler",
   "emoji_button.travel": "Reise & steder",
-  "empty_column.account_suspended": "Account suspended",
+  "empty_column.account_suspended": "Kontoen er suspendert",
   "empty_column.account_timeline": "Ingen tuter er her!",
   "empty_column.account_unavailable": "Profilen er utilgjengelig",
   "empty_column.blocks": "Du har ikke blokkert noen brukere enda.",
@@ -160,11 +167,11 @@
   "empty_column.domain_blocks": "Det er ingen skjulte domener enda.",
   "empty_column.favourited_statuses": "Du har ikke likt noen tuter enda. Når du liker en, vil den dukke opp her.",
   "empty_column.favourites": "Ingen har likt denne tuten enda. Når noen gjør det, vil de dukke opp her.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.follow_recommendations": "Ser ut som at det ikke finnes noen forslag for deg. Du kan prøve å bruke søk for å se etter folk du kan vite eller utforske trendende hashtags.",
   "empty_column.follow_requests": "Du har ingen følgeforespørsler enda. Når du mottar en, vil den dukke opp her.",
   "empty_column.hashtag": "Det er ingenting i denne hashtagen ennå.",
   "empty_column.home": "Du har ikke fulgt noen ennå. Besøk {publlic} eller bruk søk for å komme i gang og møte andre brukere.",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home.suggestions": "Se noen forslag",
   "empty_column.list": "Det er ingenting i denne listen ennå. Når medlemmene av denne listen legger ut nye statuser vil de dukke opp her.",
   "empty_column.lists": "Du har ingen lister enda. Når du lager en, vil den dukke opp her.",
   "empty_column.mutes": "Du har ikke dempet noen brukere enda.",
@@ -173,12 +180,12 @@
   "error.unexpected_crash.explanation": "På grunn av en bug i koden vår eller et nettleserkompatibilitetsproblem, kunne denne siden ikke vises riktig.",
   "error.unexpected_crash.explanation_addons": "Denne siden kunne ikke vises riktig. Denne feilen er sannsynligvis forårsaket av en nettleserutvidelse eller automatiske oversettelsesverktøy.",
   "error.unexpected_crash.next_steps": "Prøv å oppfriske siden. Dersom det ikke hjelper, vil du kanskje fortsatt kunne bruke Mastodon gjennom en annen nettleser eller app.",
-  "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
+  "error.unexpected_crash.next_steps_addons": "Prøv å deaktivere dem og laste siden på nytt. Hvis det ikke hjelper, kan du fremdeles bruke Mastodon via en annen nettleser eller en annen app.",
   "errors.unexpected_crash.copy_stacktrace": "Kopier stacktrace-en til utklippstavlen",
   "errors.unexpected_crash.report_issue": "Rapporter en feil",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "follow_recommendations.done": "Utført",
+  "follow_recommendations.heading": "Følg folk du ønsker å se innlegg fra! Her er noen forslag.",
+  "follow_recommendations.lead": "Innlegg fra mennesker du følger vil vises i kronologisk rekkefølge på hjemmefeed. Ikke vær redd for å gjøre feil, du kan slutte å følge folk like enkelt som alt!",
   "follow_request.authorize": "Autorisér",
   "follow_request.reject": "Avvis",
   "follow_requests.unlocked_explanation": "Selv om kontoen din ikke er låst, tror {domain} ansatte at du kanskje vil gjennomgå forespørsler fra disse kontoene manuelt.",
@@ -239,12 +246,12 @@
   "keyboard_shortcuts.start": "åpne «Sett i gang»-kolonnen",
   "keyboard_shortcuts.toggle_hidden": "å vise/skjule tekst bak en innholdsadvarsel",
   "keyboard_shortcuts.toggle_sensitivity": "å vise/skjule media",
-  "keyboard_shortcuts.toot": "å starte en helt ny tut",
+  "keyboard_shortcuts.toot": "Start et nytt innlegg",
   "keyboard_shortcuts.unfocus": "å ufokusere komponerings-/søkefeltet",
   "keyboard_shortcuts.up": "å flytte opp i listen",
   "lightbox.close": "Lukk",
-  "lightbox.compress": "Compress image view box",
-  "lightbox.expand": "Expand image view box",
+  "lightbox.compress": "Komprimer bildevisningsboks",
+  "lightbox.expand": "Ekspander bildevisning boks",
   "lightbox.next": "Neste",
   "lightbox.previous": "Forrige",
   "lists.account.add": "Legg til i listen",
@@ -254,9 +261,9 @@
   "lists.edit.submit": "Endre tittel",
   "lists.new.create": "Ligg til liste",
   "lists.new.title_placeholder": "Ny listetittel",
-  "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
+  "lists.replies_policy.followed": "Enhver fulgt bruker",
+  "lists.replies_policy.list": "Medlemmer i listen",
+  "lists.replies_policy.none": "Ingen",
   "lists.replies_policy.title": "Vis svar på:",
   "lists.search": "Søk blant personer du følger",
   "lists.subheading": "Dine lister",
@@ -315,7 +322,7 @@
   "notifications.column_settings.show": "Vis i kolonne",
   "notifications.column_settings.sound": "Spill lyd",
   "notifications.column_settings.status": "Nye tuter:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.unread_markers.category": "Ulest meldingsmarkører",
   "notifications.filter.all": "Alle",
   "notifications.filter.boosts": "Fremhevinger",
   "notifications.filter.favourites": "Favoritter",
@@ -323,14 +330,14 @@
   "notifications.filter.mentions": "Nevnelser",
   "notifications.filter.polls": "Avstemningsresultater",
   "notifications.filter.statuses": "Oppdateringer fra folk du følger",
-  "notifications.grant_permission": "Grant permission.",
+  "notifications.grant_permission": "Gi tillatelse.",
   "notifications.group": "{count} varslinger",
   "notifications.mark_as_read": "Merk alle varsler som lest",
-  "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
-  "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
-  "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
+  "notifications.permission_denied": "Skrivebordsvarsler er ikke tilgjengelige på grunn av tidligere nektet nettlesertillatelser",
+  "notifications.permission_denied_alert": "Skrivebordsvarsler kan ikke aktiveres, ettersom lesertillatelse har blitt nektet før",
+  "notifications.permission_required": "Skrivebordsvarsler er utilgjengelige fordi nødvendige rettigheter ikke er gitt.",
   "notifications_permission_banner.enable": "Skru på skrivebordsvarsler",
-  "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
+  "notifications_permission_banner.how_to_control": "For å motta varsler når Mastodon ikke er åpne, aktiver desktop varsler. Du kan kontrollere nøyaktig hvilke typer interaksjoner genererer skrivebordsvarsler gjennom {icon} -knappen ovenfor når de er aktivert.",
   "notifications_permission_banner.title": "Aldri gå glipp av noe",
   "picture_in_picture.restore": "Legg den tilbake",
   "poll.closed": "Lukket",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# stemme} other {# stemmer}}",
   "poll.vote": "Stem",
   "poll.voted": "Du stemte på dette svaret",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Legg til en avstemning",
   "poll_button.remove_poll": "Fjern avstemningen",
   "privacy.change": "Justér synlighet",
@@ -432,7 +440,7 @@
   "time_remaining.minutes": "{number, plural, one {# minutt} other {# minutter}} igjen",
   "time_remaining.moments": "Gjenværende øyeblikk",
   "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekunder}} igjen",
-  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.remote_resource_not_displayed": "{resource} fra andre servere vises ikke.",
   "timeline_hint.resources.followers": "Følgere",
   "timeline_hint.resources.follows": "Følger",
   "timeline_hint.resources.statuses": "Eldre tuter",
@@ -454,12 +462,13 @@
   "upload_form.video_description": "Beskriv det for folk med hørselstap eller synshemminger",
   "upload_modal.analyzing_picture": "Analyserer bildet …",
   "upload_modal.apply": "Bruk",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Velg et bilde",
   "upload_modal.description_placeholder": "Når du en gang kommer, neste sommer, skal vi atter drikke vin",
   "upload_modal.detect_text": "Oppdag tekst i bildet",
   "upload_modal.edit_media": "Rediger media",
   "upload_modal.hint": "Klikk eller dra sirkelen i forhåndsvisningen for å velge hovedpunktet som alltid vil bli vist i alle miniatyrbilder.",
-  "upload_modal.preparing_ocr": "Preparing OCR…",
+  "upload_modal.preparing_ocr": "Forbereder OCR…",
   "upload_modal.preview_label": "Forhåndsvisning ({ratio})",
   "upload_progress.label": "Laster opp...",
   "video.close": "Lukk video",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index d93fff97d..6f7bc9761 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -47,11 +47,16 @@
   "account.unmute": "Quitar de rescondre @{name}",
   "account.unmute_notifications": "Mostrar las notificacions de @{name}",
   "account_note.placeholder": "Clicar per ajustar una nòta",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Mercés de tornar ensajar aprèp {retry_time, time, medium}.",
   "alert.rate_limited.title": "Taus limitat",
   "alert.unexpected.message": "Una error s’es producha.",
   "alert.unexpected.title": "Ops !",
   "announcement.announcement": "Anóncia",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per setmana",
   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven",
   "bundle_column_error.body": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Volètz vertadièrament escafar l’estatut ?",
   "confirmations.delete_list.confirm": "Suprimir",
   "confirmations.delete_list.message": "Volètz vertadièrament suprimir aquesta lista per totjorn ?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Amagar tot lo domeni",
   "confirmations.domain_block.message": "Volètz vertadièrament blocar complètament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.\nVeiretz pas cap de contengut d’aquel domeni dins cap de flux public o dins vòstras notificacions. Vòstres seguidors d’aquel domeni seràn levats.",
   "confirmations.logout.confirm": "Desconnexion",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vòte} other {# vòtes}}",
   "poll.vote": "Votar",
   "poll.voted": "Avètz votat per aquesta responsa",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Ajustar un sondatge",
   "poll_button.remove_poll": "Levar lo sondatge",
   "privacy.change": "Ajustar la confidencialitat del messatge",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Descriure per las personas amb pèrdas auditivas o mal vesent",
   "upload_modal.analyzing_picture": "Analisi de l’imatge…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Causir un imatge",
   "upload_modal.description_placeholder": "Lo dròlle bilingüe manja un yaourt de ròcs exagonals e kiwis verds farà un an mai",
   "upload_modal.detect_text": "Detectar lo tèxt de l’imatge",
diff --git a/app/javascript/mastodon/locales/pa.json b/app/javascript/mastodon/locales/pa.json
index c1eadb5a3..eca4765c4 100644
--- a/app/javascript/mastodon/locales/pa.json
+++ b/app/javascript/mastodon/locales/pa.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index acad358ac..e8c894c37 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -47,11 +47,16 @@
   "account.unmute": "Cofnij wyciszenie @{name}",
   "account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}",
   "account_note.placeholder": "Naciśnij aby dodać notatkę",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Spróbuj ponownie po {retry_time, time, medium}.",
   "alert.rate_limited.title": "Ograniczony czasowo",
   "alert.unexpected.message": "Wystąpił nieoczekiwany błąd.",
   "alert.unexpected.title": "O nie!",
   "announcement.announcement": "Ogłoszenie",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} co tydzień",
   "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
   "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
@@ -117,6 +122,8 @@
   "confirmations.delete.message": "Czy na pewno chcesz usunąć ten wpis?",
   "confirmations.delete_list.confirm": "Usuń",
   "confirmations.delete_list.message": "Czy na pewno chcesz bezpowrotnie usunąć tą listę?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Ukryj wszystko z domeny",
   "confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.",
   "confirmations.logout.confirm": "Wyloguj",
@@ -301,7 +308,7 @@
   "notification.follow_request": "{name} poprosił(a) o możliwość śledzenia Cię",
   "notification.mention": "{name} wspomniał(a) o tobie",
   "notification.own_poll": "Twoje głosowanie zakończyło się",
-  "notification.poll": "Głosowanie w którym brałeś(-aś) udział zakończyła się",
+  "notification.poll": "Głosowanie w którym brałeś(-aś) udział zakończyło się",
   "notification.reblog": "{name} podbił(a) Twój wpis",
   "notification.status": "{name} właśnie utworzył(a) wpis",
   "notifications.clear": "Wyczyść powiadomienia",
@@ -344,6 +351,7 @@
   "poll.total_votes": "{count, plural, one {# głos} few {# głosy} many {# głosów} other {# głosów}}",
   "poll.vote": "Zagłosuj",
   "poll.voted": "Zagłosowałeś_aś na tą odpowiedź",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Dodaj głosowanie",
   "poll_button.remove_poll": "Usuń głosowanie",
   "privacy.change": "Dostosuj widoczność wpisów",
@@ -459,6 +467,7 @@
   "upload_form.video_description": "Opisz dla osób niesłyszących, niedosłyszących, niewidomych i niedowidzących",
   "upload_modal.analyzing_picture": "Analizowanie obrazu…",
   "upload_modal.apply": "Zastosuj",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Wybierz obraz",
   "upload_modal.description_placeholder": "Pchnąć w tę łódź jeża lub ośm skrzyń fig",
   "upload_modal.detect_text": "Wykryj tekst z obrazu",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index fed22080c..7745f0ca6 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -1,31 +1,31 @@
 {
   "account.account_note_header": "Nota",
-  "account.add_or_remove_from_list": "Adicionar ou Remover de listas",
+  "account.add_or_remove_from_list": "Adicionar ou remover de listas",
   "account.badges.bot": "Robô",
   "account.badges.group": "Grupo",
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Bloquear domínio {domain}",
   "account.blocked": "Bloqueado",
-  "account.browse_more_on_origin_server": "Encontre mais no perfil original",
-  "account.cancel_follow_request": "Cancelar solicitação para seguir",
+  "account.browse_more_on_origin_server": "Veja mais no perfil original",
+  "account.cancel_follow_request": "Cancelar solicitação",
   "account.direct": "Enviar toot direto para @{name}",
-  "account.disable_notifications": "Parar de me notificar quando @{name} fizer publicações",
+  "account.disable_notifications": "Cancelar notificações de @{name}",
   "account.domain_blocked": "Domínio bloqueado",
   "account.edit_profile": "Editar perfil",
-  "account.enable_notifications": "Notificar-me quando @{name} fizer publicações",
-  "account.endorse": "Destacar no perfil",
+  "account.enable_notifications": "Notificar novos toots de @{name}",
+  "account.endorse": "Recomendar",
   "account.follow": "Seguir",
   "account.followers": "Seguidores",
   "account.followers.empty": "Nada aqui.",
-  "account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
-  "account.following_counter": "{count, plural, other {{counter} Seguindo}}",
+  "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
+  "account.following_counter": "{count, plural, one {segue {counter}} other {segue {counter}}}",
   "account.follows.empty": "Nada aqui.",
-  "account.follows_you": "Segue você",
+  "account.follows_you": "te segue",
   "account.hide_reblogs": "Ocultar boosts de @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Entrou em {date}",
   "account.last_status": "Ativo pela última vez",
-  "account.link_verified_on": "Posse deste link foi verificada em {date}",
-  "account.locked_info": "Esta conta está trancada. Sua solicitação para seguir requer aprovação manual do usuário.",
+  "account.link_verified_on": "link verificado em {date}",
+  "account.locked_info": "Trancado. Seguir requer aprovação manual do perfil.",
   "account.media": "Mídia",
   "account.mention": "Mencionar @{name}",
   "account.moved_to": "{name} se mudou para:",
@@ -34,7 +34,7 @@
   "account.muted": "Silenciado",
   "account.never_active": "Nunca",
   "account.posts": "Toots",
-  "account.posts_with_replies": "Toots e respostas",
+  "account.posts_with_replies": "Com respostas",
   "account.report": "Denunciar @{name}",
   "account.requested": "Aguardando aprovação. Clique para cancelar a solicitação",
   "account.share": "Compartilhar perfil de @{name}",
@@ -42,28 +42,33 @@
   "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "Desbloquear @{name}",
   "account.unblock_domain": "Desbloquear domínio {domain}",
-  "account.unendorse": "Não destacar no perfil",
+  "account.unendorse": "Remover",
   "account.unfollow": "Deixar de seguir",
-  "account.unmute": "Tirar @{name} do mudo",
+  "account.unmute": "Dessilenciar @{name}",
   "account.unmute_notifications": "Mostrar notificações de @{name}",
-  "account_note.placeholder": "Clique para adicionar nota",
-  "alert.rate_limited.message": "Por favor tente novamente após {retry_time, time, medium}.",
-  "alert.rate_limited.title": "Frequência limitada",
+  "account_note.placeholder": "Nota pessoal sobre este perfil aqui",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
+  "alert.rate_limited.message": "Tente novamente após {retry_time, time, medium}.",
+  "alert.rate_limited.title": "Tentativas limitadas",
   "alert.unexpected.message": "Ocorreu um erro inesperado.",
   "alert.unexpected.title": "Eita!",
-  "announcement.announcement": "Anúncio",
+  "announcement.announcement": "Comunicados",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} por semana",
-  "boost_modal.combo": "Pode pressionar {combo} para pular isto na próxima vez",
-  "bundle_column_error.body": "Ocorreu um problema ao carregar este componente.",
+  "boost_modal.combo": "Pressione {combo} para pular isso na próxima vez",
+  "bundle_column_error.body": "Erro ao carregar este componente.",
   "bundle_column_error.retry": "Tente novamente",
   "bundle_column_error.title": "Erro de rede",
   "bundle_modal_error.close": "Fechar",
-  "bundle_modal_error.message": "Ocorreu um problema ao carregar este componente.",
+  "bundle_modal_error.message": "Erro ao carregar este componente.",
   "bundle_modal_error.retry": "Tente novamente",
   "column.blocks": "Usuários bloqueados",
   "column.bookmarks": "Salvos",
-  "column.community": "Local",
-  "column.direct": "Mensagens Diretas",
+  "column.community": "Linha local",
+  "column.direct": "Toots Diretos",
   "column.directory": "Explorar perfis",
   "column.domain_blocks": "Domínios bloqueados",
   "column.favourites": "Favoritos",
@@ -73,7 +78,7 @@
   "column.mutes": "Usuários silenciados",
   "column.notifications": "Notificações",
   "column.pins": "Toots fixados",
-  "column.public": "Global",
+  "column.public": "Linha global",
   "column_back_button.label": "Voltar",
   "column_header.hide_settings": "Ocultar configurações",
   "column_header.moveLeft_settings": "Mover coluna para a esquerda",
@@ -82,26 +87,26 @@
   "column_header.show_settings": "Mostrar configurações",
   "column_header.unpin": "Desafixar",
   "column_subheading.settings": "Configurações",
-  "community.column_settings.local_only": "Apenas local",
-  "community.column_settings.media_only": "Somente Mídia",
-  "community.column_settings.remote_only": "Apenas remoto",
+  "community.column_settings.local_only": "Somente local",
+  "community.column_settings.media_only": "Somente mídia",
+  "community.column_settings.remote_only": "Somente global",
   "compose_form.direct_message_warning": "Este toot só será enviado aos usuários mencionados.",
   "compose_form.direct_message_warning_learn_more": "Saiba mais",
-  "compose_form.hashtag_warning": "Este toot não vai estar listado em nenhuma hashtag porque está como não-listado. Somente toots públicos podem ser pesquisados por hashtag.",
-  "compose_form.lock_disclaimer": "Sua conta não está {locked}. Qualquer pessoa pode te seguir e ver seus toots privados.",
-  "compose_form.lock_disclaimer.lock": "trancada",
+  "compose_form.hashtag_warning": "Este toot não aparecerá em nenhuma hashtag porque está como não-listado. Somente toots públicos podem ser pesquisados por hashtag.",
+  "compose_form.lock_disclaimer": "Seu perfil não está {locked}. Qualquer um pode te seguir e ver os toots privados.",
+  "compose_form.lock_disclaimer.lock": "trancado",
   "compose_form.placeholder": "No que você está pensando?",
-  "compose_form.poll.add_option": "Adicionar uma escolha",
+  "compose_form.poll.add_option": "Adicionar opção",
   "compose_form.poll.duration": "Duração da enquete",
-  "compose_form.poll.option_placeholder": "Escolha {number}",
-  "compose_form.poll.remove_option": "Remover esta escolha",
-  "compose_form.poll.switch_to_multiple": "Alterar enquete para permitir múltiplas escolhas",
-  "compose_form.poll.switch_to_single": "Alterar enquete para permitir uma única escolha",
+  "compose_form.poll.option_placeholder": "Opção {number}",
+  "compose_form.poll.remove_option": "Remover opção",
+  "compose_form.poll.switch_to_multiple": "Permitir múltiplas escolhas",
+  "compose_form.poll.switch_to_single": "Opção única",
   "compose_form.publish": "TOOT",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.hide": "Marcar mídia como sensível",
-  "compose_form.sensitive.marked": "Mídia está marcada como sensível",
-  "compose_form.sensitive.unmarked": "Mídia não está marcada como sensível",
+  "compose_form.sensitive.hide": "{count, plural, one {Marcar mídia como sensível} other {Marcar mídias como sensível}}",
+  "compose_form.sensitive.marked": "{count, plural, one {Mídia marcada como sensível} other {Mídias marcadas como sensível}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {Mídia não está marcada como sensível} other {Mídias não estão marcadas como sensível}}",
   "compose_form.spoiler.marked": "Com Aviso de Conteúdo",
   "compose_form.spoiler.unmarked": "Sem Aviso de Conteúdo",
   "compose_form.spoiler_placeholder": "Aviso de Conteúdo aqui",
@@ -110,15 +115,17 @@
   "confirmations.block.confirm": "Bloquear",
   "confirmations.block.message": "Você tem certeza de que deseja bloquear {name}?",
   "confirmations.delete.confirm": "Excluir",
-  "confirmations.delete.message": "Tem certeza que quer excluir este status?",
+  "confirmations.delete.message": "Você tem certeza de que deseja excluir este toot?",
   "confirmations.delete_list.confirm": "Excluir",
   "confirmations.delete_list.message": "Você tem certeza de que deseja excluir esta lista?",
-  "confirmations.domain_block.confirm": "Bloquear domínio inteiro",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
+  "confirmations.domain_block.confirm": "Bloquear instância",
   "confirmations.domain_block.message": "Você tem certeza de que deseja bloquear tudo de {domain}? Você não verá mais o conteúdo desta instância em nenhuma linha do tempo pública ou nas suas notificações. Seus seguidores desta instância serão removidos.",
   "confirmations.logout.confirm": "Sair",
   "confirmations.logout.message": "Você tem certeza de que deseja sair?",
   "confirmations.mute.confirm": "Silenciar",
-  "confirmations.mute.explanation": "Isso ocultará toots deles e toots mencionando-os, mas ainda permitirá que eles vejam seus toots e te sigam.",
+  "confirmations.mute.explanation": "Isso ocultará toots do usuário e toots que o mencionam, mas ainda permitirá que ele veja teus toots e te siga.",
   "confirmations.mute.message": "Você tem certeza de que deseja silenciar {name}?",
   "confirmations.redraft.confirm": "Excluir e rascunhar",
   "confirmations.redraft.message": "Você tem certeza de que deseja apagar o toot e usá-lo como rascunho? Boosts e favoritos serão perdidos e as respostas ao toot original ficarão desconectadas.",
@@ -131,60 +138,60 @@
   "conversation.open": "Ver conversa",
   "conversation.with": "Com {names}",
   "directory.federated": "Do fediverso conhecido",
-  "directory.local": "Apenas do {domain}",
+  "directory.local": "Somente de {domain}",
   "directory.new_arrivals": "Acabaram de chegar",
   "directory.recently_active": "Ativos recentemente",
-  "embed.instructions": "Incorpore este status em seu website ao copiar o código abaixo.",
+  "embed.instructions": "Incorpore este toot no seu site ao copiar o código abaixo.",
   "embed.preview": "Aqui está como vai ficar:",
   "emoji_button.activity": "Atividade",
   "emoji_button.custom": "Personalizados",
   "emoji_button.flags": "Bandeiras",
-  "emoji_button.food": "Comida & Bebida",
-  "emoji_button.label": "Inserir emoji",
+  "emoji_button.food": "Comida e Bebida",
+  "emoji_button.label": "Adicionar emoji",
   "emoji_button.nature": "Natureza",
   "emoji_button.not_found": "Sem emojis! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objetos",
   "emoji_button.people": "Pessoas",
-  "emoji_button.recent": "Usados frequentemente",
+  "emoji_button.recent": "Mais usados",
   "emoji_button.search": "Pesquisar...",
-  "emoji_button.search_results": "Resultados da pesquisa",
+  "emoji_button.search_results": "Resultado da pesquisa",
   "emoji_button.symbols": "Símbolos",
-  "emoji_button.travel": "Viagem & Lugares",
+  "emoji_button.travel": "Viagem e Lugares",
   "empty_column.account_suspended": "Conta suspensa",
-  "empty_column.account_timeline": "Nada aqui!",
+  "empty_column.account_timeline": "Nada aqui.",
   "empty_column.account_unavailable": "Perfil indisponível",
   "empty_column.blocks": "Nada aqui.",
   "empty_column.bookmarked_statuses": "Nada aqui. Quando você salvar um toot, ele aparecerá aqui.",
-  "empty_column.community": "A linha do tempo local está vazia. Escreva algo publicamente para fazer a bola rolar!",
+  "empty_column.community": "A linha local está vazia. Publique algo para começar!",
   "empty_column.direct": "Nada aqui. Quando você enviar ou receber toots diretos, eles aparecerão aqui.",
-  "empty_column.domain_blocks": "Não há domínios bloqueados ainda.",
+  "empty_column.domain_blocks": "Nada aqui.",
   "empty_column.favourited_statuses": "Nada aqui. Quando você favoritar um toot, ele aparecerá aqui.",
   "empty_column.favourites": "Nada aqui. Quando alguém favoritar, o autor aparecerá aqui.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.follow_recommendations": "Parece que não há sugestões para você. Tente usar a pesquisa para encontrar pessoas que você possa conhecer ou explorar hashtags.",
   "empty_column.follow_requests": "Nada aqui. Quando você tiver seguidores pendentes, eles aparecerão aqui.",
   "empty_column.hashtag": "Nada aqui.",
-  "empty_column.home": "Sua linha do tempo está vazia! Visite {public} ou use a pesquisa para começar e conhecer outros usuários.",
-  "empty_column.home.suggestions": "See some suggestions",
-  "empty_column.list": "Não há nada nesta lista ainda. Quando membros desta lista postarem novos statuses, eles vão aparecer aqui.",
+  "empty_column.home": "Sua página inicial está vazia! Siga mais pessoas para começar: {suggestions}",
+  "empty_column.home.suggestions": "Veja algumas sugestões",
+  "empty_column.list": "Nada aqui. Quando membros da lista tootarem, eles aparecerão aqui.",
   "empty_column.lists": "Nada aqui. Quando você criar listas, elas aparecerão aqui.",
   "empty_column.mutes": "Nada aqui.",
-  "empty_column.notifications": "Nada aqui. Interaja com outros usuários para começar a conversar.",
-  "empty_column.public": "Não há nada aqui! Escreva algo publicamente, ou siga manualmente usuários de outros servidores para enchê-la",
-  "error.unexpected_crash.explanation": "Devido a um bug em nosso código ou um problema de compatibilidade de navegador, esta página não pôde ser exibida corretamente.",
-  "error.unexpected_crash.explanation_addons": "Esta página não pôde ser exibida corretamente. Este erro provavelmente é causado por um complemento do navegador ou ferramentas de tradução automática.",
-  "error.unexpected_crash.next_steps": "Tente atualizar a página. Se não resolver, você ainda pode conseguir usar o Mastodon por meio de um navegador ou app nativo diferente.",
-  "error.unexpected_crash.next_steps_addons": "Tente desabilitá-los e atualizar a página. Se isso não ajudar, você ainda poderá usar o Mastodon por meio de um navegador diferente ou de um aplicativo nativo.",
-  "errors.unexpected_crash.copy_stacktrace": "Copiar stacktrace para área de transferência",
-  "errors.unexpected_crash.report_issue": "Denunciar problema",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "empty_column.notifications": "Interaja com outros usuários para começar a conversar.",
+  "empty_column.public": "Publique algo ou siga manualmente usuários de outros servidores",
+  "error.unexpected_crash.explanation": "Esta página não pôde ser mostrada corretamente. Este erro provavelmente é devido a um bug em nosso código ou um problema de compatibilidade de navegador.",
+  "error.unexpected_crash.explanation_addons": "Esta página não pôde ser mostrada corretamente. Este erro provavelmente é causado por um complemento do navegador ou ferramentas de tradução automática.",
+  "error.unexpected_crash.next_steps": "Tente atualizar a página. Se isso não ajudar, você ainda poderá usar o Mastodon por meio de um navegador diferente ou de um aplicativo nativo.",
+  "error.unexpected_crash.next_steps_addons": "Tente desativá-los e atualizar a página. Se isso não ajudar, você ainda poderá usar o Mastodon por meio de um navegador diferente ou de um aplicativo nativo.",
+  "errors.unexpected_crash.copy_stacktrace": "Copiar dados do erro para área de transferência",
+  "errors.unexpected_crash.report_issue": "Reportar problema",
+  "follow_recommendations.done": "Salvar",
+  "follow_recommendations.heading": "Siga pessoas que você gostaria de acompanhar! Aqui estão algumas sugestões.",
+  "follow_recommendations.lead": "Toots de pessoas que você segue aparecerão em ordem cronológica na página inicial. Não tenha medo de cometer erros, você pode facilmente deixar de seguir a qualquer momento!",
   "follow_request.authorize": "Aprovar",
-  "follow_request.reject": "Vetar",
-  "follow_requests.unlocked_explanation": "Embora sua conta não esteja trancada, o staff de {domain} achou que você podia querer revisar pedidos para te seguir destas contas manualmente.",
+  "follow_request.reject": "Recusar",
+  "follow_requests.unlocked_explanation": "Apesar de seu perfil não ser trancado, {domain} exige que você revise a solicitação para te seguir destes perfis manualmente.",
   "generic.saved": "Salvo",
   "getting_started.developers": "Desenvolvedores",
-  "getting_started.directory": "Diretório de perfis",
+  "getting_started.directory": "Centro de usuários",
   "getting_started.documentation": "Documentação",
   "getting_started.heading": "Primeiros passos",
   "getting_started.invite": "Convidar pessoas",
@@ -194,93 +201,93 @@
   "hashtag.column_header.tag_mode.all": "e {additional}",
   "hashtag.column_header.tag_mode.any": "ou {additional}",
   "hashtag.column_header.tag_mode.none": "sem {additional}",
-  "hashtag.column_settings.select.no_options_message": "Nenhuma sugestão encontrada",
+  "hashtag.column_settings.select.no_options_message": "Sem sugestões",
   "hashtag.column_settings.select.placeholder": "Insira hashtags…",
-  "hashtag.column_settings.tag_mode.all": "Todas estas",
-  "hashtag.column_settings.tag_mode.any": "Qualquer uma destas",
-  "hashtag.column_settings.tag_mode.none": "Nenhuma destas",
-  "hashtag.column_settings.tag_toggle": "Incluir tags adicionais para esta coluna",
+  "hashtag.column_settings.tag_mode.all": "Todas",
+  "hashtag.column_settings.tag_mode.any": "Qualquer uma",
+  "hashtag.column_settings.tag_mode.none": "Nenhuma",
+  "hashtag.column_settings.tag_toggle": "Adicionar mais hashtags aqui",
   "home.column_settings.basic": "Básico",
   "home.column_settings.show_reblogs": "Mostrar boosts",
   "home.column_settings.show_replies": "Mostrar respostas",
-  "home.hide_announcements": "Esconder anúncios",
-  "home.show_announcements": "Mostrar anúncios",
+  "home.hide_announcements": "Ocultar comunicados",
+  "home.show_announcements": "Mostrar comunicados",
   "intervals.full.days": "{number, plural, one {# dia} other {# dias}}",
   "intervals.full.hours": "{number, plural, one {# hora} other {# horas}}",
   "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}",
   "keyboard_shortcuts.back": "voltar",
-  "keyboard_shortcuts.blocked": "abrir lista de usuários bloqueados",
+  "keyboard_shortcuts.blocked": "abrir usuários bloqueados",
   "keyboard_shortcuts.boost": "dar boost",
-  "keyboard_shortcuts.column": "para focar um status de uma das colunas",
-  "keyboard_shortcuts.compose": "focar na composição",
+  "keyboard_shortcuts.column": "focar na coluna",
+  "keyboard_shortcuts.compose": "focar no compositor",
   "keyboard_shortcuts.description": "Descrição",
-  "keyboard_shortcuts.direct": "abrir Mensagens Diretas",
-  "keyboard_shortcuts.down": "para mover para baixo na lista",
-  "keyboard_shortcuts.enter": "para abrir status",
-  "keyboard_shortcuts.favourite": "favoritar",
-  "keyboard_shortcuts.favourites": "abrir os favoritos",
-  "keyboard_shortcuts.federated": "para abrir linha do tempo federada",
+  "keyboard_shortcuts.direct": "abrir toots diretos",
+  "keyboard_shortcuts.down": "mover para baixo",
+  "keyboard_shortcuts.enter": "abrir toot",
+  "keyboard_shortcuts.favourite": "favoritar toot",
+  "keyboard_shortcuts.favourites": "abrir favoritos",
+  "keyboard_shortcuts.federated": "abrir linha global",
   "keyboard_shortcuts.heading": "Atalhos de teclado",
-  "keyboard_shortcuts.home": "para abrir linha do tempo de início",
+  "keyboard_shortcuts.home": "abrir página inicial",
   "keyboard_shortcuts.hotkey": "Atalho",
   "keyboard_shortcuts.legend": "mostrar estes atalhos",
-  "keyboard_shortcuts.local": "para abrir linha do tempo local",
-  "keyboard_shortcuts.mention": "para mencionar autor",
-  "keyboard_shortcuts.muted": "abrir lista de usuários silenciados",
-  "keyboard_shortcuts.my_profile": "para abrir seu perfil",
-  "keyboard_shortcuts.notifications": "para abrir coluna de notificações",
-  "keyboard_shortcuts.open_media": "para abrir mídia",
+  "keyboard_shortcuts.local": "abrir linha local",
+  "keyboard_shortcuts.mention": "mencionar usuário",
+  "keyboard_shortcuts.muted": "abrir usuários silenciados",
+  "keyboard_shortcuts.my_profile": "abrir seu perfil",
+  "keyboard_shortcuts.notifications": "abrir notificações",
+  "keyboard_shortcuts.open_media": "abrir mídia",
   "keyboard_shortcuts.pinned": "abrir toots fixados",
-  "keyboard_shortcuts.profile": "para abrir perfil do autor",
-  "keyboard_shortcuts.reply": "para responder",
-  "keyboard_shortcuts.requests": "abrir lista de seguidores pendentes",
-  "keyboard_shortcuts.search": "para focar pesquisa",
-  "keyboard_shortcuts.spoilers": "para mostrar/ocultar o campo AC",
-  "keyboard_shortcuts.start": "para abrir coluna \"primeiros passos\"",
-  "keyboard_shortcuts.toggle_hidden": "mostrar/ocultar o toot com Aviso de Conteúdo",
+  "keyboard_shortcuts.profile": "abrir perfil do usuário",
+  "keyboard_shortcuts.reply": "responder toot",
+  "keyboard_shortcuts.requests": "abrir seguidores pendentes",
+  "keyboard_shortcuts.search": "focar na pesquisa",
+  "keyboard_shortcuts.spoilers": "ativar/desativar aviso de conteúdo",
+  "keyboard_shortcuts.start": "abrir primeiros passos",
+  "keyboard_shortcuts.toggle_hidden": "expandir/ocultar aviso de conteúdo",
   "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar mídia",
-  "keyboard_shortcuts.toot": "para começar um toot novo em folha",
-  "keyboard_shortcuts.unfocus": "para desfocar de área de texto de composição/pesquisa",
-  "keyboard_shortcuts.up": "para mover para cima na lista",
+  "keyboard_shortcuts.toot": "compor novo toot",
+  "keyboard_shortcuts.unfocus": "desfocar de tudo",
+  "keyboard_shortcuts.up": "mover para cima",
   "lightbox.close": "Fechar",
-  "lightbox.compress": "Compactar caixa de visualização de imagem",
-  "lightbox.expand": "Expandir caixa de visualização de imagem",
+  "lightbox.compress": "Fechar imagem",
+  "lightbox.expand": "Abrir imagem",
   "lightbox.next": "Próximo",
   "lightbox.previous": "Anterior",
   "lists.account.add": "Adicionar à lista",
   "lists.account.remove": "Remover da lista",
   "lists.delete": "Excluir lista",
   "lists.edit": "Editar lista",
-  "lists.edit.submit": "Renomear",
+  "lists.edit.submit": "Renomear lista",
   "lists.new.create": "Criar lista",
   "lists.new.title_placeholder": "Nome da lista",
   "lists.replies_policy.followed": "Qualquer usuário seguido",
   "lists.replies_policy.list": "Membros da lista",
   "lists.replies_policy.none": "Ninguém",
   "lists.replies_policy.title": "Mostrar respostas para:",
-  "lists.search": "Procurar entre as pessoas que você segue",
+  "lists.search": "Procurar entre as pessoas que segue",
   "lists.subheading": "Suas listas",
   "load_pending": "{count, plural, one {# novo item} other {# novos items}}",
   "loading_indicator.label": "Carregando...",
-  "media_gallery.toggle_visible": "Esconder mídia",
+  "media_gallery.toggle_visible": "{number, plural, one {Ocultar mídia} other {Ocultar mídias}}",
   "missing_indicator.label": "Não encontrado",
-  "missing_indicator.sublabel": "Esse recurso não pôde ser encontrado",
+  "missing_indicator.sublabel": "Recurso não encontrado",
   "mute_modal.duration": "Duração",
   "mute_modal.hide_notifications": "Ocultar notificações deste usuário?",
-  "mute_modal.indefinite": "Indefinida",
+  "mute_modal.indefinite": "Indefinido",
   "navigation_bar.apps": "Aplicativos",
   "navigation_bar.blocks": "Usuários bloqueados",
   "navigation_bar.bookmarks": "Salvos",
   "navigation_bar.community_timeline": "Linha do tempo local",
   "navigation_bar.compose": "Compor novo toot",
-  "navigation_bar.direct": "Mensagens diretas",
+  "navigation_bar.direct": "Toots diretos",
   "navigation_bar.discover": "Descobrir",
   "navigation_bar.domain_blocks": "Domínios bloqueados",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favoritos",
   "navigation_bar.filters": "Palavras filtradas",
   "navigation_bar.follow_requests": "Seguidores pendentes",
-  "navigation_bar.follows_and_followers": "Seguindo e seguidores",
+  "navigation_bar.follows_and_followers": "Segue e seguidores",
   "navigation_bar.info": "Sobre este servidor",
   "navigation_bar.keyboard_shortcuts": "Atalhos de teclado",
   "navigation_bar.lists": "Listas",
@@ -289,70 +296,71 @@
   "navigation_bar.personal": "Pessoal",
   "navigation_bar.pins": "Toots fixados",
   "navigation_bar.preferences": "Preferências",
-  "navigation_bar.public_timeline": "Linha do tempo federada",
+  "navigation_bar.public_timeline": "Linha global",
   "navigation_bar.security": "Segurança",
-  "notification.favourite": "{name} favoritou seu status",
+  "notification.favourite": "{name} favoritou teu toot",
   "notification.follow": "{name} te seguiu",
   "notification.follow_request": "{name} quer te seguir",
   "notification.mention": "{name} te mencionou",
   "notification.own_poll": "Sua enquete terminou",
   "notification.poll": "Uma enquete que você votou terminou",
-  "notification.reblog": "{name} boostou seu status",
-  "notification.status": "{name} acabou de postar",
+  "notification.reblog": "{name} deu boost no teu toot",
+  "notification.status": "{name} acabou de tootar",
   "notifications.clear": "Limpar notificações",
   "notifications.clear_confirmation": "Você tem certeza de que deseja limpar todas as suas notificações?",
   "notifications.column_settings.alert": "Notificações no computador",
   "notifications.column_settings.favourite": "Favoritos:",
   "notifications.column_settings.filter_bar.advanced": "Mostrar todas as categorias",
-  "notifications.column_settings.filter_bar.category": "Barra de filtro rápido",
+  "notifications.column_settings.filter_bar.category": "Barra de filtro rápido das notificações",
   "notifications.column_settings.filter_bar.show": "Mostrar",
   "notifications.column_settings.follow": "Seguidores:",
   "notifications.column_settings.follow_request": "Seguidores pendentes:",
   "notifications.column_settings.mention": "Menções:",
   "notifications.column_settings.poll": "Enquetes:",
-  "notifications.column_settings.push": "Enviar notificações",
-  "notifications.column_settings.reblog": "Melhoramentos:",
-  "notifications.column_settings.show": "Mostrar nas colunas",
+  "notifications.column_settings.push": "Notificações push",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Mostrar na coluna",
   "notifications.column_settings.sound": "Tocar som",
   "notifications.column_settings.status": "Novos toots:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.unread_markers.category": "Marcar como não lidas",
   "notifications.filter.all": "Tudo",
-  "notifications.filter.boosts": "Melhoramentos",
+  "notifications.filter.boosts": "Boosts",
   "notifications.filter.favourites": "Favoritos",
-  "notifications.filter.follows": "Seguindo",
+  "notifications.filter.follows": "Seguidores",
   "notifications.filter.mentions": "Menções",
-  "notifications.filter.polls": "Resultados de enquete",
-  "notifications.filter.statuses": "Atualizações de pessoas que você segue",
-  "notifications.grant_permission": "Conceder permissão.",
+  "notifications.filter.polls": "Enquetes",
+  "notifications.filter.statuses": "Novos toots",
+  "notifications.grant_permission": "Permita notificações.",
   "notifications.group": "{count} notificações",
-  "notifications.mark_as_read": "Marcar todas as notificações como lidas",
-  "notifications.permission_denied": "Não é possível habilitar as notificações da área de trabalho pois a permissão foi negada.",
-  "notifications.permission_denied_alert": "As notificações da área de trabalho não podem ser habilitdas pois a permissão do navegador foi negada antes",
-  "notifications.permission_required": "Notificações da área de trabalho não estão disponíveis porque a permissão necessária não foi concedida.",
-  "notifications_permission_banner.enable": "Habilitar notificações da área de trabalho",
-  "notifications_permission_banner.how_to_control": "Para receber notificações quando o Mastodon não estiver aberto, habilite as notificações da área de trabalho. Você pode controlar precisamente quais tipos de interações geram notificações da área de trabalho através do botão {icon} acima uma vez habilitadas.",
+  "notifications.mark_as_read": "Marcar como lidas",
+  "notifications.permission_denied": "Navegador não tem permissão para ativar notificações no computador.",
+  "notifications.permission_denied_alert": "Verifique a permissão do navegador para ativar notificações no computador.",
+  "notifications.permission_required": "Ativar notificações no computador exige permissão do navegador.",
+  "notifications_permission_banner.enable": "Ativar notificações no computador",
+  "notifications_permission_banner.how_to_control": "Para receber notificações quando o Mastodon não estiver aberto, ative as notificações no computador. Você pode controlar precisamente quais tipos de interações geram notificações no computador através do botão {icon}.",
   "notifications_permission_banner.title": "Nunca perca nada",
-  "picture_in_picture.restore": "Colocar de volta",
-  "poll.closed": "Fechou",
+  "picture_in_picture.restore": "Por de volta",
+  "poll.closed": "Terminou",
   "poll.refresh": "Atualizar",
   "poll.total_people": "{count, plural, one {# pessoa} other {# pessoas}}",
   "poll.total_votes": "{count, plural, one {# voto} other {# votos}}",
   "poll.vote": "Votar",
-  "poll.voted": "Você votou nesta resposta",
-  "poll_button.add_poll": "Adicionar uma enquete",
+  "poll.voted": "Você votou nesta opção",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
+  "poll_button.add_poll": "Adicionar enquete",
   "poll_button.remove_poll": "Remover enquete",
-  "privacy.change": "Ajustar privacidade de status",
-  "privacy.direct.long": "Visível somente para usuários mencionados",
-  "privacy.direct.short": "Direta",
-  "privacy.private.long": "Visível somente para seguidores",
-  "privacy.private.short": "Seguidores-somente",
-  "privacy.public.long": "Visível para todos, mostrado em linhas do tempo públicas",
-  "privacy.public.short": "Pública",
-  "privacy.unlisted.long": "Visível para todos, mas não em linhas do tempo públicas",
-  "privacy.unlisted.short": "Não-listada",
+  "privacy.change": "Alterar privacidade do toot",
+  "privacy.direct.long": "Postar só para usuários mencionados",
+  "privacy.direct.short": "Direto",
+  "privacy.private.long": "Postar só para seguidores",
+  "privacy.private.short": "Privado",
+  "privacy.public.long": "Postar em linhas públicas",
+  "privacy.public.short": "Público",
+  "privacy.unlisted.long": "Não postar em linhas públicas",
+  "privacy.unlisted.short": "Não-listado",
   "refresh": "Atualizar",
   "regeneration_indicator.label": "Carregando…",
-  "regeneration_indicator.sublabel": "Seu feed de início está sendo preparado!",
+  "regeneration_indicator.sublabel": "Sua página inicial está sendo preparada!",
   "relative_time.days": "{number}d",
   "relative_time.hours": "{number}h",
   "relative_time.just_now": "agora",
@@ -361,16 +369,16 @@
   "relative_time.today": "hoje",
   "reply_indicator.cancel": "Cancelar",
   "report.forward": "Encaminhar para {target}",
-  "report.forward_hint": "A conta é de outro servidor. Enviar uma cópia anonimizada da denúncia para lá também?",
-  "report.hint": "Sua denúncia vai ser enviada aos moderadores de seu servidor. Você pode prover uma explicação de por que está denunciando essa conta abaixo:",
-  "report.placeholder": "Comentários adicionais",
+  "report.forward_hint": "A conta está em outra instância. Enviar uma cópia anônima da denúncia para lá?",
+  "report.hint": "A denúncia será enviada aos moderadores da instância. Explique por que denunciou a conta:",
+  "report.placeholder": "Comentários adicionais aqui",
   "report.submit": "Enviar",
   "report.target": "Denunciando {target}",
   "search.placeholder": "Pesquisar",
   "search_popout.search_format": "Formato de pesquisa avançada",
-  "search_popout.tips.full_text": "Texto simples retorna statuses que você escreveu, favoritou, deu boost, ou em que foi mencionado, assim como nomes de usuário e de exibição, e hashtags correspondentes.",
+  "search_popout.tips.full_text": "Texto simples retorna toots que você escreveu, favoritou, deu boost, ou em que foi mencionado, assim como nomes de usuário e de exibição, e hashtags correspondentes.",
   "search_popout.tips.hashtag": "hashtag",
-  "search_popout.tips.status": "status",
+  "search_popout.tips.status": "toot",
   "search_popout.tips.text": "Texto simples retorna nomes de exibição e de usuário, e hashtags correspondentes",
   "search_popout.tips.user": "usuário",
   "search_results.accounts": "Pessoas",
@@ -379,97 +387,98 @@
   "search_results.statuses_fts_disabled": "Pesquisar toots por seu conteúdo não está ativado nesta instância Mastodon.",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "status.admin_account": "Abrir interface de moderação para @{name}",
-  "status.admin_status": "Abrir este status na interface de moderação",
+  "status.admin_status": "Abrir este toot na interface de moderação",
   "status.block": "Bloquear @{name}",
   "status.bookmark": "Salvar",
-  "status.cancel_reblog_private": "Desboostar",
-  "status.cannot_reblog": "Este post não pode ser dado boost",
-  "status.copy": "Copiar link para status",
+  "status.cancel_reblog_private": "Desfazer boost",
+  "status.cannot_reblog": "Este toot não pode receber boost",
+  "status.copy": "Copiar link",
   "status.delete": "Excluir",
   "status.detailed_status": "Visão detalhada da conversa",
-  "status.direct": "Enviar mensagem direta para @{name}",
+  "status.direct": "Enviar toot direto para @{name}",
   "status.embed": "Incorporar",
   "status.favourite": "Favoritar",
   "status.filtered": "Filtrado",
-  "status.load_more": "Carregar mais",
-  "status.media_hidden": "Mídia escondida",
+  "status.load_more": "Ver mais",
+  "status.media_hidden": "Mídia sensível",
   "status.mention": "Mencionar @{name}",
   "status.more": "Mais",
   "status.mute": "Silenciar @{name}",
   "status.mute_conversation": "Silenciar conversa",
-  "status.open": "Expandir este status",
-  "status.pin": "Fixar no perfil",
+  "status.open": "Abrir toot",
+  "status.pin": "Fixar",
   "status.pinned": "Toot fixado",
   "status.read_more": "Ler mais",
-  "status.reblog": "Boostar",
-  "status.reblog_private": "Boostar para audiência original",
-  "status.reblogged_by": "{name} boostou",
-  "status.reblogs.empty": "Nada aqui. Quando alguém der boost, o autor aparecerá aqui.",
+  "status.reblog": "Dar boost",
+  "status.reblog_private": "Dar boost para o mesmo público",
+  "status.reblogged_by": "{name} deu boost",
+  "status.reblogs.empty": "Nada aqui. Quando alguém der boost, o usuário aparecerá aqui.",
   "status.redraft": "Excluir e rascunhar",
-  "status.remove_bookmark": "Remover marcador",
+  "status.remove_bookmark": "Remover do Salvos",
   "status.reply": "Responder",
-  "status.replyAll": "Responder a thread",
+  "status.replyAll": "Responder a conversa",
   "status.report": "Denunciar @{name}",
-  "status.sensitive_warning": "Conteúdo sensível",
+  "status.sensitive_warning": "Mídia sensível",
   "status.share": "Compartilhar",
   "status.show_less": "Mostrar menos",
-  "status.show_less_all": "Mostrar menos para todos os toots",
+  "status.show_less_all": "Mostrar menos em tudo",
   "status.show_more": "Mostrar mais",
-  "status.show_more_all": "Mostrar mais para todos os toots",
+  "status.show_more_all": "Mostrar mais em tudo",
   "status.show_thread": "Mostrar conversa",
   "status.uncached_media_warning": "Não disponível",
-  "status.unmute_conversation": "Tirar conversa do mudo",
-  "status.unpin": "Desafixar do perfil",
+  "status.unmute_conversation": "Dessilenciar conversa",
+  "status.unpin": "Desafixar",
   "suggestions.dismiss": "Ignorar sugestão",
-  "suggestions.header": "Você pode estar interessado em…",
-  "tabs_bar.federated_timeline": "Federada",
-  "tabs_bar.home": "Início",
-  "tabs_bar.local_timeline": "Local",
+  "suggestions.header": "Talvez seja do teu interesse…",
+  "tabs_bar.federated_timeline": "Linha global",
+  "tabs_bar.home": "Página inicial",
+  "tabs_bar.local_timeline": "Linha local",
   "tabs_bar.notifications": "Notificações",
   "tabs_bar.search": "Pesquisar",
   "time_remaining.days": "{number, plural, one {# dia restante} other {# dias restantes}}",
   "time_remaining.hours": "{number, plural, one {# hora restante} other {# horas restantes}}",
   "time_remaining.minutes": "{number, plural, one {# minuto restante} other {# minutos restantes}}",
-  "time_remaining.moments": "Momentos faltantes",
+  "time_remaining.moments": "Momentos restantes",
   "time_remaining.seconds": "{number, plural, one {# segundo restante} other {# segundos restantes}}",
-  "timeline_hint.remote_resource_not_displayed": "{resource} de outros servidores não são exibidos.",
+  "timeline_hint.remote_resource_not_displayed": "{resource} de outros servidores não são mostrados.",
   "timeline_hint.resources.followers": "Seguidores",
-  "timeline_hint.resources.follows": "Seguindo",
-  "timeline_hint.resources.statuses": "Toots mais antigos",
+  "timeline_hint.resources.follows": "Segue",
+  "timeline_hint.resources.statuses": "Toots anteriores",
   "trends.counter_by_accounts": "{count, plural, one {{counter} pessoa} other {{counter} pessoas}} falando",
-  "trends.trending_now": "Em alta no momento",
-  "ui.beforeunload": "Seu rascunho será perdido se você sair do Mastodon.",
+  "trends.trending_now": "Em alta agora",
+  "ui.beforeunload": "Seu rascunho será perdido se sair do Mastodon.",
   "units.short.billion": "{count} bi",
   "units.short.million": "{count} mi",
   "units.short.thousand": "{count} mil",
-  "upload_area.title": "Arraste & solte para fazer upload",
+  "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar mídia",
-  "upload_error.limit": "Limite de upload de arquivos excedido.",
-  "upload_error.poll": "Não é possível fazer upload de arquivos com enquetes.",
+  "upload_error.limit": "Limite de anexação alcançado.",
+  "upload_error.poll": "Mídias não podem ser anexadas em toots com enquetes.",
   "upload_form.audio_description": "Descrever para pessoas com deficiência auditiva",
   "upload_form.description": "Descreva para deficientes visuais",
   "upload_form.edit": "Descreva",
   "upload_form.thumbnail": "Alterar miniatura",
   "upload_form.undo": "Excluir",
-  "upload_form.video_description": "Descreva para pessoas com deficiência auditiva ou visual",
+  "upload_form.video_description": "Descrever para deficientes auditivos ou visuais",
   "upload_modal.analyzing_picture": "Analisando imagem…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Escolher imagem",
   "upload_modal.description_placeholder": "Um pequeno jabuti xereta viu dez cegonhas felizes",
-  "upload_modal.detect_text": "Detectar texto da imagem",
+  "upload_modal.detect_text": "Transcrever imagem",
   "upload_modal.edit_media": "Editar mídia",
-  "upload_modal.hint": "Clique ou arraste o círculo na prévia para escolher o ponto focal que vai estar sempre visível em todas as thumbnails.",
+  "upload_modal.hint": "Clique ou arraste o círculo na prévia para escolher o foco que ficará visível na miniatura.",
   "upload_modal.preparing_ocr": "Preparando OCR…",
   "upload_modal.preview_label": "Prévia ({ratio})",
-  "upload_progress.label": "Fazendo upload...",
+  "upload_progress.label": "Enviando...",
   "video.close": "Fechar vídeo",
-  "video.download": "Fazer download de arquivo",
+  "video.download": "Baixar",
   "video.exit_fullscreen": "Sair da tela cheia",
-  "video.expand": "Expandir vídeo",
+  "video.expand": "Abrir vídeo",
   "video.fullscreen": "Tela cheia",
-  "video.hide": "Ocultar vídeo",
-  "video.mute": "Colocar no mudo",
+  "video.hide": "Ocultar mídia",
+  "video.mute": "Sem som",
   "video.pause": "Pausar",
-  "video.play": "Tocar",
-  "video.unmute": "Tirar do mudo"
+  "video.play": "Executar",
+  "video.unmute": "Com som"
 }
diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json
index 0d5da25f7..a5cb6a335 100644
--- a/app/javascript/mastodon/locales/pt-PT.json
+++ b/app/javascript/mastodon/locales/pt-PT.json
@@ -47,11 +47,16 @@
   "account.unmute": "Não silenciar @{name}",
   "account.unmute_notifications": "Deixar de silenciar @{name}",
   "account_note.placeholder": "Clique para adicionar nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Volte a tentar depois das {retry_time, time, medium}.",
   "alert.rate_limited.title": "Limite de tentativas",
   "alert.unexpected.message": "Ocorreu um erro inesperado.",
   "alert.unexpected.title": "Bolas!",
   "announcement.announcement": "Anúncio",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "De certeza que quer eliminar esta publicação?",
   "confirmations.delete_list.confirm": "Eliminar",
   "confirmations.delete_list.message": "Tens a certeza de que deseja eliminar permanentemente esta lista?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Esconder tudo deste domínio",
   "confirmations.domain_block.message": "De certeza que queres bloquear completamente o domínio {domain}? Na maioria dos casos, silenciar ou bloquear alguns utilizadores é suficiente e é o recomendado. Não irás ver conteúdo daquele domínio em cronologia alguma nem nas tuas notificações. Os teus seguidores daquele domínio serão removidos.",
   "confirmations.logout.confirm": "Terminar sessão",
@@ -134,7 +141,7 @@
   "directory.local": "Apenas de {domain}",
   "directory.new_arrivals": "Recém chegados",
   "directory.recently_active": "Com actividade recente",
-  "embed.instructions": "Incorpora esta publicação no teu site copiando o código abaixo.",
+  "embed.instructions": "Incorpore esta publicação no seu site copiando o código abaixo.",
   "embed.preview": "Podes ver aqui como irá ficar:",
   "emoji_button.activity": "Actividade",
   "emoji_button.custom": "Personalizar",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{contar, plural, um {# vote} outro {# votes}}",
   "poll.vote": "Votar",
   "poll.voted": "Votaste nesta resposta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Adicionar votação",
   "poll_button.remove_poll": "Remover votação",
   "privacy.change": "Ajustar a privacidade da publicação",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Descreva para pessoas com diminuição da acuidade auditiva ou visual",
   "upload_modal.analyzing_picture": "A analizar imagem…",
   "upload_modal.apply": "Aplicar",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Escolher imagem",
   "upload_modal.description_placeholder": "Grave e cabisbaixo, o filho justo zelava pela querida mãe doente",
   "upload_modal.detect_text": "Detectar texto na imagem",
diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json
index c1a6b9883..b6cb0a86c 100644
--- a/app/javascript/mastodon/locales/ro.json
+++ b/app/javascript/mastodon/locales/ro.json
@@ -1,37 +1,37 @@
 {
-  "account.account_note_header": "Note",
-  "account.add_or_remove_from_list": "Adaugă sau Elimină din liste",
+  "account.account_note_header": "Notă",
+  "account.add_or_remove_from_list": "Adaugă sau elimină din liste",
   "account.badges.bot": "Robot",
   "account.badges.group": "Grup",
-  "account.block": "Blocați @{name}",
-  "account.block_domain": "Blocați domeniul {domain}",
+  "account.block": "Blochează pe @{name}",
+  "account.block_domain": "Blochează domeniul {domain}",
   "account.blocked": "Blocat",
-  "account.browse_more_on_origin_server": "Caută mai multe în profilul original",
-  "account.cancel_follow_request": "Anulați cererea de urmărire",
-  "account.direct": "Mesaj direct @{name}",
-  "account.disable_notifications": "Stop notifying me when @{name} posts",
+  "account.browse_more_on_origin_server": "Vezi mai multe pe profilul original",
+  "account.cancel_follow_request": "Anulează cererea de abonare",
+  "account.direct": "Trimite-i un mesaj direct lui @{name}",
+  "account.disable_notifications": "Nu îmi mai trimite notificări când postează @{name}",
   "account.domain_blocked": "Domeniu blocat",
-  "account.edit_profile": "Editați profilul",
-  "account.enable_notifications": "Notify me when @{name} posts",
-  "account.endorse": "Promovați pe profil",
-  "account.follow": "Urmărește",
-  "account.followers": "Urmăritori",
-  "account.followers.empty": "Acest utilizator nu are încă urmăritori.",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
-  "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
-  "account.follows.empty": "Acest utilizator nu urmărește pe nimeni încă.",
-  "account.follows_you": "Te urmărește",
-  "account.hide_reblogs": "Ascunde impulsurile de la @{name}",
-  "account.joined": "Joined {date}",
+  "account.edit_profile": "Modifică profilul",
+  "account.enable_notifications": "Trimite-mi o notificare când postează @{name}",
+  "account.endorse": "Promovează pe profil",
+  "account.follow": "Abonează-te",
+  "account.followers": "Abonați",
+  "account.followers.empty": "Acest utilizator încă nu are abonați.",
+  "account.followers_counter": "{count, plural, one {{counter} Abonat} few {{counter} Abonați} other {{counter} Abonați}}",
+  "account.following_counter": "{count, plural, one {{counter} Abonament} few {{counter} Abonamente} other {{counter} Abonamente}}",
+  "account.follows.empty": "Momentan acest utilizator nu are niciun abonament.",
+  "account.follows_you": "Este abonat la tine",
+  "account.hide_reblogs": "Ascunde distribuirile de la @{name}",
+  "account.joined": "S-a înscris în {date}",
   "account.last_status": "Ultima activitate",
-  "account.link_verified_on": "Deținerea acestui link a fost verificată la {date}",
-  "account.locked_info": "Acest profil este privat. Această persoană gestionează manual cine o urmărește.",
+  "account.link_verified_on": "Proprietatea acestui link a fost verificată pe {date}",
+  "account.locked_info": "Acest profil este privat. Această persoană aprobă manual conturile care se abonează la ea.",
   "account.media": "Media",
   "account.mention": "Menționează pe @{name}",
   "account.moved_to": "{name} a fost mutat la:",
   "account.mute": "Ignoră pe @{name}",
   "account.mute_notifications": "Ignoră notificările de la @{name}",
-  "account.muted": "Oprit",
+  "account.muted": "Ignorat",
   "account.never_active": "Niciodată",
   "account.posts": "Postări",
   "account.posts_with_replies": "Postări și răspunsuri",
@@ -47,318 +47,326 @@
   "account.unmute": "Nu mai ignora pe @{name}",
   "account.unmute_notifications": "Activează notificările de la @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Vă rugăm să reîncercați după {retry_time, time, medium}.",
-  "alert.rate_limited.title": "Rată limitată",
+  "alert.rate_limited.title": "Debit limitat",
   "alert.unexpected.message": "A apărut o eroare neașteptată.",
-  "alert.unexpected.title": "Hopa!",
+  "alert.unexpected.title": "Ups!",
   "announcement.announcement": "Anunț",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} pe săptămână",
-  "boost_modal.combo": "Poți apăsa {combo} pentru a omite asta data viitoare",
-  "bundle_column_error.body": "Ceva nu a funcționat la încărcarea acestui component.",
+  "boost_modal.combo": "Poți apăsa {combo} pentru a sări peste asta data viitoare",
+  "bundle_column_error.body": "A apărut o eroare la încărcarea acestui element.",
   "bundle_column_error.retry": "Încearcă din nou",
   "bundle_column_error.title": "Eroare de rețea",
   "bundle_modal_error.close": "Închide",
-  "bundle_modal_error.message": "Ceva nu a funcționat în timpul încărcării acestei componente.",
+  "bundle_modal_error.message": "A apărut o eroare la încărcarea acestui element.",
   "bundle_modal_error.retry": "Încearcă din nou",
   "column.blocks": "Utilizatori blocați",
   "column.bookmarks": "Marcaje",
-  "column.community": "Fluxul local",
+  "column.community": "Cronologie locală",
   "column.direct": "Mesaje directe",
-  "column.directory": "Răsfoiți profiluri",
+  "column.directory": "Explorează profiluri",
   "column.domain_blocks": "Domenii blocate",
   "column.favourites": "Favorite",
-  "column.follow_requests": "Cereri de urmărire",
+  "column.follow_requests": "Cereri de abonare",
   "column.home": "Acasă",
   "column.lists": "Liste",
   "column.mutes": "Utilizatori ignorați",
   "column.notifications": "Notificări",
   "column.pins": "Postări fixate",
-  "column.public": "Flux global",
+  "column.public": "Cronologie globală",
   "column_back_button.label": "Înapoi",
   "column_header.hide_settings": "Ascunde setările",
   "column_header.moveLeft_settings": "Mută coloana la stânga",
   "column_header.moveRight_settings": "Mută coloana la dreapta",
   "column_header.pin": "Fixează",
-  "column_header.show_settings": "Arată setările",
-  "column_header.unpin": "Eliberează",
+  "column_header.show_settings": "Afișare setări",
+  "column_header.unpin": "Anulează fixarea",
   "column_subheading.settings": "Setări",
   "community.column_settings.local_only": "Doar local",
   "community.column_settings.media_only": "Doar media",
   "community.column_settings.remote_only": "Doar la distanţă",
   "compose_form.direct_message_warning": "Această postare va fi trimisă doar utilizatorilor menționați.",
   "compose_form.direct_message_warning_learn_more": "Află mai multe",
-  "compose_form.hashtag_warning": "Această postare nu va fi listată sub niciun hashtag pentru că este nelistată. Doar postările publice pot fi găsite după un hashtag.",
-  "compose_form.lock_disclaimer": "Contul tău nu este {locked}. Oricine te poate urmări fără aprobarea ta și vedea toate postările tale.",
+  "compose_form.hashtag_warning": "Această postare nu va fi listată sub niciun hashtag deoarece este nelistată. Doar postările publice pot fi căutate cu un hashtag.",
+  "compose_form.lock_disclaimer": "Contul tău nu este {locked}. Oricine se poate abona la tine pentru a îți vedea postările numai pentru abonați.",
   "compose_form.lock_disclaimer.lock": "privat",
   "compose_form.placeholder": "La ce te gândești?",
-  "compose_form.poll.add_option": "Adăugați o opțiune",
+  "compose_form.poll.add_option": "Adaugă o opțiune",
   "compose_form.poll.duration": "Durata sondajului",
   "compose_form.poll.option_placeholder": "Opțiunea {number}",
-  "compose_form.poll.remove_option": "Îndepărtați acestă opțiune",
-  "compose_form.poll.switch_to_multiple": "Modificați sondajul pentru a permite multiple opțiuni",
-  "compose_form.poll.switch_to_single": "Modificați sondajul pentru a permite o singură opțiune",
+  "compose_form.poll.remove_option": "Elimină acestă opțiune",
+  "compose_form.poll.switch_to_multiple": "Modifică sondajul pentru a permite mai multe opțiuni",
+  "compose_form.poll.switch_to_single": "Modifică sondajul pentru a permite o singură opțiune",
   "compose_form.publish": "Postează",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.hide": "Marcați conținutul media ca sensibil",
-  "compose_form.sensitive.marked": "Conținutul media este marcat ca sensibil",
-  "compose_form.sensitive.unmarked": "Conținutul media nu este marcat ca sensibil",
-  "compose_form.spoiler.marked": "Textul este ascuns sub o avertizare",
-  "compose_form.spoiler.unmarked": "Textul nu este ascuns",
-  "compose_form.spoiler_placeholder": "Scrie avertizarea aici",
+  "compose_form.sensitive.hide": "{count, plural, one {Marchează conținutul media ca fiind sensibil} few {Marchează conținuturile media ca fiind sensibile} other {Marchează conținuturile media ca fiind sensibile}}",
+  "compose_form.sensitive.marked": "{count, plural, one {Conținutul media este marcat ca fiind sensibil} other {Conținuturile media sunt marcate ca fiind sensibile}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {Conținutul media nu este marcat ca fiind sensibil} other {Conținuturile media nu sunt marcate ca fiind sensibile}}",
+  "compose_form.spoiler.marked": "Elimină avertismentul privind conținutul",
+  "compose_form.spoiler.unmarked": "Adaugă un avertisment privind conținutul",
+  "compose_form.spoiler_placeholder": "Scrie avertismentul aici",
   "confirmation_modal.cancel": "Anulează",
-  "confirmations.block.block_and_report": "Blocați și Raportați",
+  "confirmations.block.block_and_report": "Blochează și raportează",
   "confirmations.block.confirm": "Blochează",
   "confirmations.block.message": "Ești sigur că vrei să blochezi pe {name}?",
-  "confirmations.delete.confirm": "Șterge",
-  "confirmations.delete.message": "Ești sigur că vrei să ștergi asta?",
-  "confirmations.delete_list.confirm": "Șterge",
-  "confirmations.delete_list.message": "Ești sigur că vrei să ștergi permanent această listă?",
-  "confirmations.domain_block.confirm": "Ascunde tot domeniul",
-  "confirmations.domain_block.message": "Ești absolut sigur că vrei să blochezi complet domeniul {domain}? În cele mai multe cazuri raportarea sau ignorarea anumitor lucruri este suficientă și de preferat. Nu vei mai vedea niciun conținut de la acest domeniu în nici un flux public sau în notificările tale. Urmăritorii tăi de la acele domenii vor fi eliminați.",
+  "confirmations.delete.confirm": "Elimină",
+  "confirmations.delete.message": "Ești sigur că vrei să elimini această postare?",
+  "confirmations.delete_list.confirm": "Elimină",
+  "confirmations.delete_list.message": "Ești sigur că vrei să elimini definitiv această listă?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
+  "confirmations.domain_block.confirm": "Blochează întregul domeniu",
+  "confirmations.domain_block.message": "Ești absolut sigur că vrei să blochezi tot domeniul {domain}? În cele mai multe cazuri, raportarea sau blocarea anumitor lucruri este suficientă și de preferat. Nu vei mai vedea niciun conținut din acest domeniu în vreun flux public sau în vreo notificare. Abonații tăi din acest domeniu vor fi eliminați.",
   "confirmations.logout.confirm": "Deconectare",
   "confirmations.logout.message": "Ești sigur că vrei să te deconectezi?",
   "confirmations.mute.confirm": "Ignoră",
-  "confirmations.mute.explanation": "Acest lucru va ascunde postări față de ei și postări în care sunt menționați, dar le vor permite încă să vă vadă postările și să vă urmărească.",
+  "confirmations.mute.explanation": "Postările acestei persoane și postările în care este menționată vor fi ascunse, însă tot va putea să îți vadă postările și să se aboneze la tine.",
   "confirmations.mute.message": "Ești sigur că vrei să ignori pe {name}?",
-  "confirmations.redraft.confirm": "Șterge și salvează ca ciornă",
-  "confirmations.redraft.message": "Ești sigur că vrei să ștergi această stare și să o faci ciornă? Favoritele și impulsurile se vor pierde, iar răspunsurile către postarea originală vor rămâne orfane.",
+  "confirmations.redraft.confirm": "Șterge și scrie din nou",
+  "confirmations.redraft.message": "Ești sigur că vrei să ștergi această postare și să o rescrii? Favoritele și distribuirile se vor pierde, iar răspunsurile către postarea originală vor rămâne orfane.",
   "confirmations.reply.confirm": "Răspunde",
-  "confirmations.reply.message": "Răspunzând la asta acum, mesajul pe care îl compui în prezent se va șterge. Ești sigur că vrei să continui?",
-  "confirmations.unfollow.confirm": "Nu mai urmări",
-  "confirmations.unfollow.message": "Ești sigur că nu mai vrei să urmărești pe {name}?",
-  "conversation.delete": "Ștergeți conversația",
-  "conversation.mark_as_read": "Marcați ca citit",
-  "conversation.open": "Vizualizați conversația",
+  "confirmations.reply.message": "Dacă răspunzi acum, mesajul pe care îl scrii în acest moment va fi șters. Ești sigur că vrei să continui?",
+  "confirmations.unfollow.confirm": "Dezabonează-te",
+  "confirmations.unfollow.message": "Ești sigur că vrei să te dezabonezi de la {name}?",
+  "conversation.delete": "Șterge conversația",
+  "conversation.mark_as_read": "Marchează ca citit",
+  "conversation.open": "Vizualizează conversația",
   "conversation.with": "Cu {names}",
-  "directory.federated": "De la un cunoscut fedivers",
-  "directory.local": "Doar de la {domain}",
-  "directory.new_arrivals": "Noi sosiți",
-  "directory.recently_active": "Recent activi",
-  "embed.instructions": "Înglobează această postare pe site-ul tău adăugând codul de mai jos.",
-  "embed.preview": "Cam așa va arăta:",
-  "emoji_button.activity": "Activitate",
-  "emoji_button.custom": "Personalizat",
-  "emoji_button.flags": "Marcaje",
-  "emoji_button.food": "Mâncare și Băuturi",
-  "emoji_button.label": "Inserează un zâmbet",
+  "directory.federated": "Din fediversul cunoscut",
+  "directory.local": "Doar din {domain}",
+  "directory.new_arrivals": "Înscriși recent",
+  "directory.recently_active": "Activi recent",
+  "embed.instructions": "Integrează această postare în site-ul tău copiind codul de mai jos.",
+  "embed.preview": "Iată cum va arăta:",
+  "emoji_button.activity": "Activități",
+  "emoji_button.custom": "Personalizați",
+  "emoji_button.flags": "Steaguri",
+  "emoji_button.food": "Alimente și băuturi",
+  "emoji_button.label": "Inserează un emoji",
   "emoji_button.nature": "Natură",
-  "emoji_button.not_found": "Fără zâmbete (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Nu au fost găsite emoji-uri",
   "emoji_button.objects": "Obiecte",
   "emoji_button.people": "Persoane",
-  "emoji_button.recent": "Utilizate frecvent",
-  "emoji_button.search": "Caută...",
+  "emoji_button.recent": "Utilizați frecvent",
+  "emoji_button.search": "Căutare...",
   "emoji_button.search_results": "Rezultatele căutării",
   "emoji_button.symbols": "Simboluri",
-  "emoji_button.travel": "Călătorii și Locuri",
-  "empty_column.account_suspended": "Account suspended",
+  "emoji_button.travel": "Călătorii și locuri",
+  "empty_column.account_suspended": "Cont suspendat",
   "empty_column.account_timeline": "Nicio postare aici!",
   "empty_column.account_unavailable": "Profil indisponibil",
-  "empty_column.blocks": "Nu ai blocat nici un utilizator încă.",
-  "empty_column.bookmarked_statuses": "Nu aveți nici o postare marcată încă. Atunci când veți marca una, va fi afișată aici.",
-  "empty_column.community": "Fluxul local este gol. Scrie ceva public pentru a sparge gheața!",
-  "empty_column.direct": "Nu ai nici un mesaj direct încă. Când trimiți sau primești unul, va fi afișat aici.",
-  "empty_column.domain_blocks": "Nu sunt domenii blocate încă.",
-  "empty_column.favourited_statuses": "Nu ai nici o postare favorită încă. Când vei favoriza una, va fi afișată aici.",
-  "empty_column.favourites": "Nimeni nu are această postare adăugată la favorite. Când cineva o va face va fi afișat aici.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
-  "empty_column.follow_requests": "Nu ai încă nici o cerere de urmărire. Când vei primi una, va fi afișată aici.",
-  "empty_column.hashtag": "Acest hashtag nu a fost folosit încă.",
-  "empty_column.home": "Fluxul tău este gol. Vizitează {public} sau fă o căutare pentru a începe să cunoști oameni noi.",
-  "empty_column.home.suggestions": "See some suggestions",
-  "empty_column.list": "Nu este nimic încă în această listă. Când membrii acestei liste vor începe să posteze, va apărea aici.",
-  "empty_column.lists": "Nu ai încă nici o listă. Când vei crea una, va apărea aici.",
-  "empty_column.mutes": "Nu ai ignorat nici un utilizator încă.",
-  "empty_column.notifications": "Nu ai nici o notificare încă. Interacționează cu alții pentru a începe o conversație.",
-  "empty_column.public": "Nu este nimic aici! Scrie ceva public, sau urmărește alți utilizatori din alte instanțe pentru a porni fluxul",
+  "empty_column.blocks": "Momentan nu ai blocat niciun utilizator.",
+  "empty_column.bookmarked_statuses": "Momentan nu ai nicio postare marcată. Când vei marca una, va apărea aici.",
+  "empty_column.community": "Nu există nimic în cronologia locală. Postează ceva public pentru a sparge gheața!",
+  "empty_column.direct": "Momentan nu ai niciun mesaj direct. Când trimiți sau primești un mesaj, va apărea aici.",
+  "empty_column.domain_blocks": "Momentan nu există domenii blocate.",
+  "empty_column.favourited_statuses": "Momentan nu ai nicio postare favorită. Când vei adăuga una, va apărea aici.",
+  "empty_column.favourites": "Momentan nimeni nu a adăugat această postare la favorite. Când cineva o va face, va apărea aici.",
+  "empty_column.follow_recommendations": "Se pare că nu am putut genera nicio sugestie pentru tine. Poți încerca funcția de căutare pentru a căuta persoane pe care le cunoști, sau poți explora tendințele.",
+  "empty_column.follow_requests": "Momentan nu ai nicio cerere de abonare. Când vei primi una, va apărea aici.",
+  "empty_column.hashtag": "Acest hashtag încă nu a fost folosit.",
+  "empty_column.home": "Nu există nimic în cronologia ta! Abonează-te la mai multe persoane pentru a o umple. {suggestions}",
+  "empty_column.home.suggestions": "Vezi sugestiile",
+  "empty_column.list": "Momentan nu există nimic în această listă. Când membrii ei vor posta ceva nou, vor apărea aici.",
+  "empty_column.lists": "Momentan nu ai nicio listă. Când vei crea una, va apărea aici.",
+  "empty_column.mutes": "Momentan nu ai ignorat niciun utilizator.",
+  "empty_column.notifications": "Momentan nu ai nicio notificare. Când alte persoane vor interacționa cu tine, îl vei vedea aici.",
+  "empty_column.public": "Nu există nimic aici! Postează ceva public, sau abonează-te manual la utilizatori din alte servere pentru a umple cronologia",
   "error.unexpected_crash.explanation": "Din cauza unei erori în codul nostru sau a unei probleme de compatibilitate cu navigatorul, această pagină nu a putut fi afișată corect.",
-  "error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
-  "error.unexpected_crash.next_steps": "Încercați să reîmprospătați pagina. Dacă acest lucru nu ajută, este posibil să mai puteți folosi site-ul printr-un navigator diferit sau o aplicație nativă.",
-  "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
-  "errors.unexpected_crash.copy_stacktrace": "Copiați stiva în clipboard",
-  "errors.unexpected_crash.report_issue": "Raportați o problemă",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
-  "follow_request.authorize": "Autorizează",
+  "error.unexpected_crash.explanation_addons": "Pagina nu a putut fi afișată corect. Această eroare este cel mai probabil cauzată de o extensie a navigatorului sau de instrumente de traducere automată.",
+  "error.unexpected_crash.next_steps": "Încearcă să reîmprospătezi pagina. Dacă tot nu funcționează, poți accesa Mastodon dintr-un alt navigator sau dintr-o aplicație nativă.",
+  "error.unexpected_crash.next_steps_addons": "Încearcă să le dezactivezi și să reîmprospătezi pagina. Dacă tot nu funcționează, poți accesa Mastodon dintr-un alt navigator sau dintr-o aplicație nativă.",
+  "errors.unexpected_crash.copy_stacktrace": "Copiere stacktrace în clipboard",
+  "errors.unexpected_crash.report_issue": "Raportează o problemă",
+  "follow_recommendations.done": "Terminat",
+  "follow_recommendations.heading": "Urmărește persoanele ale căror postări te-ar interesa! Iată câteva sugestii.",
+  "follow_recommendations.lead": "Postările de la persoanele la care te-ai abonat vor apărea în ordine cronologică în cronologia principală. Nu-ți fie teamă să faci greșeli, poți să te dezabonezi oricând de la ei la fel de ușor!",
+  "follow_request.authorize": "Acceptă",
   "follow_request.reject": "Respinge",
-  "follow_requests.unlocked_explanation": "Chiar dacă contul dvs nu este blocat, personalul {domain} a crezut că ați putea dori să revizuiți cererile de la aceste conturi în mod manual.",
+  "follow_requests.unlocked_explanation": "Chiar dacă contul tău nu este blocat, personalul {domain} a considerat că ai putea prefera să consulți manual cererile de abonare de la aceste conturi.",
   "generic.saved": "Salvat",
   "getting_started.developers": "Dezvoltatori",
-  "getting_started.directory": "Explorează",
+  "getting_started.directory": "Catalog de profiluri",
   "getting_started.documentation": "Documentație",
-  "getting_started.heading": "Începe",
-  "getting_started.invite": "Invită prieteni",
-  "getting_started.open_source_notice": "Mastodon este o rețea de socializare de tip open source. Puteți contribuii la dezvoltarea ei sau să semnalați erorile pe GitHub la {github}.",
-  "getting_started.security": "Securitate",
-  "getting_started.terms": "Termeni de Utilizare",
+  "getting_started.heading": "Primii pași",
+  "getting_started.invite": "Invită persoane",
+  "getting_started.open_source_notice": "Mastodon este un software cu sursă deschisă (open source). Poți contribui la dezvoltarea lui sau raporta probleme pe GitHub la {github}.",
+  "getting_started.security": "Setări cont",
+  "getting_started.terms": "Termeni și condiții",
   "hashtag.column_header.tag_mode.all": "și {additional}",
   "hashtag.column_header.tag_mode.any": "sau {additional}",
   "hashtag.column_header.tag_mode.none": "fără {additional}",
   "hashtag.column_settings.select.no_options_message": "Nu s-au găsit sugestii",
-  "hashtag.column_settings.select.placeholder": "Itroduceți hashtag-uri…",
+  "hashtag.column_settings.select.placeholder": "Introdu hashtag-uri…",
   "hashtag.column_settings.tag_mode.all": "Toate acestea",
   "hashtag.column_settings.tag_mode.any": "Oricare din acestea",
-  "hashtag.column_settings.tag_mode.none": "Niciuna din acestea",
-  "hashtag.column_settings.tag_toggle": "Adaugă etichete adiționale pentru această coloană",
+  "hashtag.column_settings.tag_mode.none": "Niciuna dintre acestea",
+  "hashtag.column_settings.tag_toggle": "Adaugă etichete suplimentare pentru această coloană",
   "home.column_settings.basic": "De bază",
-  "home.column_settings.show_reblogs": "Arată impulsurile",
-  "home.column_settings.show_replies": "Arată răspunsurile",
-  "home.hide_announcements": "Ascundeți anunțurile",
-  "home.show_announcements": "Afișați anunțurile",
+  "home.column_settings.show_reblogs": "Afișează distribuirile",
+  "home.column_settings.show_replies": "Afișează răspunsurile",
+  "home.hide_announcements": "Ascunde anunțurile",
+  "home.show_announcements": "Afișează anunțurile",
   "intervals.full.days": "{number, plural,one {# zi} other {# zile}}",
   "intervals.full.hours": "{number, plural, one {# oră} other {# ore}}",
   "intervals.full.minutes": "{number, plural, one {# minut} other {# minute}}",
-  "keyboard_shortcuts.back": "navighează înapoi",
-  "keyboard_shortcuts.blocked": "să deschidă lista utilizatorilor blocați",
-  "keyboard_shortcuts.boost": "să impulsioneze",
-  "keyboard_shortcuts.column": "să focalizeze o postare în una dintre coloane",
-  "keyboard_shortcuts.compose": "sa focalizeze zona de compunere",
+  "keyboard_shortcuts.back": "Navighează înapoi",
+  "keyboard_shortcuts.blocked": "Deschide lista utilizatorilor blocați",
+  "keyboard_shortcuts.boost": "Distribuie postarea",
+  "keyboard_shortcuts.column": "Focalizează pe coloană",
+  "keyboard_shortcuts.compose": "Focalizează pe zona de text",
   "keyboard_shortcuts.description": "Descriere",
-  "keyboard_shortcuts.direct": "să deschidă coloana de mesaje directe",
-  "keyboard_shortcuts.down": "să fie mutată jos în lista",
-  "keyboard_shortcuts.enter": "să deschidă o stare",
-  "keyboard_shortcuts.favourite": "să favorizeze",
-  "keyboard_shortcuts.favourites": "să deschidă lista cu favorite",
-  "keyboard_shortcuts.federated": "să deschidă fluxul global",
-  "keyboard_shortcuts.heading": "Comenzi rapide",
-  "keyboard_shortcuts.home": "să deschidă fluxul Acasă",
-  "keyboard_shortcuts.hotkey": "Prescurtări",
-  "keyboard_shortcuts.legend": "să afișeze această legendă",
-  "keyboard_shortcuts.local": "să deschidă fluxul local",
-  "keyboard_shortcuts.mention": "să menționeze autorul",
-  "keyboard_shortcuts.muted": "să deschidă lista utilizatorilor ignorați",
-  "keyboard_shortcuts.my_profile": "să deschidă profilul tău",
-  "keyboard_shortcuts.notifications": "să deschidă coloana cu notificări",
-  "keyboard_shortcuts.open_media": "pentru a deschide media",
-  "keyboard_shortcuts.pinned": "să deschidă lista postărilor fixate",
-  "keyboard_shortcuts.profile": "să deschidă profilul autorului",
-  "keyboard_shortcuts.reply": "să răspundă",
-  "keyboard_shortcuts.requests": "să deschidă lista cu cereri de urmărire",
-  "keyboard_shortcuts.search": "să focalizeze căutarea",
-  "keyboard_shortcuts.spoilers": "pentru a afişa/ascunde câmpul CW",
-  "keyboard_shortcuts.start": "să deschidă coloana \"Începere\"",
-  "keyboard_shortcuts.toggle_hidden": "să arate/ascundă textul în spatele CW",
-  "keyboard_shortcuts.toggle_sensitivity": "pentru a afișa/ascunde media",
-  "keyboard_shortcuts.toot": "să înceapă o postare nouă",
-  "keyboard_shortcuts.unfocus": "să dezactiveze zona de compunere/căutare",
-  "keyboard_shortcuts.up": "să mute mai sus în listă",
+  "keyboard_shortcuts.direct": "Deschide coloana de mesaje directe",
+  "keyboard_shortcuts.down": "Coboară în listă",
+  "keyboard_shortcuts.enter": "Deschide postarea",
+  "keyboard_shortcuts.favourite": "Adaugă postarea la favorite",
+  "keyboard_shortcuts.favourites": "Deschide lista de favorite",
+  "keyboard_shortcuts.federated": "Afișează cronologia globală",
+  "keyboard_shortcuts.heading": "Comenzi rapide ale tastaturii",
+  "keyboard_shortcuts.home": "Afișează cronologia principală",
+  "keyboard_shortcuts.hotkey": "Tastă rapidă",
+  "keyboard_shortcuts.legend": "Afișează această legendă",
+  "keyboard_shortcuts.local": "Deschide cronologia locală",
+  "keyboard_shortcuts.mention": "Menționează autorul",
+  "keyboard_shortcuts.muted": "Deschide lista utilizatorilor ignorați",
+  "keyboard_shortcuts.my_profile": "Afișează propriul profil",
+  "keyboard_shortcuts.notifications": "Deschide coloana cu notificări",
+  "keyboard_shortcuts.open_media": "Deschide media",
+  "keyboard_shortcuts.pinned": "Deschide lista postărilor fixate",
+  "keyboard_shortcuts.profile": "Afișează profilul autorului",
+  "keyboard_shortcuts.reply": "Răspunde la postare",
+  "keyboard_shortcuts.requests": "Deschide lista de cereri de abonare",
+  "keyboard_shortcuts.search": "Focalizează pe bara de căutare",
+  "keyboard_shortcuts.spoilers": "Afișează/ascunde câmpul CW",
+  "keyboard_shortcuts.start": "Deschide coloana \"Primii pași\"",
+  "keyboard_shortcuts.toggle_hidden": "Afișează/ascunde textul din spatele CW",
+  "keyboard_shortcuts.toggle_sensitivity": "Afișează/ascunde media",
+  "keyboard_shortcuts.toot": "Începe o postare nouă",
+  "keyboard_shortcuts.unfocus": "Părăsește zona de text/bara de căutare",
+  "keyboard_shortcuts.up": "Urcă în listă",
   "lightbox.close": "Închide",
-  "lightbox.compress": "Compress image view box",
-  "lightbox.expand": "Expand image view box",
-  "lightbox.next": "Următorul",
-  "lightbox.previous": "Precedentul",
+  "lightbox.compress": "Închide panoul de vizualizare a imaginilor",
+  "lightbox.expand": "Deschide panoul de vizualizare a imaginilor",
+  "lightbox.next": "Înainte",
+  "lightbox.previous": "Înapoi",
   "lists.account.add": "Adaugă în listă",
   "lists.account.remove": "Elimină din listă",
   "lists.delete": "Șterge lista",
-  "lists.edit": "Editează lista",
+  "lists.edit": "Modifică lista",
   "lists.edit.submit": "Schimbă titlul",
-  "lists.new.create": "Adaugă listă",
+  "lists.new.create": "Adaugă o listă",
   "lists.new.title_placeholder": "Titlu pentru noua listă",
-  "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "Nimeni",
-  "lists.replies_policy.title": "Show replies to:",
-  "lists.search": "Caută printre persoanele pe care le urmărești",
+  "lists.replies_policy.followed": "Tuturor persoanelor la care te-ai abonat",
+  "lists.replies_policy.list": "Membrilor din listă",
+  "lists.replies_policy.none": "Nu afișa nimănui",
+  "lists.replies_policy.title": "Afișează răspunsurile:",
+  "lists.search": "Caută printre persoanele la care ești abonat",
   "lists.subheading": "Listele tale",
   "load_pending": "{count, plural, one {# element nou} other {# elemente noi}}",
   "loading_indicator.label": "Se încarcă...",
-  "media_gallery.toggle_visible": "Ascunde media",
+  "media_gallery.toggle_visible": "{number, plural, one {Ascunde imaginea} other {Ascunde imaginile}}",
   "missing_indicator.label": "Nu a fost găsit",
   "missing_indicator.sublabel": "Această resursă nu a putut fi găsită",
   "mute_modal.duration": "Durata",
-  "mute_modal.hide_notifications": "Ascunzi notificările de la acest utilizator?",
-  "mute_modal.indefinite": "Indefinite",
+  "mute_modal.hide_notifications": "Ascunde notificările de la acest utilizator?",
+  "mute_modal.indefinite": "Nedeterminat",
   "navigation_bar.apps": "Aplicații mobile",
   "navigation_bar.blocks": "Utilizatori blocați",
   "navigation_bar.bookmarks": "Marcaje",
-  "navigation_bar.community_timeline": "Flux local",
+  "navigation_bar.community_timeline": "Cronologie locală",
   "navigation_bar.compose": "Compune o nouă postare",
   "navigation_bar.direct": "Mesaje directe",
   "navigation_bar.discover": "Descoperă",
   "navigation_bar.domain_blocks": "Domenii blocate",
-  "navigation_bar.edit_profile": "Editează profilul",
+  "navigation_bar.edit_profile": "Modifică profilul",
   "navigation_bar.favourites": "Favorite",
   "navigation_bar.filters": "Cuvinte ignorate",
-  "navigation_bar.follow_requests": "Cereri de urmărire",
-  "navigation_bar.follows_and_followers": "Urmăriri și urmăritori",
+  "navigation_bar.follow_requests": "Cereri de abonare",
+  "navigation_bar.follows_and_followers": "Abonamente și abonați",
   "navigation_bar.info": "Despre această instanță",
-  "navigation_bar.keyboard_shortcuts": "Prescurtări",
+  "navigation_bar.keyboard_shortcuts": "Taste rapide",
   "navigation_bar.lists": "Liste",
   "navigation_bar.logout": "Deconectare",
   "navigation_bar.mutes": "Utilizatori ignorați",
-  "navigation_bar.personal": "Personale",
+  "navigation_bar.personal": "Personal",
   "navigation_bar.pins": "Postări fixate",
   "navigation_bar.preferences": "Preferințe",
-  "navigation_bar.public_timeline": "Flux global",
+  "navigation_bar.public_timeline": "Cronologie globală",
   "navigation_bar.security": "Securitate",
   "notification.favourite": "{name} a adăugat postarea ta la favorite",
-  "notification.follow": "{name} te urmărește",
-  "notification.follow_request": "{name} a cerut să te urmărească",
+  "notification.follow": "{name} s-a abonat la tine",
+  "notification.follow_request": "{name} a trimis o cerere de abonare",
   "notification.mention": "{name} te-a menționat",
-  "notification.own_poll": "Sondajul tău s-a sfârșit",
-  "notification.poll": "Un sondaj la care ai votat s-a sfârșit",
-  "notification.reblog": "{name} a impulsionat postarea ta",
-  "notification.status": "{name} just posted",
+  "notification.own_poll": "Sondajul tău s-a încheiat",
+  "notification.poll": "Un sondaj pentru care ai votat s-a încheiat",
+  "notification.reblog": "{name} ți-a distribuit postarea",
+  "notification.status": "{name} tocmai a postat",
   "notifications.clear": "Șterge notificările",
   "notifications.clear_confirmation": "Ești sigur că vrei să ștergi permanent toate notificările?",
   "notifications.column_settings.alert": "Notificări pe desktop",
   "notifications.column_settings.favourite": "Favorite:",
   "notifications.column_settings.filter_bar.advanced": "Afișează toate categoriile",
   "notifications.column_settings.filter_bar.category": "Bară de filtrare rapidă",
-  "notifications.column_settings.filter_bar.show": "Arată",
-  "notifications.column_settings.follow": "Noi urmăritori:",
-  "notifications.column_settings.follow_request": "Noi cereri de urmărire:",
+  "notifications.column_settings.filter_bar.show": "Afișează",
+  "notifications.column_settings.follow": "Noi abonați:",
+  "notifications.column_settings.follow_request": "Noi cereri de abonare:",
   "notifications.column_settings.mention": "Mențiuni:",
   "notifications.column_settings.poll": "Rezultate sondaj:",
   "notifications.column_settings.push": "Notificări push",
-  "notifications.column_settings.reblog": "Impulsuri:",
-  "notifications.column_settings.show": "Arată în coloană",
-  "notifications.column_settings.sound": "Redă sunet",
-  "notifications.column_settings.status": "New toots:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.reblog": "Distribuiri:",
+  "notifications.column_settings.show": "Afișează în coloană",
+  "notifications.column_settings.sound": "Redare sunet",
+  "notifications.column_settings.status": "Postări noi:",
+  "notifications.column_settings.unread_markers.category": "Marcaje de notificări necitite",
   "notifications.filter.all": "Toate",
-  "notifications.filter.boosts": "Impulsuri",
+  "notifications.filter.boosts": "Distribuiri",
   "notifications.filter.favourites": "Favorite",
-  "notifications.filter.follows": "Urmărește",
-  "notifications.filter.mentions": "Menționări",
+  "notifications.filter.follows": "Abonați",
+  "notifications.filter.mentions": "Mențiuni",
   "notifications.filter.polls": "Rezultate sondaj",
-  "notifications.filter.statuses": "Updates from people you follow",
-  "notifications.grant_permission": "Grant permission.",
+  "notifications.filter.statuses": "Noutăți de la persoanele la care ești abonat",
+  "notifications.grant_permission": "Acordă permisiunea.",
   "notifications.group": "{count} notificări",
-  "notifications.mark_as_read": "Mark every notification as read",
-  "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
-  "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
-  "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
-  "notifications_permission_banner.enable": "Enable desktop notifications",
-  "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
-  "notifications_permission_banner.title": "Nu rata niciodată nimic",
+  "notifications.mark_as_read": "Marchează toate notificările ca citite",
+  "notifications.permission_denied": "Notificările pe desktop nu sunt disponibile deoarece cererea de permisiuni a navigatorului a fost respinsă",
+  "notifications.permission_denied_alert": "Notificările pe desktop nu pot fi activate, deoarece permisiunea navigatorului a fost refuzată înainte",
+  "notifications.permission_required": "Notificările pe desktop nu sunt disponibile deoarece permisiunea necesară nu a fost acordată.",
+  "notifications_permission_banner.enable": "Activează notificările pe desktop",
+  "notifications_permission_banner.how_to_control": "Pentru a primi notificări când Mastodon nu este deschis, activează notificările pe desktop. Poți controla exact ce tipuri de interacțiuni generează notificări pe desktop apăsând pe butonul {icon} de mai sus odată ce sunt activate.",
+  "notifications_permission_banner.title": "Rămâne la curent",
   "picture_in_picture.restore": "Pune-l înapoi",
   "poll.closed": "Închis",
-  "poll.refresh": "Reîmprospătează",
+  "poll.refresh": "Reîncarcă",
   "poll.total_people": "{count, plural, one {# persoană} other {# persoane}}",
   "poll.total_votes": "{count, plural, one {# vot} other {# voturi}}",
   "poll.vote": "Votează",
   "poll.voted": "Ai votat pentru acest răspuns",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Adaugă un sondaj",
-  "poll_button.remove_poll": "Îndepărtează sondajul",
-  "privacy.change": "Cine vede asta",
-  "privacy.direct.long": "Postează doar pentru utilizatorii menționați",
+  "poll_button.remove_poll": "Elimină sondajul",
+  "privacy.change": "Modifică confidențialitatea postării",
+  "privacy.direct.long": "Vizibil doar pentru utilizatorii menționați",
   "privacy.direct.short": "Direct",
-  "privacy.private.long": "Postează doar pentru urmăritori",
-  "privacy.private.short": "Doar urmăritorii",
-  "privacy.public.long": "Postează în fluxul public",
+  "privacy.private.long": "Vizibil doar pentru abonați",
+  "privacy.private.short": "Doar abonați",
+  "privacy.public.long": "Vizibil pentru toți, afișat în cronologiile publice",
   "privacy.public.short": "Public",
-  "privacy.unlisted.long": "Nu afișa în fluxul public",
+  "privacy.unlisted.long": "Vizibil pentru toți, dar nu și în cronologiile publice",
   "privacy.unlisted.short": "Nelistat",
-  "refresh": "Reîmprospătează",
+  "refresh": "Reîncarcă",
   "regeneration_indicator.label": "Se încarcă…",
-  "regeneration_indicator.sublabel": "Fluxul tău este în preparare!",
+  "regeneration_indicator.sublabel": "Cronologia ta principală este în curs de pregătire!",
   "relative_time.days": "{number}z",
   "relative_time.hours": "{number}o",
   "relative_time.just_now": "acum",
   "relative_time.minutes": "{number}m",
-  "relative_time.seconds": "{number}",
-  "relative_time.today": "azi",
+  "relative_time.seconds": "{number}s",
+  "relative_time.today": "astăzi",
   "reply_indicator.cancel": "Anulează",
   "report.forward": "Redirecționează către {target}",
   "report.forward_hint": "Acest cont este de pe un alt server. Trimitem o copie anonimă a raportului și acolo?",
@@ -436,7 +444,7 @@
   "timeline_hint.resources.followers": "Urmăritori",
   "timeline_hint.resources.follows": "Urmăriri",
   "timeline_hint.resources.statuses": "Postări mai vechi",
-  "trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
+  "trends.counter_by_accounts": "{count, plural, one {{counter} persoană postează} other {{counter} persoane postează}}",
   "trends.trending_now": "În tendință acum",
   "ui.beforeunload": "Postarea se va pierde dacă părăsești pagina.",
   "units.short.billion": "{count}Mld",
@@ -448,25 +456,26 @@
   "upload_error.poll": "Încărcarea fișierului nu este permisă cu sondaje.",
   "upload_form.audio_description": "Descrie pentru persoanele cu deficiență a auzului",
   "upload_form.description": "Adaugă o descriere pentru persoanele cu deficiențe de vedere",
-  "upload_form.edit": "Editează",
+  "upload_form.edit": "Modifică",
   "upload_form.thumbnail": "Schimbă miniatura",
   "upload_form.undo": "Șterge",
-  "upload_form.video_description": "Descrie pentru persoanele cu pierdere a auzului sau tulburări de vedere",
+  "upload_form.video_description": "Adaugă o descriere pentru persoanele cu deficiențe vizuale sau auditive",
   "upload_modal.analyzing_picture": "Se analizează imaginea…",
   "upload_modal.apply": "Aplică",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Alege imaginea",
-  "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
+  "upload_modal.description_placeholder": "Vând muzică de jazz și haine de bun-gust în New-York și Quebec la preț fix",
   "upload_modal.detect_text": "Detectare text din imagine",
-  "upload_modal.edit_media": "Editați media",
-  "upload_modal.hint": "Faceţi clic sau trageţi cercul pe previzualizare pentru a alege punctul focal care va fi întotdeauna vizualizat pe toate miniaturile.",
-  "upload_modal.preparing_ocr": "Preparing OCR…",
+  "upload_modal.edit_media": "Modifică media",
+  "upload_modal.hint": "Fă clic sau glisează cercul pe previzualizare pentru a alege punctul focal care va fi vizibil în toate miniaturile.",
+  "upload_modal.preparing_ocr": "Se pregătește OCR…",
   "upload_modal.preview_label": "Previzualizare ({ratio})",
-  "upload_progress.label": "Se Încarcă...",
+  "upload_progress.label": "Se încarcă...",
   "video.close": "Închide video",
-  "video.download": "Descărcați fișierul",
-  "video.exit_fullscreen": "Închide",
+  "video.download": "Descarcă fișierul",
+  "video.exit_fullscreen": "Ieși din modul ecran complet",
   "video.expand": "Extinde video",
-  "video.fullscreen": "Ecran întreg",
+  "video.fullscreen": "Ecran complet",
   "video.hide": "Ascunde video",
   "video.mute": "Oprește sonorul",
   "video.pause": "Pauză",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 2e04328cc..a37418101 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -6,23 +6,23 @@
   "account.block": "Заблокировать @{name}",
   "account.block_domain": "Заблокировать {domain}",
   "account.blocked": "Заблокирован(а)",
-  "account.browse_more_on_origin_server": "Посмотреть их можно в оригинальном профиле",
+  "account.browse_more_on_origin_server": "Посмотреть в оригинальном профиле",
   "account.cancel_follow_request": "Отменить запрос",
   "account.direct": "Написать @{name}",
   "account.disable_notifications": "Отключить уведомления от @{name}",
-  "account.domain_blocked": "Домен скрыт",
-  "account.edit_profile": "Изменить профиль",
+  "account.domain_blocked": "Домен заблокирован",
+  "account.edit_profile": "Редактировать профиль",
   "account.enable_notifications": "Включить уведомления для @{name}",
   "account.endorse": "Рекомендовать в профиле",
   "account.follow": "Подписаться",
-  "account.followers": "Подписаны",
+  "account.followers": "Подписчики",
   "account.followers.empty": "На этого пользователя пока никто не подписан.",
   "account.followers_counter": "{count, plural, one {{counter} подписчик} many {{counter} подписчиков} other {{counter} подписчика}}",
   "account.following_counter": "{count, plural, one {{counter} подписка} many {{counter} подписок} other {{counter} подписки}}",
   "account.follows.empty": "Этот пользователь пока ни на кого не подписался.",
   "account.follows_you": "Подписан(а) на вас",
   "account.hide_reblogs": "Скрыть продвижения от @{name}",
-  "account.joined": "Присоединился {date}",
+  "account.joined": "Зарегистрирован(а) с {date}",
   "account.last_status": "Последняя активность",
   "account.link_verified_on": "Владение этой ссылкой было проверено {date}",
   "account.locked_info": "Это закрытый аккаунт. Его владелец вручную одобряет подписчиков.",
@@ -35,8 +35,8 @@
   "account.never_active": "Никогда",
   "account.posts": "Посты",
   "account.posts_with_replies": "Посты и ответы",
-  "account.report": "Пожаловаться",
-  "account.requested": "Ожидает подтверждения. Нажмите для отмены",
+  "account.report": "Жалоба №{name}",
+  "account.requested": "Ожидает подтверждения. Нажмите для отмены запроса",
   "account.share": "Поделиться профилем @{name}",
   "account.show_reblogs": "Показывать продвижения от @{name}",
   "account.statuses_counter": "{count, plural, one {{counter} пост} many {{counter} постов} other {{counter} поста}}",
@@ -44,14 +44,19 @@
   "account.unblock_domain": "Разблокировать {domain}",
   "account.unendorse": "Не рекомендовать в профиле",
   "account.unfollow": "Отписаться",
-  "account.unmute": "Не игнорировать @{name}",
+  "account.unmute": "Убрать {name} из игнорируемых",
   "account.unmute_notifications": "Показывать уведомления от @{name}",
   "account_note.placeholder": "Текст заметки",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Пожалуйста, повторите после {retry_time, time, medium}.",
   "alert.rate_limited.title": "Вы выполняете действие слишком часто",
   "alert.unexpected.message": "Произошла непредвиденная ошибка.",
-  "alert.unexpected.title": "Ой!",
+  "alert.unexpected.title": "Упс!",
   "announcement.announcement": "Объявление",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} / неделю",
   "boost_modal.combo": "{combo}, чтобы пропустить это в следующий раз",
   "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
@@ -65,7 +70,7 @@
   "column.community": "Локальная лента",
   "column.direct": "Личные сообщения",
   "column.directory": "Просмотр профилей",
-  "column.domain_blocks": "Скрытые домены",
+  "column.domain_blocks": "Заблокированные домены",
   "column.favourites": "Избранное",
   "column.follow_requests": "Запросы на подписку",
   "column.home": "Главная",
@@ -86,7 +91,7 @@
   "community.column_settings.media_only": "Только с медиафайлами",
   "community.column_settings.remote_only": "Только удалённые",
   "compose_form.direct_message_warning": "Адресованные посты отправляются и видны только упомянутым в них пользователям.",
-  "compose_form.direct_message_warning_learn_more": "Узнать подробнее",
+  "compose_form.direct_message_warning_learn_more": "Подробнее",
   "compose_form.hashtag_warning": "Так как этот пост не публичный, он не отобразится в поиске по хэштегам.",
   "compose_form.lock_disclaimer": "Ваша учётная запись {locked}. Любой пользователь сможет подписаться на вас и просматривать посты для подписчиков.",
   "compose_form.lock_disclaimer.lock": "не закрыта",
@@ -95,13 +100,13 @@
   "compose_form.poll.duration": "Продолжительность опроса",
   "compose_form.poll.option_placeholder": "Вариант {number}",
   "compose_form.poll.remove_option": "Убрать этот вариант",
-  "compose_form.poll.switch_to_multiple": "Переключить в режим выбора нескольких ответов",
+  "compose_form.poll.switch_to_multiple": "Разрешить выбор нескольких вариантов",
   "compose_form.poll.switch_to_single": "Переключить в режим выбора одного ответа",
   "compose_form.publish": "Запостить",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.hide": "Отметить медиа{count, plural, one {файл} other {файлы}} как «деликатного характера»",
-  "compose_form.sensitive.marked": "Медиа{count, plural, one {файл отмечен} other {файлы отмечены}} как «деликатного характера»",
-  "compose_form.sensitive.unmarked": "Медиа{count, plural, one {файл} other {файлы}} не отмечены как «деликатного характера»",
+  "compose_form.sensitive.hide": "{count, plural, one {Отметить медифайл как деликатный} other {Отметить медифайлы как деликатные}}",
+  "compose_form.sensitive.marked": "Медиа{count, plural, =1 {файл отмечен} other {файлы отмечены}} как «деликатного характера»",
+  "compose_form.sensitive.unmarked": "Медиа{count, plural, =1 {файл не отмечен} other {файлы не отмечены}} как «деликатного характера»",
   "compose_form.spoiler.marked": "Текст скрыт за предупреждением",
   "compose_form.spoiler.unmarked": "Текст не скрыт",
   "compose_form.spoiler_placeholder": "Текст предупреждения",
@@ -113,21 +118,23 @@
   "confirmations.delete.message": "Вы уверены, что хотите удалить этот пост?",
   "confirmations.delete_list.confirm": "Удалить",
   "confirmations.delete_list.message": "Вы действительно хотите навсегда удалить этот список?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Да, заблокировать узел",
   "confirmations.domain_block.message": "Вы точно уверены, что хотите скрыть все посты с узла {domain}? В большинстве случаев пары блокировок и скрытий вполне достаточно.\n\nПри блокировке узла, вы перестанете получать уведомления оттуда, все посты будут скрыты из публичных лент, а подписчики убраны.",
   "confirmations.logout.confirm": "Выйти",
   "confirmations.logout.message": "Вы уверены, что хотите выйти?",
   "confirmations.mute.confirm": "Игнорировать",
-  "confirmations.mute.explanation": "Это скроет посты этого пользователя и те, в которых он упоминается, но при этом он по-прежнему сможет подписаться на вас и смотреть ваши посты.",
+  "confirmations.mute.explanation": "Это действие скроет посты данного пользователя и те, в которых он упоминается, но при этом он по-прежнему сможет подписаться и смотреть ваши посты.",
   "confirmations.mute.message": "Вы уверены, что хотите добавить {name} в список игнорируемых?",
   "confirmations.redraft.confirm": "Удалить и исправить",
-  "confirmations.redraft.message": "Вы уверены, что хотите переписать этот пост? Старый пост будет удалён, а вместе с ним пропадут отметки «избранного», продвижения и ответы.",
+  "confirmations.redraft.message": "Вы уверены, что хотите отредактировать этот пост? Старый пост будет удалён, а вместе с ним пропадут отметки «В избранное», продвижения и ответы.",
   "confirmations.reply.confirm": "Ответить",
   "confirmations.reply.message": "При ответе, текст набираемого поста будет очищен. Продолжить?",
   "confirmations.unfollow.confirm": "Отписаться",
   "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?",
   "conversation.delete": "Удалить беседу",
-  "conversation.mark_as_read": "Пометить прочитанным",
+  "conversation.mark_as_read": "Отметить как прочитанное",
   "conversation.open": "Просмотр беседы",
   "conversation.with": "С {names}",
   "directory.federated": "Со всей федерации",
@@ -163,13 +170,13 @@
   "empty_column.follow_recommendations": "Похоже, у нас нет предложений для вас. Вы можете попробовать поискать людей, которых уже знаете, или изучить актуальные хэштеги.",
   "empty_column.follow_requests": "Вам ещё не приходили запросы на подписку. Все новые запросы будут показаны здесь.",
   "empty_column.hashtag": "С этим хэштегом пока ещё ничего не постили.",
-  "empty_column.home": "Пока вы ни на кого не подписаны. Полистайте {public} или используйте поиск, чтобы освоиться и завести новые знакомства.",
+  "empty_column.home": "Ваша лента совсем пуста! Подпишитесь на других, чтобы заполнить её. {suggestions}",
   "empty_column.home.suggestions": "Посмотреть некоторые предложения",
   "empty_column.list": "В этом списке пока ничего нет.",
   "empty_column.lists": "У вас ещё нет списков. Созданные вами списки будут показаны здесь.",
   "empty_column.mutes": "Вы ещё никого не добавляли в список игнорируемых.",
   "empty_column.notifications": "У вас пока нет уведомлений. Взаимодействуйте с другими, чтобы завести разговор.",
-  "empty_column.public": "Здесь ничего нет! Опубликуйте что-нибудь или подпишитесь на пользователей с других узлов, чтобы заполнить ленту",
+  "empty_column.public": "Здесь совсем пусто. Опубликуйте что-нибудь или подпишитесь на пользователей с других сообществ, чтобы заполнить ленту",
   "error.unexpected_crash.explanation": "Из-за несовместимого браузера или ошибки в нашем коде, эта страница не может быть корректно отображена.",
   "error.unexpected_crash.explanation_addons": "Эта страница не может быть корректно отображена. Скорее всего, эта ошибка вызвана расширением браузера или инструментом автоматического перевода.",
   "error.unexpected_crash.next_steps": "Попробуйте обновить страницу. Если проблема не исчезает, используйте Mastodon из-под другого браузера или приложения.",
@@ -177,8 +184,8 @@
   "errors.unexpected_crash.copy_stacktrace": "Скопировать диагностическую информацию",
   "errors.unexpected_crash.report_issue": "Сообщить о проблеме",
   "follow_recommendations.done": "Готово",
-  "follow_recommendations.heading": "Подпишитесь на людей, от которые вы хотели бы видеть посты! Вот несколько предложений.",
-  "follow_recommendations.lead": "Посты от людей, на которых вы подписаны, будут отображаться в вашей домашней ленте в хронологическом порядке. Не бойтесь ошибаться, вы можете отписаться от людей так же легко в любое время!",
+  "follow_recommendations.heading": "Подпишитесь на людей, чьи посты вы бы хотели видеть. Вот несколько предложений.",
+  "follow_recommendations.lead": "Посты от людей, на которых вы подписаны, будут отображаться в вашей домашней ленте в хронологическом порядке. Не бойтесь ошибиться — вы так же легко сможете отписаться от них в любое время!",
   "follow_request.authorize": "Авторизовать",
   "follow_request.reject": "Отказать",
   "follow_requests.unlocked_explanation": "Этот запрос отправлен с учётной записи, для которой администрация {domain} включила ручную проверку подписок.",
@@ -262,7 +269,7 @@
   "lists.subheading": "Ваши списки",
   "load_pending": "{count, plural, one {# новый элемент} few {# новых элемента} other {# новых элементов}}",
   "loading_indicator.label": "Загрузка...",
-  "media_gallery.toggle_visible": "Показать/скрыть",
+  "media_gallery.toggle_visible": "Показать/скрыть {number, plural, =1 {изображение} other {изображения}}",
   "missing_indicator.label": "Не найдено",
   "missing_indicator.sublabel": "Запрашиваемый ресурс не найден",
   "mute_modal.duration": "Продолжительность",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# голос} few {# голоса} many {# голосов} other {# голосов}}",
   "poll.vote": "Голосовать",
   "poll.voted": "Вы проголосовали за этот вариант",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Добавить опрос",
   "poll_button.remove_poll": "Удалить опрос",
   "privacy.change": "Изменить видимость поста",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Опишите видео для людей с нарушением слуха или зрения",
   "upload_modal.analyzing_picture": "Обработка изображения…",
   "upload_modal.apply": "Применить",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Выбрать изображение",
   "upload_modal.description_placeholder": "На дворе трава, на траве дрова",
   "upload_modal.detect_text": "Найти текст на картинке",
diff --git a/app/javascript/mastodon/locales/sa.json b/app/javascript/mastodon/locales/sa.json
index be9f06d1d..1e496c3ec 100644
--- a/app/javascript/mastodon/locales/sa.json
+++ b/app/javascript/mastodon/locales/sa.json
@@ -47,11 +47,16 @@
   "account.unmute": "सशब्दम् @{name}",
   "account.unmute_notifications": "@{name} सूचनाः सक्रियन्ताम्",
   "account_note.placeholder": "टीकायोजनार्थं नुद्यताम्",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "{retry_time, time, medium}. समयात् पश्चात् प्रयतताम्",
   "alert.rate_limited.title": "सीमितगतिः",
   "alert.unexpected.message": "अनपेक्षितदोषो जातः ।",
   "alert.unexpected.title": "अरे !",
   "announcement.announcement": "उद्घोषणा",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} प्रतिसप्ताहे",
   "boost_modal.combo": "{combo} अत्र स्प्रष्टुं शक्यते, त्यक्तुमेतमन्यस्मिन् समये",
   "bundle_column_error.body": "विषयस्याऽऽरोपणे कश्चिद्दोषो जातः",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "निश्चयेन दौत्यमिदं नश्यताम्?",
   "confirmations.delete_list.confirm": "नश्यताम्",
   "confirmations.delete_list.message": "सूचिरियं निश्चयेन स्थायित्वेन च नश्यताम् वा?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "निषिद्धः प्रदेशः क्रियताम्",
   "confirmations.domain_block.message": "नूनं निश्चयेनैव विनष्टुमिच्छति पूर्णप्रदेशमेव {domain} ? अधिकांशसन्दर्भेऽस्थायित्वेन निषेधता निःशब्दत्वञ्च पर्याप्तं चयनीयञ्च । न तस्मात् प्रदेशात्सर्वे विषया द्रष्टुमशक्याः किस्यांश्चिदपि सर्वजनिकसमयतालिकायां वा स्वीयसूचनापटले । सर्वेऽनुसर्तारस्ते प्रदेशात् ये सन्ति ते नश्यन्ते ।",
   "confirmations.logout.confirm": "बहिर्गम्यताम्",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/sc.json b/app/javascript/mastodon/locales/sc.json
index 32eadde94..339bbac09 100644
--- a/app/javascript/mastodon/locales/sc.json
+++ b/app/javascript/mastodon/locales/sc.json
@@ -22,7 +22,7 @@
   "account.follows.empty": "Custa persone non sighit ancora a nemos.",
   "account.follows_you": "Ti sighit",
   "account.hide_reblogs": "Cua is cumpartziduras de @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "At aderidu su {date}",
   "account.last_status": "Ùrtima atividade",
   "account.link_verified_on": "Sa propiedade de custu ligòngiu est istada controllada su {date}",
   "account.locked_info": "S'istadu de riservadesa de custu contu est istadu cunfiguradu comente blocadu. Sa persone chi tenet sa propiedade revisionat a manu chie dda podet sighire.",
@@ -47,11 +47,16 @@
   "account.unmute": "Torra a ativare a @{name}",
   "account.unmute_notifications": "Ativa notìficas pro @{name}",
   "account_note.placeholder": "Incarca pro agiùnghere una nota",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Torra·bi a proare a pustis de {retry_time, time, medium}.",
   "alert.rate_limited.title": "Màssimu de rechestas barigadu",
   "alert.unexpected.message": "Ddoe est istada una faddina.",
   "alert.unexpected.title": "Oh!",
   "announcement.announcement": "Annùntziu",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} a sa chida",
   "boost_modal.combo": "Podes incarcare {combo} pro brincare custu sa borta chi benit",
   "bundle_column_error.body": "Faddina in su carrigamentu de custu cumponente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Seguru chi boles cantzellare custa publicatzione?",
   "confirmations.delete_list.confirm": "Cantzella",
   "confirmations.delete_list.message": "Seguru chi boles cantzellare custa lista in manera permanente?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Bloca totu su domìniu",
   "confirmations.domain_block.message": "Boles de seguru, ma a beru a beru, blocare {domain}? In sa parte manna de is casos, pagos blocos o silentziamentos de persones sunt sufitzientes e preferìbiles. No as a bìdere cuntenutos dae custu domìniu in peruna lìnia de tempus pùblica o in is notìficas tuas. Sa gente chi ti sighit dae cussu domìniu at a èssere bogada.",
   "confirmations.logout.confirm": "Essi·nche",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# votu} other {# votos}}",
   "poll.vote": "Vota",
   "poll.voted": "As votadu custa risposta",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Agiunghe unu sondàgiu",
   "poll_button.remove_poll": "Cantzella su sondàgiu",
   "privacy.change": "Modìfica s'istadu de riservadesa",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Descritzione pro persones cun pèrdida auditiva o problemas visuales",
   "upload_modal.analyzing_picture": "Analizende immàgine…",
   "upload_modal.apply": "Àplica",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Sèbera un'immàgine",
   "upload_modal.description_placeholder": "Su margiane castàngiu brincat lestru a subra de su cane mandrone",
   "upload_modal.detect_text": "Rileva testu de s'immàgine",
diff --git a/app/javascript/mastodon/locales/si.json b/app/javascript/mastodon/locales/si.json
index d36ae4b2d..0baa6c7ae 100644
--- a/app/javascript/mastodon/locales/si.json
+++ b/app/javascript/mastodon/locales/si.json
@@ -22,12 +22,12 @@
   "account.follows.empty": "This user doesn't follow anyone yet.",
   "account.follows_you": "Follows you",
   "account.hide_reblogs": "Hide boosts from @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "{date} එක් වී ඇත",
   "account.last_status": "අවසන් වරට සක්‍රීය",
   "account.link_verified_on": "මෙම සබැඳියේ හිමිකාරිත්වය {date} දින පරීක්ෂා කරන ලදි",
   "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
   "account.media": "මාධ්‍යය",
-  "account.mention": "Mention @{name}",
+  "account.mention": "@{name} සැඳහුම",
   "account.moved_to": "{name} has moved to:",
   "account.mute": "@{name} නිහඬ කරන්න",
   "account.mute_notifications": "Mute notifications from @{name}",
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "සටහන එකතු කිරීමට ක්ලික් කරන්න",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "කරුණාකර {retry_time, time, medium} ට පසු නැවත උත්සාහ කරන්න.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "අපොයි!",
   "announcement.announcement": "නිවේදනය",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -61,9 +66,9 @@
   "bundle_modal_error.message": "Something went wrong while loading this component.",
   "bundle_modal_error.retry": "නැවත උත්සාහ කරන්න",
   "column.blocks": "අවහිර කළ පරිශීලකයින්",
-  "column.bookmarks": "Bookmarks",
+  "column.bookmarks": "පොත් යොමු",
   "column.community": "Local timeline",
-  "column.direct": "Direct messages",
+  "column.direct": "සෘජු පණිවිඩ",
   "column.directory": "පැතිකඩයන් පිරික්සන්න",
   "column.domain_blocks": "අවහිර කළ වසම්",
   "column.favourites": "ප්‍රියතමයන්",
@@ -76,8 +81,8 @@
   "column.public": "Federated timeline",
   "column_back_button.label": "ආපසු",
   "column_header.hide_settings": "සැකසුම් සඟවන්න",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
+  "column_header.moveLeft_settings": "තීරුව වමට ගෙනයන්න",
+  "column_header.moveRight_settings": "තීරුව දකුණට ගෙනයන්න",
   "column_header.pin": "Pin",
   "column_header.show_settings": "සැකසුම් පෙන්වන්න",
   "column_header.unpin": "Unpin",
@@ -99,9 +104,9 @@
   "compose_form.poll.switch_to_single": "තනි තේරීමකට ඉඩ දීම සඳහා මත විමසුම වෙනස් කරන්න",
   "compose_form.publish": "පිඹින්න",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
-  "compose_form.sensitive.marked": "{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}",
-  "compose_form.sensitive.unmarked": "{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}",
+  "compose_form.sensitive.hide": "{count, plural, one {මාධ්‍ය සංවේදී ලෙස සලකුණු කරන්න} other {මාධ්‍ය සංවේදී ලෙස සලකුණු කරන්න}}",
+  "compose_form.sensitive.marked": "{count, plural, one {මාධ්‍ය සංවේදී ලෙස සලකුණු කර ඇත} other {මාධ්‍ය සංවේදී ලෙස සලකුණු කර ඇත}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {මාධ්‍ය සංවේදී ලෙස සලකුණු කර නැත} other {මාධ්‍ය සංවේදී ලෙස සලකුණු කර නැත}}",
   "compose_form.spoiler.marked": "Text is hidden behind warning",
   "compose_form.spoiler.unmarked": "පාඨය සඟවා නැත",
   "compose_form.spoiler_placeholder": "ඔබගේ අවවාදය මෙහි ලියන්න",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "සම්පූර්ණ වසම අවහිර කරන්න",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "නික්මෙන්න",
@@ -141,9 +148,9 @@
   "emoji_button.flags": "Flags",
   "emoji_button.food": "ආහාර සහ පාන",
   "emoji_button.label": "Insert emoji",
-  "emoji_button.nature": "Nature",
+  "emoji_button.nature": "සොබාදහම",
   "emoji_button.not_found": "No matching emojis found",
-  "emoji_button.objects": "Objects",
+  "emoji_button.objects": "වස්තූන්",
   "emoji_button.people": "මිනිසුන්",
   "emoji_button.recent": "නිතර භාවිතා වූ",
   "emoji_button.search": "සොයන්න...",
@@ -182,10 +189,10 @@
   "follow_request.authorize": "Authorize",
   "follow_request.reject": "ප්‍රතික්ෂේප",
   "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
-  "generic.saved": "Saved",
+  "generic.saved": "සුරැකිණි",
   "getting_started.developers": "සංවර්ධකයින්",
-  "getting_started.directory": "Profile directory",
-  "getting_started.documentation": "Documentation",
+  "getting_started.directory": "පැතිකඩ නාමාවලිය",
+  "getting_started.documentation": "ප්‍රලේඛනය",
   "getting_started.heading": "Getting started",
   "getting_started.invite": "මිනිසුන්ට ආරාධනා කරන්න",
   "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
@@ -213,7 +220,7 @@
   "keyboard_shortcuts.boost": "to boost",
   "keyboard_shortcuts.column": "to focus a status in one of the columns",
   "keyboard_shortcuts.compose": "to focus the compose textarea",
-  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.description": "සවිස්තරය",
   "keyboard_shortcuts.direct": "to open direct messages column",
   "keyboard_shortcuts.down": "to move down in the list",
   "keyboard_shortcuts.enter": "to open status",
@@ -222,7 +229,7 @@
   "keyboard_shortcuts.federated": "to open federated timeline",
   "keyboard_shortcuts.heading": "Keyboard Shortcuts",
   "keyboard_shortcuts.home": "to open home timeline",
-  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.hotkey": "උණුසුම් යතුර",
   "keyboard_shortcuts.legend": "to display this legend",
   "keyboard_shortcuts.local": "to open local timeline",
   "keyboard_shortcuts.mention": "to mention author",
@@ -256,7 +263,7 @@
   "lists.new.title_placeholder": "New list title",
   "lists.replies_policy.followed": "Any followed user",
   "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
+  "lists.replies_policy.none": "කිසිවෙක් නැත",
   "lists.replies_policy.title": "Show replies to:",
   "lists.search": "Search among people you follow",
   "lists.subheading": "Your lists",
@@ -268,21 +275,21 @@
   "mute_modal.duration": "Duration",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
   "mute_modal.indefinite": "Indefinite",
-  "navigation_bar.apps": "Mobile apps",
-  "navigation_bar.blocks": "Blocked users",
-  "navigation_bar.bookmarks": "Bookmarks",
+  "navigation_bar.apps": "ජංගම යෙදුම්",
+  "navigation_bar.blocks": "අවහිර කළ පරිශීලකයින්",
+  "navigation_bar.bookmarks": "පොත් යොමු",
   "navigation_bar.community_timeline": "Local timeline",
   "navigation_bar.compose": "Compose new toot",
-  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.direct": "සෘජු පණිවිඩ",
   "navigation_bar.discover": "Discover",
   "navigation_bar.domain_blocks": "Hidden domains",
-  "navigation_bar.edit_profile": "Edit profile",
-  "navigation_bar.favourites": "Favourites",
-  "navigation_bar.filters": "Muted words",
+  "navigation_bar.edit_profile": "පැතිකඩ සංස්කරණය",
+  "navigation_bar.favourites": "ප්‍රියතමයන්",
+  "navigation_bar.filters": "නිහඬ කළ වචන",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.follows_and_followers": "Follows and followers",
-  "navigation_bar.info": "About this server",
-  "navigation_bar.keyboard_shortcuts": "Hotkeys",
+  "navigation_bar.info": "මෙම සේවාදායකය පිළිබඳව",
+  "navigation_bar.keyboard_shortcuts": "උණුසුම් යතුරු",
   "navigation_bar.lists": "Lists",
   "navigation_bar.logout": "නික්මෙන්න",
   "navigation_bar.mutes": "Muted users",
@@ -299,33 +306,33 @@
   "notification.poll": "A poll you have voted in has ended",
   "notification.reblog": "{name} boosted your status",
   "notification.status": "{name} just posted",
-  "notifications.clear": "Clear notifications",
+  "notifications.clear": "දැනුම්දීම් හිස්කරන්න",
   "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
   "notifications.column_settings.alert": "Desktop notifications",
-  "notifications.column_settings.favourite": "Favourites:",
+  "notifications.column_settings.favourite": "ප්‍රියතමයන්:",
   "notifications.column_settings.filter_bar.advanced": "Display all categories",
   "notifications.column_settings.filter_bar.category": "Quick filter bar",
   "notifications.column_settings.filter_bar.show": "පෙන්වන්න",
   "notifications.column_settings.follow": "New followers:",
   "notifications.column_settings.follow_request": "New follow requests:",
-  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.mention": "සැඳහුම්:",
   "notifications.column_settings.poll": "Poll results:",
   "notifications.column_settings.push": "Push notifications",
   "notifications.column_settings.reblog": "Boosts:",
-  "notifications.column_settings.show": "Show in column",
-  "notifications.column_settings.sound": "Play sound",
+  "notifications.column_settings.show": "තීරුවෙහි පෙන්වන්න",
+  "notifications.column_settings.sound": "ශබ්දය ධාවනය",
   "notifications.column_settings.status": "New toots:",
   "notifications.column_settings.unread_markers.category": "Unread notification markers",
   "notifications.filter.all": "සියල්ල",
   "notifications.filter.boosts": "Boosts",
-  "notifications.filter.favourites": "Favourites",
+  "notifications.filter.favourites": "ප්‍රියතමයන්",
   "notifications.filter.follows": "Follows",
-  "notifications.filter.mentions": "Mentions",
+  "notifications.filter.mentions": "සැඳහුම්",
   "notifications.filter.polls": "Poll results",
   "notifications.filter.statuses": "Updates from people you follow",
   "notifications.grant_permission": "Grant permission.",
-  "notifications.group": "{count} notifications",
-  "notifications.mark_as_read": "Mark every notification as read",
+  "notifications.group": "දැනුම්දීම් {count}",
+  "notifications.mark_as_read": "සෑම දැනුම්දීමක්ම කියවූ ලෙස සලකුණු කරන්න",
   "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
   "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
   "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
@@ -333,21 +340,22 @@
   "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
   "notifications_permission_banner.title": "Never miss a thing",
   "picture_in_picture.restore": "Put it back",
-  "poll.closed": "Closed",
-  "poll.refresh": "Refresh",
+  "poll.closed": "වසා ඇත",
+  "poll.refresh": "නැවුම් කරන්න",
   "poll.total_people": "{count, plural, one {# person} other {# people}}",
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "මනාපය",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
   "privacy.direct.long": "Visible for mentioned users only",
-  "privacy.direct.short": "Direct",
+  "privacy.direct.short": "සෘජු",
   "privacy.private.long": "Visible for followers only",
   "privacy.private.short": "Followers-only",
   "privacy.public.long": "Visible for all, shown in public timelines",
-  "privacy.public.short": "Public",
+  "privacy.public.short": "ප්‍රසිද්ධ",
   "privacy.unlisted.long": "Visible for all, but not in public timelines",
   "privacy.unlisted.short": "Unlisted",
   "refresh": "නැවුම් කරන්න",
@@ -364,7 +372,7 @@
   "report.forward_hint": "The account 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 account below:",
   "report.placeholder": "අමතර අදහස්",
-  "report.submit": "Submit",
+  "report.submit": "යොමන්න",
   "report.target": "Report {target}",
   "search.placeholder": "සොයන්න",
   "search_popout.search_format": "Advanced search format",
@@ -380,33 +388,33 @@
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "status.admin_account": "Open moderation interface for @{name}",
   "status.admin_status": "Open this status in the moderation interface",
-  "status.block": "Block @{name}",
-  "status.bookmark": "Bookmark",
+  "status.block": "@{name} අවහිර කරන්න",
+  "status.bookmark": "පොත් යොමුව",
   "status.cancel_reblog_private": "Unboost",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.copy": "Copy link to status",
   "status.delete": "Delete",
   "status.detailed_status": "Detailed conversation view",
-  "status.direct": "Direct message @{name}",
-  "status.embed": "Embed",
-  "status.favourite": "Favourite",
+  "status.direct": "@{name} සෘජු පණිවිඩය",
+  "status.embed": "එබ්බවූ",
+  "status.favourite": "ප්‍රියතම",
   "status.filtered": "පෙරන ලද",
   "status.load_more": "තව පූරණය කරන්න",
   "status.media_hidden": "මාධ්‍ය සඟවා ඇත",
-  "status.mention": "Mention @{name}",
+  "status.mention": "@{name} සැඳහුම",
   "status.more": "තව",
   "status.mute": "@{name} නිහඬ කරන්න",
   "status.mute_conversation": "සංවාදය නිහඬ කරන්න",
   "status.open": "Expand this status",
   "status.pin": "Pin on profile",
   "status.pinned": "Pinned toot",
-  "status.read_more": "Read more",
+  "status.read_more": "තව කියවන්න",
   "status.reblog": "Boost",
   "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
-  "status.remove_bookmark": "Remove bookmark",
+  "status.remove_bookmark": "පොත්යොමුව ඉවත් කරන්න",
   "status.reply": "පිළිතුරු",
   "status.replyAll": "Reply to thread",
   "status.report": "@{name} වාර්තා කරන්න",
@@ -414,7 +422,7 @@
   "status.share": "බෙදාගන්න",
   "status.show_less": "අඩුවෙන් පෙන්වන්න",
   "status.show_less_all": "Show less for all",
-  "status.show_more": "Show more",
+  "status.show_more": "තව පෙන්වන්න",
   "status.show_more_all": "Show more for all",
   "status.show_thread": "Show thread",
   "status.uncached_media_warning": "Not available",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "පින්තූරය විශ්ලේෂණය කරමින්…",
   "upload_modal.apply": "යොදන්න",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "පින්තුරයක් තෝරන්න",
   "upload_modal.description_placeholder": "කඩිසර දුඹුරු හිවලෙක් කම්මැලි බල්ලා මතින් පනී",
   "upload_modal.detect_text": "පින්තූරයෙන් පාඨ හඳුනාගන්න",
@@ -462,12 +471,12 @@
   "upload_modal.preparing_ocr": "Preparing OCR…",
   "upload_modal.preview_label": "පෙරදසුන ({ratio})",
   "upload_progress.label": "උඩුගත වෙමින්...",
-  "video.close": "Close video",
+  "video.close": "දෘශ්‍යකය වසන්න",
   "video.download": "ගොනුව බාගන්න",
-  "video.exit_fullscreen": "Exit full screen",
+  "video.exit_fullscreen": "පූර්ණ තිරයෙන් පිටවන්න",
   "video.expand": "Expand video",
   "video.fullscreen": "පූර්ණ තිරය",
-  "video.hide": "Hide video",
+  "video.hide": "දෘශ්‍යකය සඟවන්න",
   "video.mute": "Mute sound",
   "video.pause": "විරාමය",
   "video.play": "ධාවනය",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index d10f29aaf..5911881bb 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -1,28 +1,28 @@
 {
-  "account.account_note_header": "Note",
+  "account.account_note_header": "Poznámka",
   "account.add_or_remove_from_list": "Pridaj do, alebo odober zo zoznamov",
   "account.badges.bot": "Bot",
   "account.badges.group": "Skupina",
   "account.block": "Blokuj @{name}",
   "account.block_domain": "Ukry všetko z {domain}",
   "account.blocked": "Blokovaný/á",
-  "account.browse_more_on_origin_server": "Browse more on the original profile",
+  "account.browse_more_on_origin_server": "Prehľadávaj viac na pôvodnom profile",
   "account.cancel_follow_request": "Zruš žiadosť o sledovanie",
   "account.direct": "Priama správa pre @{name}",
-  "account.disable_notifications": "Stop notifying me when @{name} posts",
+  "account.disable_notifications": "Prestaň oboznamovať keď má príspevky @{name}",
   "account.domain_blocked": "Doména ukrytá",
   "account.edit_profile": "Uprav profil",
-  "account.enable_notifications": "Notify me when @{name} posts",
+  "account.enable_notifications": "Oboznamuj ma, keď má @{name} príspevky",
   "account.endorse": "Zobrazuj na profile",
   "account.follow": "Nasleduj",
   "account.followers": "Sledujúci",
   "account.followers.empty": "Tohto používateľa ešte nikto nenásleduje.",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
+  "account.followers_counter": "{count, plural, one {{counter} Sledujúci} few {{counter} Sledujúci} many {{counter} Sledujúci} other {{counter} Sledujúci}}",
   "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
   "account.follows.empty": "Tento používateľ ešte nikoho nenasleduje.",
   "account.follows_you": "Nasleduje ťa",
   "account.hide_reblogs": "Skry vyzdvihnutia od @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Pridal/a sa v {date}",
   "account.last_status": "Naposledy aktívny",
   "account.link_verified_on": "Vlastníctvo tohto odkazu bolo skontrolované {date}",
   "account.locked_info": "Stav súkromia pre tento účet je nastavený na zamknutý. Jeho vlastník sám prehodnocuje, kto ho môže sledovať.",
@@ -47,11 +47,16 @@
   "account.unmute": "Prestaň ignorovať @{name}",
   "account.unmute_notifications": "Zruš stĺmenie oboznámení od @{name}",
   "account_note.placeholder": "Klikni pre vloženie poznámky",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Prosím, skús to znova za {retry_time, time, medium}.",
   "alert.rate_limited.title": "Tempo obmedzené",
   "alert.unexpected.message": "Vyskytla sa nečakaná chyba.",
   "alert.unexpected.title": "Ups!",
   "announcement.announcement": "Oboznámenie",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} týždenne",
   "boost_modal.combo": "Nabudúce môžeš kliknúť {combo} pre preskočenie",
   "bundle_column_error.body": "Pri načítaní tohto prvku nastala nejaká chyba.",
@@ -84,7 +89,7 @@
   "column_subheading.settings": "Nastavenia",
   "community.column_settings.local_only": "Iba miestna",
   "community.column_settings.media_only": "Iba médiá",
-  "community.column_settings.remote_only": "Remote only",
+  "community.column_settings.remote_only": "Iba odľahlé",
   "compose_form.direct_message_warning": "Tento príspevok bude boslaný iba spomenutým užívateľom.",
   "compose_form.direct_message_warning_learn_more": "Zisti viac",
   "compose_form.hashtag_warning": "Tento toot nebude zobrazený pod žiadným haštagom lebo nieje listovaný. Iba verejné tooty môžu byť nájdené podľa haštagu.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Si si istý/á, že chceš vymazať túto správu?",
   "confirmations.delete_list.confirm": "Vymaž",
   "confirmations.delete_list.message": "Si si istý/á, že chceš natrvalo vymazať tento zoznam?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Skry celú doménu",
   "confirmations.domain_block.message": "Si si naozaj istý/á, že chceš blokovať celú doménu {domain}? Vo väčšine prípadov stačí blokovať alebo ignorovať pár konkrétnych užívateľov, čo sa doporučuje. Neuvidíš obsah z tejto domény v žiadnej verejnej časovej osi, ani v oznámeniach. Tvoji následovníci pochádzajúci z tejto domény budú odstránení.",
   "confirmations.logout.confirm": "Odhlás sa",
@@ -150,7 +157,7 @@
   "emoji_button.search_results": "Nájdené",
   "emoji_button.symbols": "Symboly",
   "emoji_button.travel": "Cestovanie a miesta",
-  "empty_column.account_suspended": "Account suspended",
+  "empty_column.account_suspended": "Účet bol vylúčený",
   "empty_column.account_timeline": "Niesú tu žiadne príspevky!",
   "empty_column.account_unavailable": "Profil nedostupný",
   "empty_column.blocks": "Ešte si nikoho nezablokoval/a.",
@@ -160,28 +167,28 @@
   "empty_column.domain_blocks": "Žiadne domény ešte niesú skryté.",
   "empty_column.favourited_statuses": "Nemáš obľúbené ešte žiadne príspevky. Keď si nejaký obľúbiš, bude zobrazený práve tu.",
   "empty_column.favourites": "Tento toot si ešte nikto neobľúbil. Ten kto si ho obľúbi, bude zobrazený tu.",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.follow_recommendations": "Zdá sa že pre Vás nemohli byť vygenerované žiadne návrhy. Môžete skúsiť použiť vyhľadávanie aby ste našli ľudi ktorých poznáte, alebo preskúmať trendujúce heštegy.",
   "empty_column.follow_requests": "Ešte nemáš žiadne požiadavky o následovanie. Keď nejaké dostaneš, budú tu zobrazené.",
   "empty_column.hashtag": "Pod týmto hashtagom sa ešte nič nenachádza.",
   "empty_column.home": "Tvoja lokálna osa je zatiaľ prázdna! Pre začiatok navštív {public}, alebo použi vyhľadávanie a nájdi tak aj iných užívateľov.",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home.suggestions": "Prezri nejaké návrhy",
   "empty_column.list": "Tento zoznam je ešte prázdny. Keď ale členovia tohoto zoznamu napíšu nové správy, tak tie sa objavia priamo tu.",
   "empty_column.lists": "Nemáš ešte žiadne zoznamy. Keď nejaký vytvoríš, bude zobrazený práve tu.",
   "empty_column.mutes": "Ešte si nestĺmil žiadných užívateľov.",
   "empty_column.notifications": "Ešte nemáš žiadne oznámenia. Začni komunikovať s ostatnými, aby diskusia mohla začať.",
   "empty_column.public": "Ešte tu nič nie je. Napíš niečo verejne, alebo začni sledovať užívateľov z iných serverov, aby tu niečo pribudlo",
   "error.unexpected_crash.explanation": "Kvôli chybe v našom kóde, alebo problému s kompatibilitou prehliadača, túto stránku nebolo možné zobraziť správne.",
-  "error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
+  "error.unexpected_crash.explanation_addons": "Túto stránku sa nepodarilo zobraziť správne. Táto chyba je pravdepodobne spôsobená rozšírením v prehliadači, alebo nástrojmi automatického prekladu.",
   "error.unexpected_crash.next_steps": "Skús obnoviť stránku. Ak to nepomôže, pravdepodobne budeš stále môcť používať Mastodon cez iný prehliadač, alebo natívnu aplikáciu.",
-  "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
+  "error.unexpected_crash.next_steps_addons": "Skús ich vypnúť, a obnoviť túto stránku. Ak to nepomôže, pravdepodobne budeš stále môcť Mastodon používať cez iný prehliadač, alebo natívnu aplikáciu.",
   "errors.unexpected_crash.copy_stacktrace": "Skopíruj stacktrace do schránky",
   "errors.unexpected_crash.report_issue": "Nahlás problém",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "follow_recommendations.done": "Hotovo",
+  "follow_recommendations.heading": "Následuj ľudí od ktorých by si chcel/a vidieť príspevky! Tu sú nejaké návrhy.",
+  "follow_recommendations.lead": "Príspevky od ľudi ktorých sledujete sa zobrazia v chronologickom poradí na Vašej nástenke. Nebojte sa spraviť chyby, vždy môžete zrušiť sledovanie konkrétnych ľudí!",
   "follow_request.authorize": "Povoľ prístup",
   "follow_request.reject": "Odmietni",
-  "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
+  "follow_requests.unlocked_explanation": "Síce Váš učet nie je uzamknutý, ale {domain} tím si myslel že môžete chcieť skontrolovať žiadosti o sledovanie z týchto účtov manuálne.",
   "generic.saved": "Uložené",
   "getting_started.developers": "Vývojári",
   "getting_started.directory": "Zoznam profilov",
@@ -243,8 +250,8 @@
   "keyboard_shortcuts.unfocus": "nesústreď sa na písaciu plochu, alebo hľadanie",
   "keyboard_shortcuts.up": "posuň sa vyššie v zozname",
   "lightbox.close": "Zatvor",
-  "lightbox.compress": "Compress image view box",
-  "lightbox.expand": "Expand image view box",
+  "lightbox.compress": "Zmenšiť náhľad obrázku",
+  "lightbox.expand": "Rozšíriť náhľad obrázku",
   "lightbox.next": "Ďalšie",
   "lightbox.previous": "Predchádzajúci",
   "lists.account.add": "Pridaj do zoznamu",
@@ -254,10 +261,10 @@
   "lists.edit.submit": "Zmeň názov",
   "lists.new.create": "Pridaj zoznam",
   "lists.new.title_placeholder": "Názov nového zoznamu",
-  "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
-  "lists.replies_policy.none": "No one",
-  "lists.replies_policy.title": "Show replies to:",
+  "lists.replies_policy.followed": "Akýkoľvek nasledovaný užívateľ",
+  "lists.replies_policy.list": "Členovia na zozname",
+  "lists.replies_policy.none": "Nikto",
+  "lists.replies_policy.title": "Ukáž odpovede na:",
   "lists.search": "Vyhľadávaj medzi užívateľmi, ktorých sleduješ",
   "lists.subheading": "Tvoje zoznamy",
   "load_pending": "{count, plural, one {# nová položka} other {# nových položiek}}",
@@ -265,9 +272,9 @@
   "media_gallery.toggle_visible": "Zapni/Vypni viditeľnosť",
   "missing_indicator.label": "Nenájdené",
   "missing_indicator.sublabel": "Tento zdroj sa ešte nepodarilo nájsť",
-  "mute_modal.duration": "Duration",
+  "mute_modal.duration": "Trvanie",
   "mute_modal.hide_notifications": "Skry oznámenia od tohto používateľa?",
-  "mute_modal.indefinite": "Indefinite",
+  "mute_modal.indefinite": "Bez obmedzenia",
   "navigation_bar.apps": "Aplikácie",
   "navigation_bar.blocks": "Blokovaní užívatelia",
   "navigation_bar.bookmarks": "Záložky",
@@ -298,7 +305,7 @@
   "notification.own_poll": "Tvoja anketa sa skončila",
   "notification.poll": "Anketa v ktorej si hlasoval/a sa skončila",
   "notification.reblog": "{name} zdieľal/a tvoj príspevok",
-  "notification.status": "{name} just posted",
+  "notification.status": "{name} práve uverejnil/a",
   "notifications.clear": "Vyčisti oboznámenia",
   "notifications.clear_confirmation": "Naozaj chceš nenávratne prečistiť všetky tvoje oboznámenia?",
   "notifications.column_settings.alert": "Oboznámenia na ploche",
@@ -315,30 +322,31 @@
   "notifications.column_settings.show": "Ukáž v stĺpci",
   "notifications.column_settings.sound": "Prehraj zvuk",
   "notifications.column_settings.status": "New toots:",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.unread_markers.category": "Značenia neprečítaných oboznámení",
   "notifications.filter.all": "Všetky",
   "notifications.filter.boosts": "Vyzdvihnutia",
   "notifications.filter.favourites": "Obľúbené",
   "notifications.filter.follows": "Sledovania",
   "notifications.filter.mentions": "Iba spomenutia",
   "notifications.filter.polls": "Výsledky ankiet",
-  "notifications.filter.statuses": "Updates from people you follow",
-  "notifications.grant_permission": "Grant permission.",
+  "notifications.filter.statuses": "Aktualizácie od ľudí, ktorých následuješ",
+  "notifications.grant_permission": "Udeľ povolenie.",
   "notifications.group": "{count} oboznámení",
-  "notifications.mark_as_read": "Mark every notification as read",
-  "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
-  "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
-  "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
-  "notifications_permission_banner.enable": "Enable desktop notifications",
+  "notifications.mark_as_read": "Označ každé oboznámenie za prečítané",
+  "notifications.permission_denied": "Oboznámenia na plochu sú nedostupné, kvôli predtým zamietnutej požiadavke prehliadača",
+  "notifications.permission_denied_alert": "Oboznámenia na ploche nemôžu byť zapnuté, pretože požiadavka prehliadača o to, bola už skôr zamietnutá",
+  "notifications.permission_required": "Oboznámenia na ploche sú nedostupné, pretože potrebné povolenia neboli udelené.",
+  "notifications_permission_banner.enable": "Povoliť oboznámenia na plochu",
   "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
-  "notifications_permission_banner.title": "Never miss a thing",
-  "picture_in_picture.restore": "Put it back",
+  "notifications_permission_banner.title": "Nikdy nezmeškaj jedinú vec",
+  "picture_in_picture.restore": "Vrátiť späť",
   "poll.closed": "Uzatvorená",
-  "poll.refresh": "Občerstvi",
+  "poll.refresh": "Obnoviť",
   "poll.total_people": "{count, plural, one {# človek} few {# ľudia} other {# ľudí}}",
   "poll.total_votes": "{count, plural, one {# hlas} few {# hlasov} many {# hlasov} other {# hlasov}}",
   "poll.vote": "Hlasuj",
   "poll.voted": "Hlasoval/a si za túto voľbu",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Pridaj anketu",
   "poll_button.remove_poll": "Odstráň anketu",
   "privacy.change": "Uprav súkromie príspevku",
@@ -350,9 +358,9 @@
   "privacy.public.short": "Verejné",
   "privacy.unlisted.long": "Neposielaj do verejných časových osí",
   "privacy.unlisted.short": "Verejne, ale nezobraziť v osi",
-  "refresh": "Občerstvi",
+  "refresh": "Obnoviť",
   "regeneration_indicator.label": "Načítava sa…",
-  "regeneration_indicator.sublabel": "Vaša domovská nástenka sa pripravuje!",
+  "regeneration_indicator.sublabel": "Vaša nástenka sa pripravuje!",
   "relative_time.days": "{number}dní",
   "relative_time.hours": "{number}hod",
   "relative_time.just_now": "teraz",
@@ -368,7 +376,7 @@
   "report.target": "Nahlás {target}",
   "search.placeholder": "Hľadaj",
   "search_popout.search_format": "Pokročilé vyhľadávanie",
-  "search_popout.tips.full_text": "Vráti jednoduchý textový výpis príspevkov ktoré si napísal/a, ktoré si obľúbil/a, povýšil/a, alebo aj tých, v ktorých si bol/a spomenutý/á, a potom všetky zadaniu odpovedajúce prezívky, mená a haštagy.",
+  "search_popout.tips.full_text": "Vráti jednoduchý textový výpis príspevkov ktoré si napísal/a, ktoré si obľúbil/a, povýšil/a, alebo aj tých, v ktorých si bol/a spomenutý/á, a potom všetky zadaniu odpovedajúce prezývky, mená a haštagy.",
   "search_popout.tips.hashtag": "haštag",
   "search_popout.tips.status": "príspevok",
   "search_popout.tips.text": "Vráti jednoduchý textový výpis zhodujúcich sa mien, prezývok a haštagov",
@@ -423,7 +431,7 @@
   "suggestions.dismiss": "Zavrhni návrh",
   "suggestions.header": "Mohlo by ťa zaujímať…",
   "tabs_bar.federated_timeline": "Federovaná",
-  "tabs_bar.home": "Domovská",
+  "tabs_bar.home": "Domov",
   "tabs_bar.local_timeline": "Miestna",
   "tabs_bar.notifications": "Oboznámenia",
   "tabs_bar.search": "Hľadaj",
@@ -432,34 +440,35 @@
   "time_remaining.minutes": "Ostáva {number, plural, one {# minúta} few {# minút} many {# minút} other {# minúty}}",
   "time_remaining.moments": "Ostáva už iba chviľka",
   "time_remaining.seconds": "Ostáva {number, plural, one {# sekunda} few {# sekúnd} many {# sekúnd} other {# sekúnd}}",
-  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.remote_resource_not_displayed": "{resource} z iných serverov sa nezobrazí.",
   "timeline_hint.resources.followers": "Sledujúci",
   "timeline_hint.resources.follows": "Následuje",
   "timeline_hint.resources.statuses": "Staršie príspevky",
-  "trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
+  "trends.counter_by_accounts": "{count, plural, one {{counter} človek rozpráva} few {{counter} ľudia rozprávajú} many {{counter} ľudia rozprávajú} other {{counter} ľudí rozpráva}}",
   "trends.trending_now": "Teraz populárne",
   "ui.beforeunload": "Čo máš rozpísané sa stratí, ak opustíš Mastodon.",
-  "units.short.billion": "{count}B",
-  "units.short.million": "{count}M",
-  "units.short.thousand": "{count}K",
+  "units.short.billion": "{count}mld.",
+  "units.short.million": "{count}mil.",
+  "units.short.thousand": "{count}tis.",
   "upload_area.title": "Pretiahni a pusť pre nahratie",
   "upload_button.label": "Pridaj médiálny súbor (JPEG, PNG, GIF, WebM, MP4, MOV)",
   "upload_error.limit": "Limit pre nahrávanie súborov bol prekročený.",
   "upload_error.poll": "Nahrávanie súborov pri anketách nieje možné.",
   "upload_form.audio_description": "Popíš, pre ľudí so stratou sluchu",
   "upload_form.description": "Opis pre slabo vidiacich",
-  "upload_form.edit": "Popíš",
-  "upload_form.thumbnail": "Change thumbnail",
+  "upload_form.edit": "Uprav",
+  "upload_form.thumbnail": "Zmeniť miniatúru",
   "upload_form.undo": "Vymaž",
   "upload_form.video_description": "Popíš, pre ľudí so stratou sluchu, alebo očným znevýhodnením",
   "upload_modal.analyzing_picture": "Analyzujem obrázok…",
   "upload_modal.apply": "Použi",
-  "upload_modal.choose_image": "Choose image",
+  "upload_modal.applying": "Applying…",
+  "upload_modal.choose_image": "Vyber obrázok",
   "upload_modal.description_placeholder": "Rýchla hnedá líška skáče ponad lenivého psa",
   "upload_modal.detect_text": "Rozpoznaj text z obrázka",
   "upload_modal.edit_media": "Uprav médiá",
   "upload_modal.hint": "Klikni, alebo potiahni okruh ukážky pre zvolenie z ktorého východzieho bodu bude vždy v dohľadne na všetkých náhľadoch.",
-  "upload_modal.preparing_ocr": "Preparing OCR…",
+  "upload_modal.preparing_ocr": "Pripravujem OCR…",
   "upload_modal.preview_label": "Náhľad ({ratio})",
   "upload_progress.label": "Nahráva sa...",
   "video.close": "Zavri video",
diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json
index 2790a4d19..0919fc3cd 100644
--- a/app/javascript/mastodon/locales/sl.json
+++ b/app/javascript/mastodon/locales/sl.json
@@ -1,5 +1,5 @@
 {
-  "account.account_note_header": "Note",
+  "account.account_note_header": "Opombe",
   "account.add_or_remove_from_list": "Dodaj ali odstrani iz seznama",
   "account.badges.bot": "Robot",
   "account.badges.group": "Group",
@@ -47,11 +47,16 @@
   "account.unmute": "Odtišaj @{name}",
   "account.unmute_notifications": "Vklopi obvestila od @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Zgodila se je nepričakovana napaka.",
   "alert.unexpected.title": "Uups!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Če želite preskočiti to, lahko pritisnete {combo}",
   "bundle_column_error.body": "Med nalaganjem te komponente je prišlo do napake.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Ali ste prepričani, da želite izbrisati to stanje?",
   "confirmations.delete_list.confirm": "Izbriši",
   "confirmations.delete_list.message": "Ali ste prepričani, da želite trajno izbrisati ta seznam?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Skrij celotno domeno",
   "confirmations.domain_block.message": "Ali ste res, res prepričani, da želite blokirati celotno {domain}? V večini primerov je nekaj ciljnih blokiranj ali utišanj dovolj in boljše. Vsebino iz te domene ne boste videli v javnih časovnicah ali obvestilih. Vaši sledilci iz te domene bodo odstranjeni.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural,one {# glas} other {# glasov}}",
   "poll.vote": "Glasuj",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Dodaj anketo",
   "poll_button.remove_poll": "Odstrani anketo",
   "privacy.change": "Prilagodi zasebnost statusa",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "Pri Jakcu bom vzel šest čudežnih fig",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json
index de274500a..062b566d0 100644
--- a/app/javascript/mastodon/locales/sq.json
+++ b/app/javascript/mastodon/locales/sq.json
@@ -47,11 +47,16 @@
   "account.unmute": "Ktheji zërin @{name}",
   "account.unmute_notifications": "Hiqua ndalimin e shfaqjes njoftimeve nga @{name}",
   "account_note.placeholder": "Klikoni për të shtuar shënim",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Ju lutemi, riprovoni pas {retry_time, time, medium}.",
   "alert.rate_limited.title": "Shpejtësi e kufizuar",
   "alert.unexpected.message": "Ndodhi një gabim të papritur.",
   "alert.unexpected.title": "Hëm!",
   "announcement.announcement": "Lajmërim",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} për javë",
   "boost_modal.combo": "Që kjo të anashkalohet herës tjetër, mund të shtypni {combo}",
   "bundle_column_error.body": "Diç shkoi ters teksa ngarkohej ky përbërës.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Jeni i sigurt se doni të fshihet kjo gjendje?",
   "confirmations.delete_list.confirm": "Fshije",
   "confirmations.delete_list.message": "Jeni i sigurt se doni të fshihet përgjithmonë kjo listë?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Bllokoje krejt përkatësinë",
   "confirmations.domain_block.message": "Jeni i sigurt, shumë i sigurt se doni të bllokohet krejt {domain}? Në shumicën e rasteve, ndoca bllokime ose heshtime me synim të caktuar janë të mjaftueshme dhe të parapëlqyera. S’keni për të parë lëndë nga kjo përkatësi në ndonjë rrjedhë kohore publike, apo te njoftimet tuaja. Ndjekësit tuaj prej asaj përkatësie do të hiqen.",
   "confirmations.logout.confirm": "Dilni",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural,one {# votë }other {# vota }}",
   "poll.vote": "Votoni",
   "poll.voted": "Votuat për këtë përgjigje",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Shtoni një pyetësor",
   "poll_button.remove_poll": "Hiqe pyetësorin",
   "privacy.change": "Rregulloni privatësi mesazhesh",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Përshkruajeni për persona me dëgjim të kufizuar ose probleme shikimi",
   "upload_modal.analyzing_picture": "Po analizohet fotoja…",
   "upload_modal.apply": "Aplikoje",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Zgjidhni figurë",
   "upload_modal.description_placeholder": "Deshe Korçën, Korçën të dhamë",
   "upload_modal.detect_text": "Pikase tekstin prej fotoje",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index 61e37ba53..3e29734ef 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -47,11 +47,16 @@
   "account.unmute": "Ukloni ućutkavanje korisniku @{name}",
   "account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put",
   "bundle_column_error.body": "Nešto je pošlo po zlu prilikom učitavanja ove komponente.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Da li ste sigurni da želite obrišete ovaj status?",
   "confirmations.delete_list.confirm": "Obriši",
   "confirmations.delete_list.message": "Da li ste sigurni da želite da bespovratno obrišete ovu listu?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Sakrij ceo domen",
   "confirmations.domain_block.message": "Da li ste stvarno, stvarno sigurno da želite da blokirate ceo domen {domain}? U većini slučajeva, par dobrih blokiranja ili ućutkavanja su dovoljna i preporučljiva.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Podesi status privatnosti",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index 24a4c832c..ba822e15d 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -6,14 +6,14 @@
   "account.block": "Блокирај @{name}",
   "account.block_domain": "Сакриј све са домена {domain}",
   "account.blocked": "Блокиран",
-  "account.browse_more_on_origin_server": "Погледајте још на оригиналном профилу",
+  "account.browse_more_on_origin_server": "Погледајте још на оригиналном налогу",
   "account.cancel_follow_request": "Поништи захтеве за праћење",
   "account.direct": "Директна порука @{name}",
   "account.disable_notifications": "Прекини обавештавање за објаве корисника @{name}",
   "account.domain_blocked": "Домен сакривен",
-  "account.edit_profile": "Измени профил",
+  "account.edit_profile": "Уреди налог",
   "account.enable_notifications": "Обавести ме када @{name} објави",
-  "account.endorse": "Приказати на профилу",
+  "account.endorse": "Истакнуто на налогу",
   "account.follow": "Запрати",
   "account.followers": "Пратиоци",
   "account.followers.empty": "Тренутно нико не прати овог корисника.",
@@ -22,7 +22,7 @@
   "account.follows.empty": "Корисник тренутно не прати никога.",
   "account.follows_you": "Прати Вас",
   "account.hide_reblogs": "Сакриј подршке које даје корисника @{name}",
-  "account.joined": "Joined {date}",
+  "account.joined": "Придружио/ла се {date}",
   "account.last_status": "Последњи пут активан/на",
   "account.link_verified_on": "Власништво над овом везом је проверено {date}",
   "account.locked_info": "Статус приватности овог налога је подешен на закључано. Власник ручно прегледа ко га може пратити.",
@@ -37,21 +37,26 @@
   "account.posts_with_replies": "Трубе и одговори",
   "account.report": "Пријави @{name}",
   "account.requested": "Чекам одобрење. Кликните да поништите захтев за праћење",
-  "account.share": "Подели профил корисника @{name}",
+  "account.share": "Подели налог корисника @{name}",
   "account.show_reblogs": "Прикажи подршке од корисника @{name}",
   "account.statuses_counter": "{count, plural, one {{counter} објава} few {{counter} објаве} other {{counter} објава}}",
   "account.unblock": "Одблокирај корисника @{name}",
   "account.unblock_domain": "Одблокирај домен {domain}",
-  "account.unendorse": "Не истичи на профилу",
+  "account.unendorse": "Не истичи на налогу",
   "account.unfollow": "Отпрати",
   "account.unmute": "Уклони ућуткавање кориснику @{name}",
   "account.unmute_notifications": "Укључи назад обавештења од корисника @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Молимо покушајте поново после {retry_time, time, medium}.",
   "alert.rate_limited.title": "Ограничена брзина",
   "alert.unexpected.message": "Појавила се неочекивана грешка.",
   "alert.unexpected.title": "Упс!",
   "announcement.announcement": "Најава",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} недељно",
   "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут",
   "bundle_column_error.body": "Нешто је пошло по злу приликом учитавања ове компоненте.",
@@ -64,7 +69,7 @@
   "column.bookmarks": "Обележивачи",
   "column.community": "Локална временска линија",
   "column.direct": "Директне поруке",
-  "column.directory": "Претражиј профиле",
+  "column.directory": "Претражи налоге",
   "column.domain_blocks": "Скривени домени",
   "column.favourites": "Омиљене",
   "column.follow_requests": "Захтеви за праћење",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Да ли сте сигурни да желите обришете овај статус?",
   "confirmations.delete_list.confirm": "Обриши",
   "confirmations.delete_list.message": "Да ли сте сигурни да желите да бесповратно обришете ову листу?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Сакриј цео домен",
   "confirmations.domain_block.message": "Да ли сте заиста сигурни да желите да блокирате цео домен {domain}? У већини случајева, неколико добро промишљених блокирања или ућуткавања су довољна и препоручљива.",
   "confirmations.logout.confirm": "Одјави се",
@@ -152,7 +159,7 @@
   "emoji_button.travel": "Путовања и места",
   "empty_column.account_suspended": "Налог суспендован",
   "empty_column.account_timeline": "Овде нема труба!",
-  "empty_column.account_unavailable": "Профил недоступан",
+  "empty_column.account_unavailable": "Налог је недоступан",
   "empty_column.blocks": "Још увек немате блокираних корисника.",
   "empty_column.bookmarked_statuses": "Још увек немате обележене трубе. Када их обележите, појавиће се овде.",
   "empty_column.community": "Локална временска линија је празна. Напишите нешто јавно да започнете!",
@@ -184,7 +191,7 @@
   "follow_requests.unlocked_explanation": "Иако ваш налог није закључан, особље {domain} је помислило да бисте можда желели ручно да прегледате захтеве за праћење са ових налога.",
   "generic.saved": "Сачувано",
   "getting_started.developers": "Програмери",
-  "getting_started.directory": "Профил фасцикле",
+  "getting_started.directory": "Директоријум налога",
   "getting_started.documentation": "Документација",
   "getting_started.heading": "Да почнете",
   "getting_started.invite": "Позовите људе",
@@ -227,11 +234,11 @@
   "keyboard_shortcuts.local": "да отворите локалну временску линију",
   "keyboard_shortcuts.mention": "да поменете аутора",
   "keyboard_shortcuts.muted": "да отворите листу ућутканих корисника",
-  "keyboard_shortcuts.my_profile": "да отворите ваш профил",
+  "keyboard_shortcuts.my_profile": "Погледајте ваш налог",
   "keyboard_shortcuts.notifications": "да отворите колону обавештења",
   "keyboard_shortcuts.open_media": "за отварање медија",
   "keyboard_shortcuts.pinned": "да отворите листу закачених труба",
-  "keyboard_shortcuts.profile": "да отворите профил аутора",
+  "keyboard_shortcuts.profile": "Погледајте налог аутора",
   "keyboard_shortcuts.reply": "да одговорите",
   "keyboard_shortcuts.requests": "да отворите листу примљених захтева за праћење",
   "keyboard_shortcuts.search": "да се пребаците на претрагу",
@@ -276,11 +283,11 @@
   "navigation_bar.direct": "Директне поруке",
   "navigation_bar.discover": "Откриј",
   "navigation_bar.domain_blocks": "Сакривени домени",
-  "navigation_bar.edit_profile": "Измени профил",
+  "navigation_bar.edit_profile": "Измени налог",
   "navigation_bar.favourites": "Омиљене",
   "navigation_bar.filters": "Пригушене речи",
   "navigation_bar.follow_requests": "Захтеви за праћење",
-  "navigation_bar.follows_and_followers": "Follows and followers",
+  "navigation_bar.follows_and_followers": "Праћења и пратиоци",
   "navigation_bar.info": "О овој инстанци",
   "navigation_bar.keyboard_shortcuts": "Пречице на тастатури",
   "navigation_bar.lists": "Листе",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# гласање} few {# гласања} other {# гласања}}",
   "poll.vote": "Гласајте",
   "poll.voted": "Гласали сте за овај одговор",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Додај анкету",
   "poll_button.remove_poll": "Уклони анкету",
   "privacy.change": "Подеси статус приватности",
@@ -398,7 +406,7 @@
   "status.mute": "Ућуткај @{name}",
   "status.mute_conversation": "Ућуткај преписку",
   "status.open": "Прошири овај статус",
-  "status.pin": "Закачи на профил",
+  "status.pin": "Закачи на налог",
   "status.pinned": "Закачена труба",
   "status.read_more": "Прочитајте више",
   "status.reblog": "Подржи",
@@ -419,7 +427,7 @@
   "status.show_thread": "Show thread",
   "status.uncached_media_warning": "Није доступно",
   "status.unmute_conversation": "Укључи преписку",
-  "status.unpin": "Откачи са профила",
+  "status.unpin": "Откачи са налога",
   "suggestions.dismiss": "Dismiss suggestion",
   "suggestions.header": "You might be interested in…",
   "tabs_bar.federated_timeline": "Федерисано",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Анализа слике…",
   "upload_modal.apply": "Примени",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Изабери слику",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index b3f49aaa9..f80d1ce0e 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -47,11 +47,16 @@
   "account.unmute": "Sluta tysta @{name}",
   "account.unmute_notifications": "Återaktivera aviseringar från @{name}",
   "account_note.placeholder": "Klicka för att lägga till anteckning",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Vänligen försök igen efter {retry_time, time, medium}.",
   "alert.rate_limited.title": "Mängd begränsad",
   "alert.unexpected.message": "Ett oväntat fel uppstod.",
   "alert.unexpected.title": "Hoppsan!",
   "announcement.announcement": "Meddelande",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per vecka",
   "boost_modal.combo": "Du kan trycka {combo} för att slippa detta nästa gång",
   "bundle_column_error.body": "Något gick fel medan denna komponent laddades.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Är du säker på att du vill radera denna status?",
   "confirmations.delete_list.confirm": "Radera",
   "confirmations.delete_list.message": "Är du säker på att du vill radera denna lista permanent?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Dölj hela domänen",
   "confirmations.domain_block.message": "Är du verkligen, verkligen säker på att du vill blockera hela {domain}? I de flesta fall är några riktade blockeringar eller nedtystade konton tillräckligt och att föredra. Du kommer inte se innehåll från den domänen i den allmänna tidslinjen eller i dina aviseringar. Dina följare från den domänen komer att tas bort.",
   "confirmations.logout.confirm": "Logga ut",
@@ -164,7 +171,7 @@
   "empty_column.follow_requests": "Du har inga följarförfrågningar än. När du får en kommer den visas här.",
   "empty_column.hashtag": "Det finns inget i denna hashtag ännu.",
   "empty_column.home": "Din hemma-tidslinje är tom! Besök {public} eller använd sökning för att komma igång och träffa andra användare.",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home.suggestions": "Se några förslag",
   "empty_column.list": "Det finns inget i denna lista än. När medlemmar i denna lista lägger till nya statusar kommer de att visas här.",
   "empty_column.lists": "Du har inga listor än. När skapar en kommer den dyka upp här.",
   "empty_column.mutes": "Du har ännu inte tystat några användare.",
@@ -255,7 +262,7 @@
   "lists.new.create": "Lägg till lista",
   "lists.new.title_placeholder": "Ny listrubrik",
   "lists.replies_policy.followed": "Any followed user",
-  "lists.replies_policy.list": "Members of the list",
+  "lists.replies_policy.list": "Medlemmar i listan",
   "lists.replies_policy.none": "Ingen",
   "lists.replies_policy.title": "Visa svar till:",
   "lists.search": "Sök bland personer du följer",
@@ -323,7 +330,7 @@
   "notifications.filter.mentions": "Omnämningar",
   "notifications.filter.polls": "Omröstningsresultat",
   "notifications.filter.statuses": "Uppdateringar från personer som du följer",
-  "notifications.grant_permission": "Grant permission.",
+  "notifications.grant_permission": "Godkänn åtkomst.",
   "notifications.group": "{count} aviseringar",
   "notifications.mark_as_read": "Markera varje avisering som läst",
   "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {1 röst} other {# röster}}",
   "poll.vote": "Rösta",
   "poll.voted": "Du röstade för detta svar",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Lägg till en omröstning",
   "poll_button.remove_poll": "Ta bort omröstning",
   "privacy.change": "Justera sekretess",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Beskriv för personer med hörsel- eller synnedsättning",
   "upload_modal.analyzing_picture": "Analyserar bild…",
   "upload_modal.apply": "Verkställ",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Välj bild",
   "upload_modal.description_placeholder": "En snabb brun räv hoppar över den lata hunden",
   "upload_modal.detect_text": "Upptäck bildens text",
diff --git a/app/javascript/mastodon/locales/szl.json b/app/javascript/mastodon/locales/szl.json
index c1eadb5a3..eca4765c4 100644
--- a/app/javascript/mastodon/locales/szl.json
+++ b/app/javascript/mastodon/locales/szl.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json
index 3d0d80e37..1b3e70fb6 100644
--- a/app/javascript/mastodon/locales/ta.json
+++ b/app/javascript/mastodon/locales/ta.json
@@ -47,11 +47,16 @@
   "account.unmute": "@{name} இன் மீது மௌனத் தடையை நீக்குக",
   "account.unmute_notifications": "@{name} இலிருந்து அறிவிப்புகளின் மீது மௌனத் தடையை நீக்குக",
   "account_note.placeholder": "குறிப்பு ஒன்றை சேர்க்க சொடுக்கவும்",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "{retry_time, time, medium} க்கு பிறகு மீண்டும் முயற்சிக்கவும்.",
   "alert.rate_limited.title": "பயன்பாடு கட்டுப்படுத்தப்பட்டுள்ளது",
   "alert.unexpected.message": "எதிர்பாராத பிழை ஏற்பட்டுவிட்டது.",
   "alert.unexpected.title": "அச்சச்சோ!",
   "announcement.announcement": "அறிவிப்பு",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "ஒவ்வொரு வாரம் {count}",
   "boost_modal.combo": "நீங்கள் இதை அடுத்தமுறை தவிர்க்க {combo} வை அழுத்தவும்",
   "bundle_column_error.body": "இக்கூற்றை ஏற்றம் செய்யும்பொழுது ஏதோ தவறு ஏற்பட்டுள்ளது.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "இப்பதிவை நிச்சயமாக நீக்க விரும்புகிறீர்களா?",
   "confirmations.delete_list.confirm": "நீக்கு",
   "confirmations.delete_list.message": "இப்பட்டியலை நிரந்தரமாக நீக்க நிச்சயம் விரும்புகிறீர்களா?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "முழு களத்தையும் மறை",
   "confirmations.domain_block.message": "நீங்கள் முழு {domain} களத்தையும் நிச்சயமாக, நிச்சயமாகத் தடுக்க விரும்புகிறீர்களா? பெரும்பாலும் சில குறிப்பிட்ட பயனர்களைத் தடுப்பதே போதுமானது. முழு களத்தையும் தடுத்தால், அதிலிருந்து வரும் எந்தப் பதிவையும் உங்களால் காண முடியாது, மேலும் அப்பதிவுகள் குறித்த அறிவிப்புகளும் உங்களுக்கு வராது. அந்தக் களத்தில் இருக்கும் பின்தொடர்பவர்கள் உங்கள் பக்கத்திலிருந்து நீக்கப்படுவார்கள்.",
   "confirmations.logout.confirm": "வெளியேறு",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} மற்ற {# votes}}",
   "poll.vote": "வாக்களி",
   "poll.voted": "உங்கள் தேர்வு",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "வாக்கெடுப்பைச் சேர்க்கவும்",
   "poll_button.remove_poll": "வாக்கெடுப்பை அகற்று",
   "privacy.change": "நிலை தனியுரிமை",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "செவித்திறன் மற்றும் பார்வைக் குறைபாடு உள்ளவர்களுக்காக விளக்குக‌",
   "upload_modal.analyzing_picture": "படம் ஆராயப்படுகிறது…",
   "upload_modal.apply": "உபயோகி",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "படத்தைத் தேர்வுசெய்ய",
   "upload_modal.description_placeholder": "பொருள் விளக்கம்",
   "upload_modal.detect_text": "படத்தில் இருக்கும் எழுத்தை கண்டறி",
diff --git a/app/javascript/mastodon/locales/tai.json b/app/javascript/mastodon/locales/tai.json
index 1119c3800..2302e7ccc 100644
--- a/app/javascript/mastodon/locales/tai.json
+++ b/app/javascript/mastodon/locales/tai.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json
index 306c1ec9f..348d753dc 100644
--- a/app/javascript/mastodon/locales/te.json
+++ b/app/javascript/mastodon/locales/te.json
@@ -47,11 +47,16 @@
   "account.unmute": "@{name}పై మ్యూట్ ని తొలగించు",
   "account.unmute_notifications": "@{name} నుంచి ప్రకటనలపై మ్యూట్ ని తొలగించు",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "అనుకోని తప్పు జరిగినది.",
   "alert.unexpected.title": "అయ్యో!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "మీరు తదుపరిసారి దీనిని దాటవేయడానికి {combo} నొక్కవచ్చు",
   "bundle_column_error.body": "ఈ భాగం లోడ్ అవుతున్నప్పుడు ఏదో తప్పు జరిగింది.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "మీరు ఖచ్చితంగా ఈ స్టేటస్ ని తొలగించాలనుకుంటున్నారా?",
   "confirmations.delete_list.confirm": "తొలగించు",
   "confirmations.delete_list.message": "మీరు ఖచ్చితంగా ఈ జాబితాను శాశ్వతంగా తొలగించాలనుకుంటున్నారా?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "మొత్తం డొమైన్ను దాచు",
   "confirmations.domain_block.message": "మీరు నిజంగా నిజంగా మొత్తం {domain} ని బ్లాక్ చేయాలనుకుంటున్నారా? చాలా సందర్భాలలో కొన్ని లక్ష్యంగా ఉన్న బ్లాక్స్ లేదా మ్యూట్స్ సరిపోతాయి మరియు ఉత్తమమైనవి. మీరు ఆ డొమైన్ నుండి కంటెంట్ను ఏ ప్రజా కాలక్రమాలలో లేదా మీ నోటిఫికేషన్లలో చూడలేరు. ఆ డొమైన్ నుండి మీ అనుచరులు తీసివేయబడతారు.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "ఎన్నుకోండి",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "ఒక ఎన్నికను చేర్చు",
   "poll_button.remove_poll": "ఎన్నికను తొలగించు",
   "privacy.change": "స్టేటస్ గోప్యతను సర్దుబాటు చేయండి",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 03dc725c5..90b8ba464 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -47,11 +47,16 @@
   "account.unmute": "เลิกซ่อน @{name}",
   "account.unmute_notifications": "เลิกซ่อนการแจ้งเตือนจาก @{name}",
   "account_note.placeholder": "คลิกเพื่อเพิ่มหมายเหตุ",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "โปรดลองใหม่หลังจาก {retry_time, time, medium}",
   "alert.rate_limited.title": "มีการจำกัดอัตรา",
   "alert.unexpected.message": "เกิดข้อผิดพลาดที่ไม่คาดคิด",
   "alert.unexpected.title": "อุปส์!",
   "announcement.announcement": "ประกาศ",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} ต่อสัปดาห์",
   "boost_modal.combo": "คุณสามารถกด {combo} เพื่อข้ามสิ่งนี้ในครั้งถัดไป",
   "bundle_column_error.body": "มีบางอย่างผิดพลาดขณะโหลดส่วนประกอบนี้",
@@ -87,7 +92,7 @@
   "community.column_settings.remote_only": "ระยะไกลเท่านั้น",
   "compose_form.direct_message_warning": "จะส่งโพสต์นี้ไปยังผู้ใช้ที่กล่าวถึงเท่านั้น",
   "compose_form.direct_message_warning_learn_more": "เรียนรู้เพิ่มเติม",
-  "compose_form.hashtag_warning": "จะไม่แสดงรายการโพสต์นี้ภายใต้แฮชแท็กใด ๆ เนื่องจากไม่อยู่ในรายการ เฉพาะโพสต์สาธารณะเท่านั้นที่สามารถค้นหาโดยแฮชแท็ก",
+  "compose_form.hashtag_warning": "จะไม่แสดงรายการโพสต์นี้ภายใต้แฮชแท็กใด ๆ เนื่องจากไม่อยู่ในรายการ เฉพาะโพสต์สาธารณะเท่านั้นที่สามารถค้นหาได้โดยแฮชแท็ก",
   "compose_form.lock_disclaimer": "บัญชีของคุณไม่ได้ {locked} ใครก็ตามสามารถติดตามคุณเพื่อดูโพสต์สำหรับผู้ติดตามเท่านั้นของคุณ",
   "compose_form.lock_disclaimer.lock": "ล็อคอยู่",
   "compose_form.placeholder": "คุณกำลังคิดอะไรอยู่?",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "คุณแน่ใจหรือไม่ว่าต้องการลบโพสต์นี้?",
   "confirmations.delete_list.confirm": "ลบ",
   "confirmations.delete_list.message": "คุณแน่ใจหรือไม่ว่าต้องการลบรายการนี้อย่างถาวร?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "ปิดกั้นทั้งโดเมน",
   "confirmations.domain_block.message": "คุณแน่ใจจริง ๆ หรือไม่ว่าต้องการปิดกั้นทั้ง {domain}? ในกรณีส่วนใหญ่ การปิดกั้นหรือการซ่อนแบบกำหนดเป้าหมายไม่กี่รายการนั้นเพียงพอและเป็นที่นิยม คุณจะไม่เห็นเนื้อหาจากโดเมนนั้นในเส้นเวลาสาธารณะใด ๆ หรือการแจ้งเตือนของคุณ จะเอาผู้ติดตามของคุณจากโดเมนนั้นออก",
   "confirmations.logout.confirm": "ออกจากระบบ",
@@ -152,7 +159,7 @@
   "emoji_button.travel": "การเดินทางและสถานที่",
   "empty_column.account_suspended": "ระงับบัญชีอยู่",
   "empty_column.account_timeline": "ไม่มีโพสต์ที่นี่!",
-  "empty_column.account_unavailable": "ไม่มีโปรไฟล์",
+  "empty_column.account_unavailable": "โปรไฟล์ไม่พร้อมใช้งาน",
   "empty_column.blocks": "คุณยังไม่ได้ปิดกั้นผู้ใช้ใด ๆ",
   "empty_column.bookmarked_statuses": "คุณยังไม่มีโพสต์ที่เพิ่มที่คั่นหน้าไว้ใด ๆ เมื่อคุณเพิ่มที่คั่นหน้าโพสต์ โพสต์จะปรากฏที่นี่",
   "empty_column.community": "เส้นเวลาในเซิร์ฟเวอร์ว่างเปล่า เขียนบางอย่างเป็นสาธารณะเพื่อเริ่มต้น!",
@@ -164,7 +171,7 @@
   "empty_column.follow_requests": "คุณยังไม่มีคำขอติดตามใด ๆ เมื่อคุณได้รับคำขอ คำขอจะปรากฏที่นี่",
   "empty_column.hashtag": "ยังไม่มีสิ่งใดในแฮชแท็กนี้",
   "empty_column.home": "เส้นเวลาหน้าแรกของคุณว่างเปล่า! ติดตามผู้คนเพิ่มเติมเพื่อเติมเส้นเวลาให้เต็ม {suggestions}",
-  "empty_column.home.suggestions": "ดูข้อเสนอแนะบางอย่าง",
+  "empty_column.home.suggestions": "ดูข้อเสนอแนะบางส่วน",
   "empty_column.list": "ยังไม่มีสิ่งใดในรายการนี้ เมื่อสมาชิกของรายการนี้โพสต์โพสต์ใหม่ โพสต์จะปรากฏที่นี่",
   "empty_column.lists": "คุณยังไม่มีรายการใด ๆ เมื่อคุณสร้างรายการ รายการจะปรากฏที่นี่",
   "empty_column.mutes": "คุณยังไม่ได้ซ่อนผู้ใช้ใด ๆ",
@@ -172,12 +179,12 @@
   "empty_column.public": "ไม่มีสิ่งใดที่นี่! เขียนบางอย่างเป็นสาธารณะ หรือติดตามผู้ใช้จากเซิร์ฟเวอร์อื่น ๆ ด้วยตนเองเพื่อเติมให้เต็ม",
   "error.unexpected_crash.explanation": "เนื่องจากข้อบกพร่องในโค้ดของเราหรือปัญหาความเข้ากันได้ของเบราว์เซอร์ จึงไม่สามารถแสดงหน้านี้ได้อย่างถูกต้อง",
   "error.unexpected_crash.explanation_addons": "ไม่สามารถแสดงหน้านี้ได้อย่างถูกต้อง ข้อผิดพลาดนี้เป็นไปได้ว่าเกิดจากส่วนเสริมของเบราว์เซอร์หรือเครื่องมือการแปลอัตโนมัติ",
-  "error.unexpected_crash.next_steps": "ลองรีเฟรชหน้า หากนั่นไม่ช่วย คุณอาจยังสามารถใช้ Mastodon ผ่านเบราว์เซอร์อื่นหรือแอป",
-  "error.unexpected_crash.next_steps_addons": "ลองปิดใช้งานส่วนเสริมหรือเครื่องมือแล้วรีเฟรชหน้า หากนั่นไม่ช่วย คุณอาจยังสามารถใช้ Mastodon ผ่านเบราว์เซอร์อื่นหรือแอป",
+  "error.unexpected_crash.next_steps": "ลองรีเฟรชหน้า หากนั่นไม่ช่วย คุณอาจยังสามารถใช้ Mastodon ได้ผ่านเบราว์เซอร์อื่นหรือแอป",
+  "error.unexpected_crash.next_steps_addons": "ลองปิดใช้งานส่วนเสริมหรือเครื่องมือแล้วรีเฟรชหน้า หากนั่นไม่ช่วย คุณอาจยังสามารถใช้ Mastodon ได้ผ่านเบราว์เซอร์อื่นหรือแอป",
   "errors.unexpected_crash.copy_stacktrace": "คัดลอกการติดตามสแตกไปยังคลิปบอร์ด",
   "errors.unexpected_crash.report_issue": "รายงานปัญหา",
   "follow_recommendations.done": "เสร็จสิ้น",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
+  "follow_recommendations.heading": "ติดตามผู้คนที่คุณต้องการเห็นโพสต์! นี่คือข้อเสนอแนะบางส่วน",
   "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
   "follow_request.authorize": "อนุญาต",
   "follow_request.reject": "ปฏิเสธ",
@@ -188,7 +195,7 @@
   "getting_started.documentation": "เอกสารประกอบ",
   "getting_started.heading": "เริ่มต้นใช้งาน",
   "getting_started.invite": "เชิญผู้คน",
-  "getting_started.open_source_notice": "Mastodon เป็นซอฟต์แวร์โอเพนซอร์ส คุณสามารถมีส่วนร่วมหรือรายงานปัญหาใน GitHub ที่ {github}",
+  "getting_started.open_source_notice": "Mastodon เป็นซอฟต์แวร์โอเพนซอร์ส คุณสามารถมีส่วนร่วมหรือรายงานปัญหาได้ใน GitHub ที่ {github}",
   "getting_started.security": "การตั้งค่าบัญชี",
   "getting_started.terms": "เงื่อนไขการให้บริการ",
   "hashtag.column_header.tag_mode.all": "และ {additional}",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, other {# การลงคะแนน}}",
   "poll.vote": "ลงคะแนน",
   "poll.voted": "คุณได้ลงคะแนนให้กับคำตอบนี้",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "เพิ่มการสำรวจความคิดเห็น",
   "poll_button.remove_poll": "เอาการสำรวจความคิดเห็นออก",
   "privacy.change": "เปลี่ยนความเป็นส่วนตัวของโพสต์",
@@ -362,7 +370,7 @@
   "reply_indicator.cancel": "ยกเลิก",
   "report.forward": "ส่งต่อไปยัง {target}",
   "report.forward_hint": "บัญชีมาจากเซิร์ฟเวอร์อื่น ส่งสำเนาของรายงานที่ไม่ระบุตัวตนไปที่นั่นด้วย?",
-  "report.hint": "จะส่งรายงานไปยังผู้ควบคุมเซิร์ฟเวอร์ของคุณ คุณสามารถให้คำอธิบายเหตุผลที่คุณรายงานบัญชีนี้ด้านล่าง:",
+  "report.hint": "จะส่งรายงานไปยังผู้ควบคุมเซิร์ฟเวอร์ของคุณ คุณสามารถให้คำอธิบายเหตุผลที่คุณรายงานบัญชีนี้ได้ด้านล่าง:",
   "report.placeholder": "ความคิดเห็นเพิ่มเติม",
   "report.submit": "ส่ง",
   "report.target": "กำลังรายงาน {target}",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "อธิบายสำหรับผู้สูญเสียการได้ยินหรือบกพร่องทางการมองเห็น",
   "upload_modal.analyzing_picture": "กำลังวิเคราะห์รูปภาพ…",
   "upload_modal.apply": "นำไปใช้",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "เลือกภาพ",
   "upload_modal.description_placeholder": "สุนัขจิ้งจอกสีน้ำตาลที่ว่องไวกระโดดข้ามสุนัขขี้เกียจ",
   "upload_modal.detect_text": "ตรวจหาข้อความจากรูปภาพ",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index ec97545e7..2ae8a908a 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -9,10 +9,10 @@
   "account.browse_more_on_origin_server": "Orijinal profilde daha fazlasına göz atın",
   "account.cancel_follow_request": "Takip isteğini iptal et",
   "account.direct": "@{name} adlı kişiye mesaj gönder",
-  "account.disable_notifications": "@{name} gönderi yaptığında bana bildirmeyi durdur",
+  "account.disable_notifications": "@{name} gönderi atınca bana bildirmeyi durdur",
   "account.domain_blocked": "Alan adı engellendi",
   "account.edit_profile": "Profili düzenle",
-  "account.enable_notifications": "@{name} gönderi yaptığında bana bildir",
+  "account.enable_notifications": "@{name} gönderi atınca bana bildir",
   "account.endorse": "Profildeki özellik",
   "account.follow": "Takip et",
   "account.followers": "Takipçi",
@@ -33,13 +33,13 @@
   "account.mute_notifications": "@{name} adlı kişinin bildirimlerini kapat",
   "account.muted": "Susturuldu",
   "account.never_active": "Asla",
-  "account.posts": "Toot",
-  "account.posts_with_replies": "Tootlar ve cevaplar",
+  "account.posts": "Gönderiler",
+  "account.posts_with_replies": "Gönderiler ve yanıtlar",
   "account.report": "@{name} adlı kişiyi bildir",
   "account.requested": "Onay bekleniyor. Takip isteğini iptal etmek için tıklayın",
   "account.share": "@{name} adlı kişinin profilini paylaş",
   "account.show_reblogs": "@{name} kişisinin boostlarını göster",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toot}}",
+  "account.statuses_counter": "{count, plural, one {{counter} Gönderi} other {{counter} Gönderi}}",
   "account.unblock": "@{name} adlı kişinin engelini kaldır",
   "account.unblock_domain": "{domain} alan adının engelini kaldır",
   "account.unendorse": "Profilde gösterme",
@@ -47,13 +47,18 @@
   "account.unmute": "@{name} adlı kişinin sesini aç",
   "account.unmute_notifications": "@{name} adlı kişinin bildirimlerini aç",
   "account_note.placeholder": "Not eklemek için tıklayın",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Lütfen {retry_time, time, medium} süresinden sonra tekrar deneyin.",
   "alert.rate_limited.title": "Oran sınırlıdır",
   "alert.unexpected.message": "Beklenmedik bir hata oluştu.",
   "alert.unexpected.title": "Hay aksi!",
   "announcement.announcement": "Duyuru",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "Haftada {count}",
-  "boost_modal.combo": "Bir daha ki sefere {combo} tuşuna basabilirsiniz",
+  "boost_modal.combo": "Bir daha ki sefere {combo} tuşuna basabilirsin",
   "bundle_column_error.body": "Bu bileşen yüklenirken bir şeyler ters gitti.",
   "bundle_column_error.retry": "Tekrar deneyin",
   "bundle_column_error.title": "Ağ hatası",
@@ -72,7 +77,7 @@
   "column.lists": "Listeler",
   "column.mutes": "Sessize alınmış kullanıcılar",
   "column.notifications": "Bildirimler",
-  "column.pins": "Sabitlenmiş tootlar",
+  "column.pins": "Sabitlenmiş gönderiler",
   "column.public": "Federe zaman tüneli",
   "column_back_button.label": "Geri",
   "column_header.hide_settings": "Ayarları gizle",
@@ -85,10 +90,10 @@
   "community.column_settings.local_only": "Sadece yerel",
   "community.column_settings.media_only": "Sadece medya",
   "community.column_settings.remote_only": "Sadece uzak",
-  "compose_form.direct_message_warning": "Bu toot sadece belirtilen kullanıcılara gönderilecektir.",
+  "compose_form.direct_message_warning": "Bu gönderi sadece belirtilen kullanıcılara gönderilecektir.",
   "compose_form.direct_message_warning_learn_more": "Daha fazla bilgi edinin",
-  "compose_form.hashtag_warning": "Bu toot liste dışı olduğu için hiç bir etikette yer almayacak. Sadece herkese açık tootlar etiketlerde bulunabilir.",
-  "compose_form.lock_disclaimer": "Hesabınız {locked} değil. Sadece takipçilerle paylaştığınız gönderileri görebilmek için sizi herhangi bir kullanıcı takip edebilir.",
+  "compose_form.hashtag_warning": "Bu gönderi liste dışı olduğu için hiç bir etikette yer almayacak. Sadece herkese açık gönderiler etiketlerde bulunabilir.",
+  "compose_form.lock_disclaimer": "Hesabın {locked} değil. Yalnızca takipçilere özel gönderilerini görüntülemek için herkes seni takip edebilir.",
   "compose_form.lock_disclaimer.lock": "kilitli",
   "compose_form.placeholder": "Aklında ne var?",
   "compose_form.poll.add_option": "Bir seçenek ekleyin",
@@ -108,24 +113,26 @@
   "confirmation_modal.cancel": "İptal",
   "confirmations.block.block_and_report": "Engelle ve Bildir",
   "confirmations.block.confirm": "Engelle",
-  "confirmations.block.message": "{name} adlı kullanıcıyı engellemek istediğinizden emin misiniz?",
+  "confirmations.block.message": "{name} adlı kullanıcıyı engellemek istediğinden emin misin?",
   "confirmations.delete.confirm": "Sil",
-  "confirmations.delete.message": "Bu tootu silmek istediğinizden emin misiniz?",
+  "confirmations.delete.message": "Bu tootu silmek istediğinden emin misin?",
   "confirmations.delete_list.confirm": "Sil",
-  "confirmations.delete_list.message": "Bu listeyi kalıcı olarak silmek istediğinize emin misiniz?",
+  "confirmations.delete_list.message": "Bu listeyi kalıcı olarak silmek istediğinden emin misin?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Alanın tamamını engelle",
-  "confirmations.domain_block.message": "tüm {domain} alan adını engellemek istediğinizden emin misiniz? Genellikle birkaç hedefli engel ve susturma işi görür ve tercih edilir.",
+  "confirmations.domain_block.message": "{domain} alanının tamamını engellemek istediğinden gerçekten emin misin? Genellikle hedeflenen birkaç engelleme veya sessize alma yeterlidir ve tercih edilir. Bu alan adından gelen içeriği herhangi bir genel zaman çizelgesinde veya bildirimlerinde görmezsin. Bu alan adındaki takipçilerin kaldırılır.",
   "confirmations.logout.confirm": "Oturumu kapat",
-  "confirmations.logout.message": "Oturumu kapatmak istediğinizden emin misiniz?",
+  "confirmations.logout.message": "Oturumu kapatmak istediğinden emin misin?",
   "confirmations.mute.confirm": "Sessize al",
-  "confirmations.mute.explanation": "Bu onlardan gelen ve onlardan bahseden gönderileri gizleyecek, fakat yine de onların gönderilerinizi görmelerine ve sizi takip etmelerine izin verecektir.",
-  "confirmations.mute.message": "{name} kullanıcısını sessize almak istediğinizden emin misiniz?",
+  "confirmations.mute.explanation": "Bu, onlardan gelen ve bahseden gönderileri gizler. Ancak yine de gönderilerini görmelerine ve seni takip etmelerine izin verilir.",
+  "confirmations.mute.message": "{name} kullanıcısını sessize almak istediğinden emin misin?",
   "confirmations.redraft.confirm": "Sil ve yeniden taslak yap",
-  "confirmations.redraft.message": "Bu toot'u silmek ve yeniden taslak yapmak istediğinizden emin misiniz? Favoriler, boostlar kaybolacak ve orijinal gönderiye verilen yanıtlar sahipsiz kalacak.",
+  "confirmations.redraft.message": "Bu tootu silmek ve yeniden taslak yapmak istediğinden emin misin? Favoriler, boostlar kaybolur ve özgün gönderiye verilen yanıtlar sahipsiz kalır.",
   "confirmations.reply.confirm": "Yanıtla",
-  "confirmations.reply.message": "Şimdi yanıtlarken o an oluşturduğunuz mesajın üzerine yazılır. Devam etmek istediğinize emin misiniz?",
+  "confirmations.reply.message": "Şimdi yanıtlarken o an oluşturduğun mesajın üzerine yazılır. Devam etmek istediğine emin misin?",
   "confirmations.unfollow.confirm": "Takibi bırak",
-  "confirmations.unfollow.message": "{name} adlı kullanıcıyı takibi bırakmak istediğinizden emin misiniz?",
+  "confirmations.unfollow.message": "{name} adlı kullanıcıyı takibi bırakmak istediğinden emin misin?",
   "conversation.delete": "Sohbeti sil",
   "conversation.mark_as_read": "Okundu olarak işaretle",
   "conversation.open": "Sohbeti görüntüle",
@@ -151,22 +158,22 @@
   "emoji_button.symbols": "Semboller",
   "emoji_button.travel": "Seyahat ve Yerler",
   "empty_column.account_suspended": "Hesap askıya alındı",
-  "empty_column.account_timeline": "Burada hiç toot yok!",
+  "empty_column.account_timeline": "Burada hiç gönderi yok!",
   "empty_column.account_unavailable": "Profil kullanılamıyor",
-  "empty_column.blocks": "Henüz bir kullanıcıyı engellemediniz.",
-  "empty_column.bookmarked_statuses": "Henüz yer imine eklediğiniz toot yok. Yer imine eklendiğinde burada görünecek.",
+  "empty_column.blocks": "Henüz herhangi bir kullanıcıyı engellemedin.",
+  "empty_column.bookmarked_statuses": "Henüz yer imine eklediğin toot yok. Bir tanesi yer imine eklendiğinde burada görünür.",
   "empty_column.community": "Yerel zaman çizelgesi boş. Daha fazla eğlence için herkese açık bir gönderi paylaşın!",
-  "empty_column.direct": "Henüz direkt mesajınız yok. Bir tane gönderdiğinizde veya aldığınızda burada görünecektir.",
+  "empty_column.direct": "Henüz direkt mesajın yok. Bir tane gönderdiğinde veya aldığında burada görünür.",
   "empty_column.domain_blocks": "Henüz hiçbir gizli alan adı yok.",
-  "empty_column.favourited_statuses": "Hiç favori tootunuz yok. Favori olduğunda burada görünecek.",
-  "empty_column.favourites": "Kimse bu tootu favorilerine eklememiş. Biri eklediğinde burada görünecek.",
+  "empty_column.favourited_statuses": "Favori tootun yok. Favori tootun olduğunda burada görünür.",
+  "empty_column.favourites": "Kimse bu gönderiyi favorilerine eklememiş. Biri eklediğinde burada görünecek.",
   "empty_column.follow_recommendations": "Öyle görünüyor ki sizin için hiçbir öneri oluşturulamıyor. Tanıdığınız kişileri aramak için aramayı kullanabilir veya öne çıkanlara bakabilirsiniz.",
   "empty_column.follow_requests": "Hiç takip isteğiniz yok. Bir tane aldığınızda burada görünecek.",
   "empty_column.hashtag": "Henüz bu hashtag’e sahip hiçbir gönderi yok.",
-  "empty_column.home": "Henüz kimseyi takip etmiyorsunuz. {public} ziyaret edebilir veya arama kısmını kullanarak diğer kullanıcılarla iletişime geçebilirsiniz.",
+  "empty_column.home": "Ana zaman tünelin boş! Akışını doldurmak için daha fazla kişiyi takip et. {suggestions}",
   "empty_column.home.suggestions": "Bazı önerileri görün",
   "empty_column.list": "Bu listede henüz hiçbir şey yok.",
-  "empty_column.lists": "Henüz listeniz yok. Liste oluşturduğunuzda burada görünecek.",
+  "empty_column.lists": "Henüz listen yok. Liste oluşturduğunda burada görünür.",
   "empty_column.mutes": "Henüz bir kullanıcıyı sessize almadınız.",
   "empty_column.notifications": "Henüz bildiriminiz yok. Sohbete başlamak için başkalarıyla etkileşim kurun.",
   "empty_column.public": "Burada hiçbir şey yok! Herkese açık bir şeyler yazın veya burayı doldurmak için diğer sunuculardaki kullanıcıları takip edin",
@@ -210,14 +217,14 @@
   "intervals.full.minutes": "{number, plural, one {# dakika} other {# dakika}}",
   "keyboard_shortcuts.back": "geriye gitmek için",
   "keyboard_shortcuts.blocked": "engellenen kullanıcılar listesini açmak için",
-  "keyboard_shortcuts.boost": "boostlamak için",
+  "keyboard_shortcuts.boost": "Gönderiyi teşvik et",
   "keyboard_shortcuts.column": "sütunlardan birindeki duruma odaklanmak için",
   "keyboard_shortcuts.compose": "yazma alanına odaklanmak için",
   "keyboard_shortcuts.description": "Açıklama",
   "keyboard_shortcuts.direct": "direkt mesajlar sütununu açmak için",
   "keyboard_shortcuts.down": "listede aşağıya inmek için",
-  "keyboard_shortcuts.enter": "durumu açmak için",
-  "keyboard_shortcuts.favourite": "beğenmek için",
+  "keyboard_shortcuts.enter": "Gönderiyi aç",
+  "keyboard_shortcuts.favourite": "Gönderiyi beğen",
   "keyboard_shortcuts.favourites": "favoriler listesini açmak için",
   "keyboard_shortcuts.federated": "federe edilmiş zaman tünelini açmak için",
   "keyboard_shortcuts.heading": "Klavye kısayolları",
@@ -230,16 +237,16 @@
   "keyboard_shortcuts.my_profile": "profilinizi açmak için",
   "keyboard_shortcuts.notifications": "bildirimler sütununu açmak için",
   "keyboard_shortcuts.open_media": "medyayı açmak için",
-  "keyboard_shortcuts.pinned": "sabitlenmiş tootların listesini açmak için",
+  "keyboard_shortcuts.pinned": "Sabitlenmiş gönderilerin listesini aç",
   "keyboard_shortcuts.profile": "yazarın profilini açmak için",
-  "keyboard_shortcuts.reply": "yanıtlamak için",
+  "keyboard_shortcuts.reply": "Gönderiyi yanıtla",
   "keyboard_shortcuts.requests": "takip istekleri listesini açmak için",
   "keyboard_shortcuts.search": "aramaya odaklanmak için",
   "keyboard_shortcuts.spoilers": "CW alanını göstermek/gizlemek için",
   "keyboard_shortcuts.start": "\"başlarken\" sütununu açmak için",
   "keyboard_shortcuts.toggle_hidden": "CW'den önceki yazıyı göstermek/gizlemek için",
   "keyboard_shortcuts.toggle_sensitivity": "medyayı göstermek/gizlemek için",
-  "keyboard_shortcuts.toot": "yepyeni bir toot başlatmak için",
+  "keyboard_shortcuts.toot": "Yeni bir gönderi başlat",
   "keyboard_shortcuts.unfocus": "aramada bir gönderiye odaklanmamak için",
   "keyboard_shortcuts.up": "listede yukarıya çıkmak için",
   "lightbox.close": "Kapat",
@@ -272,7 +279,7 @@
   "navigation_bar.blocks": "Engellenen kullanıcılar",
   "navigation_bar.bookmarks": "Yer İmleri",
   "navigation_bar.community_timeline": "Yerel Zaman Tüneli",
-  "navigation_bar.compose": "Yeni toot oluştur",
+  "navigation_bar.compose": "Yeni gönderi yaz",
   "navigation_bar.direct": "Direkt Mesajlar",
   "navigation_bar.discover": "Keşfet",
   "navigation_bar.domain_blocks": "Engellenen alan adları",
@@ -287,17 +294,17 @@
   "navigation_bar.logout": "Oturumu kapat",
   "navigation_bar.mutes": "Sessize alınmış kullanıcılar",
   "navigation_bar.personal": "Kişisel",
-  "navigation_bar.pins": "Sabitlenmiş tootlar",
+  "navigation_bar.pins": "Sabitlenmiş gönderiler",
   "navigation_bar.preferences": "Tercihler",
   "navigation_bar.public_timeline": "Federe zaman tüneli",
   "navigation_bar.security": "Güvenlik",
-  "notification.favourite": "{name} tootunu beğendi",
+  "notification.favourite": "{name} gönderini beğendi",
   "notification.follow": "{name} seni takip etti",
   "notification.follow_request": "{name} size takip isteği gönderdi",
   "notification.mention": "{name} senden bahsetti",
   "notification.own_poll": "Anketiniz sona erdi",
   "notification.poll": "Oy verdiğiniz bir anket sona erdi",
-  "notification.reblog": "{name} tootunu boostladı",
+  "notification.reblog": "{name} gönderini teşvik etti",
   "notification.status": "{name} az önce gönderdi",
   "notifications.clear": "Bildirimleri temizle",
   "notifications.clear_confirmation": "Tüm bildirimlerinizi kalıcı olarak temizlemek ister misiniz?",
@@ -314,7 +321,7 @@
   "notifications.column_settings.reblog": "Boostlar:",
   "notifications.column_settings.show": "Sütunda göster",
   "notifications.column_settings.sound": "Ses çal",
-  "notifications.column_settings.status": "Yeni tootlar:",
+  "notifications.column_settings.status": "Yeni gönderiler:",
   "notifications.column_settings.unread_markers.category": "Okunmamış bildirim işaretleri",
   "notifications.filter.all": "Tümü",
   "notifications.filter.boosts": "Boostlar",
@@ -339,9 +346,10 @@
   "poll.total_votes": "{count, plural, one {# oy} other {# oy}}",
   "poll.vote": "Oy ver",
   "poll.voted": "Bu cevap için oy kullandınız",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Bir anket ekleyin",
   "poll_button.remove_poll": "Anketi kaldır",
-  "privacy.change": "Toot gizliliğini ayarlayın",
+  "privacy.change": "Gönderi gizliliğini değiştir",
   "privacy.direct.long": "Sadece bahsedilen kullanıcılar için görünür",
   "privacy.direct.short": "Direkt",
   "privacy.private.long": "Sadece takipçiler için görünür",
@@ -352,7 +360,7 @@
   "privacy.unlisted.short": "Listelenmemiş",
   "refresh": "Yenile",
   "regeneration_indicator.label": "Yükleniyor…",
-  "regeneration_indicator.sublabel": "Ana akışınız hazırlanıyor!",
+  "regeneration_indicator.sublabel": "Ana akışın hazırlanıyor!",
   "relative_time.days": "{number}g",
   "relative_time.hours": "{number}sa",
   "relative_time.just_now": "şimdi",
@@ -368,18 +376,18 @@
   "report.target": "{target} Bildiriliyor",
   "search.placeholder": "Ara",
   "search_popout.search_format": "Gelişmiş arama biçimi",
-  "search_popout.tips.full_text": "Basit metin yazdığınız, tercih ettiğiniz, boostladığınız veya bunlardan bahsettiğiniz tootların yanı sıra kullanıcı adlarını, görünen adları ve hashtag'leri eşleştiren tootları döndürür.",
+  "search_popout.tips.full_text": "Basit metin yazdığınız, beğendiğiniz, teşvik ettiğiniz veya söz edilen gönderilerin yanı sıra kullanıcı adlarını, görünen adları ve hashtag'leri eşleştiren gönderileri de döndürür.",
   "search_popout.tips.hashtag": "etiket",
-  "search_popout.tips.status": "toot",
+  "search_popout.tips.status": "gönderi",
   "search_popout.tips.text": "Basit metin, eşleşen görünen adları, kullanıcı adlarını ve hashtag'leri döndürür",
   "search_popout.tips.user": "kullanıcı",
   "search_results.accounts": "İnsanlar",
   "search_results.hashtags": "Etiketler",
-  "search_results.statuses": "Tootlar",
-  "search_results.statuses_fts_disabled": "Bu Mastodon sunucusunda toot içeriğine göre arama etkin değil.",
+  "search_results.statuses": "Gönderiler",
+  "search_results.statuses_fts_disabled": "Bu Mastodon sunucusunda gönderi içeriğine göre arama etkin değil.",
   "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuç}}",
   "status.admin_account": "@{name} için denetim arayüzünü açın",
-  "status.admin_status": "Denetim arayüzünde bu durumu açın",
+  "status.admin_status": "Denetim arayüzünde bu gönderiyi açın",
   "status.block": "@{name} adlı kişiyi engelle",
   "status.bookmark": "Yer imlerine ekle",
   "status.cancel_reblog_private": "Boostu geri al",
@@ -392,7 +400,7 @@
   "status.favourite": "Beğen",
   "status.filtered": "Filtrelenmiş",
   "status.load_more": "Daha fazlasını yükle",
-  "status.media_hidden": "Gizli görsel",
+  "status.media_hidden": "Medya gizli",
   "status.mention": "@{name} kişisinden bahset",
   "status.more": "Daha fazla",
   "status.mute": "@{name} kişisini sessize al",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "İşitme kaybı veya görme engeli olan kişiler için tarif edin",
   "upload_modal.analyzing_picture": "Resim analiz ediliyor…",
   "upload_modal.apply": "Uygula",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Resim seç",
   "upload_modal.description_placeholder": "Pijamalı hasta yağız şoföre çabucak güvendi",
   "upload_modal.detect_text": "Resimdeki metni algıla",
diff --git a/app/javascript/mastodon/locales/tt.json b/app/javascript/mastodon/locales/tt.json
index fa53a59db..456c5f72d 100644
--- a/app/javascript/mastodon/locales/tt.json
+++ b/app/javascript/mastodon/locales/tt.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Ой!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Бетерү",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Чыгу",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Куллан",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/ug.json b/app/javascript/mastodon/locales/ug.json
index c1eadb5a3..eca4765c4 100644
--- a/app/javascript/mastodon/locales/ug.json
+++ b/app/javascript/mastodon/locales/ug.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Are you sure you want to delete this status?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "Log out",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 5e21d00fc..808701957 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -47,11 +47,16 @@
   "account.unmute": "Зняти глушення з @{name}",
   "account.unmute_notifications": "Показувати сповіщення від @{name}",
   "account_note.placeholder": "Коментарі відсутні",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Спробуйте ще раз через {retry_time, time, medium}.",
   "alert.rate_limited.title": "Швидкість обмежена",
   "alert.unexpected.message": "Трапилась неочікувана помилка.",
   "alert.unexpected.title": "Ой!",
   "announcement.announcement": "Оголошення",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} в тиждень",
   "boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
   "bundle_column_error.body": "Щось пішло не так під час завантаження компоненту.",
@@ -99,9 +104,9 @@
   "compose_form.poll.switch_to_single": "Перемкнути у режим вибору однієї відповіді",
   "compose_form.publish": "Дмухнути",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.hide": "Позначити медіа як дражливе",
-  "compose_form.sensitive.marked": "Медіа відмічене як дражливе",
-  "compose_form.sensitive.unmarked": "Медіа не відмічене як дражливе",
+  "compose_form.sensitive.hide": "{count, plural, one {Позначити медіа делікатним} other {Позначити медіа делікатними}}",
+  "compose_form.sensitive.marked": "{count, plural, one {Медіа позначене делікатним} other {Медіа позначені делікатними}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {Медіа не позначене делікатним} other {Медіа не позначені делікатними}}",
   "compose_form.spoiler.marked": "Текст приховано під попередженням",
   "compose_form.spoiler.unmarked": "Текст видимий",
   "compose_form.spoiler_placeholder": "Напишіть своє попередження тут",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "Ви впевнені, що хочете видалити цей допис?",
   "confirmations.delete_list.confirm": "Видалити",
   "confirmations.delete_list.message": "Ви впевнені, що хочете видалити цей список назавжди?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Сховати весь домен",
   "confirmations.domain_block.message": "Ви точно, точно впевнені, що хочете заблокувати весь домен {domain}? У більшості випадків для нормальної роботи краще заблокувати/заглушити лише деяких користувачів. Ви не зможете бачити контент з цього домену у будь-яких стрічках або ваших сповіщеннях. Ваші підписники з цього домену будуть відписані від вас.",
   "confirmations.logout.confirm": "Вийти",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# голос} few {# голоси} many {# голосів} other {# голосів}}",
   "poll.vote": "Проголосувати",
   "poll.voted": "Ви голосували за цю відповідь",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Додати опитування",
   "poll_button.remove_poll": "Видалити опитування",
   "privacy.change": "Змінити видимість допису",
@@ -410,7 +418,7 @@
   "status.reply": "Відповісти",
   "status.replyAll": "Відповісти на ланцюжок",
   "status.report": "Поскаржитися на @{name}",
-  "status.sensitive_warning": "Дражливий зміст",
+  "status.sensitive_warning": "Делікатний зміст",
   "status.share": "Поділитися",
   "status.show_less": "Згорнути",
   "status.show_less_all": "Показувати менше для всіх",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Опишіть для людей із вадами слуху або зору",
   "upload_modal.analyzing_picture": "Аналізуємо малюнок…",
   "upload_modal.apply": "Застосувати",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Вибрати зображення",
   "upload_modal.description_placeholder": "Щурячий бугай із їжаком-харцизом в'ючись підписали ґешефт у єнах",
   "upload_modal.detect_text": "Виявити текст на малюнку",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index 4885056b7..55f6856d2 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -1,28 +1,28 @@
 {
-  "account.account_note_header": "Note",
+  "account.account_note_header": "نوٹ",
   "account.add_or_remove_from_list": "فہرست میں شامل یا برطرف کریں",
   "account.badges.bot": "روبوٹ",
-  "account.badges.group": "Group",
+  "account.badges.group": "گروپ",
   "account.block": "مسدود @{name}",
   "account.block_domain": "{domain} سے سب چھپائیں",
   "account.blocked": "مسدود کردہ",
-  "account.browse_more_on_origin_server": "Browse more on the original profile",
+  "account.browse_more_on_origin_server": "اصل پروفائل پر مزید براؤز کریں",
   "account.cancel_follow_request": "درخواستِ پیروی منسوخ کریں",
   "account.direct": "راست پیغام @{name}",
-  "account.disable_notifications": "Stop notifying me when @{name} posts",
+  "account.disable_notifications": "جب @{name} پوسٹ کرے تو مجھ مطلع نہ کریں",
   "account.domain_blocked": "پوشیدہ ڈومین",
   "account.edit_profile": "مشخص ترمیم کریں",
-  "account.enable_notifications": "Notify me when @{name} posts",
+  "account.enable_notifications": "جب @{name} پوسٹ کرے تو مجھ مطلع کریں",
   "account.endorse": "مشکص پر نمایاں کریں",
   "account.follow": "پیروی کریں",
   "account.followers": "پیروکار",
   "account.followers.empty": "\"ہنوز اس صارف کی کوئی پیروی نہیں کرتا\".",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
-  "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
+  "account.followers_counter": "{count, plural,one {{counter} پیروکار} other {{counter} پیروکار}}",
+  "account.following_counter": "{count, plural, one {{counter} پیروی کر رہے ہیں} other {{counter} پیروی کر رہے ہیں}}",
   "account.follows.empty": "\"یہ صارف ہنوز کسی کی پیروی نہیں کرتا ہے\".",
   "account.follows_you": "آپ کا پیروکار ہے",
   "account.hide_reblogs": "@{name} سے فروغ چھپائیں",
-  "account.joined": "Joined {date}",
+  "account.joined": "{date} شامل ہوئے",
   "account.last_status": "آخری فعال",
   "account.link_verified_on": "اس لنک کی ملکیت کی توثیق {date} پر کی گئی تھی",
   "account.locked_info": "اس اکاونٹ کا اخفائی ضابطہ مقفل ہے۔ صارف کی پیروی کون کر سکتا ہے اس کا جائزہ وہ خود لیتا ہے.",
@@ -47,11 +47,16 @@
   "account.unmute": "@{name} کو با آواز کریں",
   "account.unmute_notifications": "@{name} سے اطلاعات کو با آواز کریں",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "\"{retry_time, time, medium} کے بعد کوشش کریں\".",
-  "alert.rate_limited.title": "Rate limited",
+  "alert.rate_limited.title": "محدود شرح",
   "alert.unexpected.message": "ایک غیر متوقع سہو ہوا ہے.",
   "alert.unexpected.title": "ا رے!",
-  "announcement.announcement": "Announcement",
+  "announcement.announcement": "اعلان",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} فی ہفتہ",
   "boost_modal.combo": "آئیندہ یہ نہ دیکھنے کیلئے آپ {combo} دبا سکتے ہیں",
   "bundle_column_error.body": "اس عنصر کو برآمد کرتے وقت کچھ خرابی پیش آئی ہے.",
@@ -61,7 +66,7 @@
   "bundle_modal_error.message": "اس عنصر کو برآمد کرتے وقت کچھ خرابی پیش آئی ہے.",
   "bundle_modal_error.retry": "دوبارہ کوشش کریں",
   "column.blocks": "مسدود صارفین",
-  "column.bookmarks": "Bookmarks",
+  "column.bookmarks": "بُک مارکس",
   "column.community": "مقامی زمانی جدول",
   "column.direct": "راست پیغام",
   "column.directory": "مشخصات کا مطالعہ کریں",
@@ -82,9 +87,9 @@
   "column_header.show_settings": "ترتیبات دکھائیں",
   "column_header.unpin": "رہا کریں",
   "column_subheading.settings": "ترتیبات",
-  "community.column_settings.local_only": "Local only",
+  "community.column_settings.local_only": "صرف مقامی",
   "community.column_settings.media_only": "وسائل فقط",
-  "community.column_settings.remote_only": "Remote only",
+  "community.column_settings.remote_only": "صرف خارجی",
   "compose_form.direct_message_warning": "یہ ٹوٹ صرف مذکورہ صارفین کو بھیجا جائے گا.",
   "compose_form.direct_message_warning_learn_more": "مزید جانیں",
   "compose_form.hashtag_warning": "چونکہ یہ ٹوٹ غیر مندرجہ ہے لہذا یہ کسی بھی ہیش ٹیگ کے تحت درج نہیں کیا جائے گا. ہیش ٹیگ کے تحت صرف \nعمومی ٹوٹ تلاش کئے جا سکتے ہیں.",
@@ -95,54 +100,56 @@
   "compose_form.poll.duration": "مدتِ رائے",
   "compose_form.poll.option_placeholder": "انتخاب {number}",
   "compose_form.poll.remove_option": "یہ انتخاب ہٹا دیں",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "متعدد انتخاب کی اجازت دینے کے لیے پول تبدیل کریں",
+  "compose_form.poll.switch_to_single": "کسی ایک انتخاب کے لیے پول تبدیل کریں",
   "compose_form.publish": "ٹوٹ",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "وسائل کو حساس نشاندہ کریں",
   "compose_form.sensitive.marked": "وسائل حساس نشاندہ ہے",
-  "compose_form.sensitive.unmarked": "{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}",
+  "compose_form.sensitive.unmarked": "{count, plural, one {میڈیا کو حساس کے طور پر نشان زد نہیں کیا گیا ہے} other {میڈیا کو حساس کے طور پر نشان زد نہیں کیا گیا ہے}}",
   "compose_form.spoiler.marked": "Text is hidden behind warning",
   "compose_form.spoiler.unmarked": "Text is not hidden",
-  "compose_form.spoiler_placeholder": "Write your warning here",
-  "confirmation_modal.cancel": "Cancel",
-  "confirmations.block.block_and_report": "Block & Report",
-  "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
-  "confirmations.delete.confirm": "Delete",
+  "compose_form.spoiler_placeholder": "اپنی وارننگ یہاں لکھیں",
+  "confirmation_modal.cancel": "منسوخ",
+  "confirmations.block.block_and_report": "شکایت کریں اور بلاک کریں",
+  "confirmations.block.confirm": "بلاک",
+  "confirmations.block.message": "کیا واقعی آپ {name} کو بلاک کرنا چاہتے ہیں؟",
+  "confirmations.delete.confirm": "ڈیلیٹ",
   "confirmations.delete.message": "Are you sure you want to delete this status?",
-  "confirmations.delete_list.confirm": "Delete",
-  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.delete_list.confirm": "ڈیلیٹ",
+  "confirmations.delete_list.message": "کیا آپ واقعی اس فہرست کو مستقل طور پر ڈیلیٹ کرنا چاہتے ہیں؟",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
-  "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
-  "confirmations.logout.confirm": "Log out",
-  "confirmations.logout.message": "Are you sure you want to log out?",
-  "confirmations.mute.confirm": "Mute",
-  "confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.redraft.confirm": "Delete & redraft",
+  "confirmations.domain_block.message": "کیا آپ واقعی، واقعی یقین رکھتے ہیں کہ آپ پورے {domain} کو بلاک کرنا چاہتے ہیں؟ زیادہ تر معاملات میں چند ٹارگٹیڈ بلاکس یا خاموش کرنا کافی اور افضل ہیں۔ آپ اس ڈومین کا مواد کسی بھی عوامی ٹائم لائنز یا اپنی اطلاعات میں نہیں دیکھیں گے۔ اس ڈومین سے آپ کے پیروکاروں کو ہٹا دیا جائے گا۔",
+  "confirmations.logout.confirm": "لاگ آؤٹ",
+  "confirmations.logout.message": "کیا واقعی آپ لاگ آؤٹ ہونا چاہتے ہیں؟",
+  "confirmations.mute.confirm": "خاموش",
+  "confirmations.mute.explanation": "یہ ان سے پوسٹس اور ان کا تذکرہ کرنے والی پوسٹس کو چھپائے گا، لیکن یہ پھر بھی انہیں آپ کی پوسٹس دیکھنے اور آپ کی پیروی کرنے کی اجازت دے گا۔",
+  "confirmations.mute.message": "کیا واقعی آپ {name} کو خاموش کرنا چاہتے ہیں؟",
+  "confirmations.redraft.confirm": "ڈیلیٹ کریں اور دوبارہ ڈرافٹ کریں",
   "confirmations.redraft.message": "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.",
-  "confirmations.reply.confirm": "Reply",
-  "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "conversation.delete": "Delete conversation",
-  "conversation.mark_as_read": "Mark as read",
-  "conversation.open": "View conversation",
-  "conversation.with": "With {names}",
-  "directory.federated": "From known fediverse",
-  "directory.local": "From {domain} only",
-  "directory.new_arrivals": "New arrivals",
-  "directory.recently_active": "Recently active",
+  "confirmations.reply.confirm": "جواب دیں",
+  "confirmations.reply.message": "ابھی جواب دینے سے وہ پیغام اوور رائٹ ہو جائے گا جو آپ فی الحال لکھ رہے ہیں۔ کیا آپ واقعی آگے بڑھنا چاہتے ہیں؟",
+  "confirmations.unfollow.confirm": "پیروی ترک کریں",
+  "confirmations.unfollow.message": "کیا واقعی آپ {name} کی پیروی ترک کرنا چاہتے ہیں؟",
+  "conversation.delete": "گفتگو کو ڈیلیٹ کریں",
+  "conversation.mark_as_read": "بطور پڑھا ہوا دکھائیں",
+  "conversation.open": "گفتگو دیکھیں",
+  "conversation.with": "{names} کے ساتھ",
+  "directory.federated": "معروف فیڈی ورس سے",
+  "directory.local": "صرف {domain} سے",
+  "directory.new_arrivals": "نئے آنے والے",
+  "directory.recently_active": "حال میں میں ایکٹیو",
   "embed.instructions": "Embed this status on your website by copying the code below.",
-  "embed.preview": "Here is what it will look like:",
-  "emoji_button.activity": "Activity",
-  "emoji_button.custom": "Custom",
-  "emoji_button.flags": "Flags",
-  "emoji_button.food": "Food & Drink",
-  "emoji_button.label": "Insert emoji",
-  "emoji_button.nature": "Nature",
-  "emoji_button.not_found": "No matching emojis found",
+  "embed.preview": "یہ اس طرح نظر آئے گا:",
+  "emoji_button.activity": "سرگرمی",
+  "emoji_button.custom": "حسب منشا",
+  "emoji_button.flags": "پرچم",
+  "emoji_button.food": "عذا و مشروب",
+  "emoji_button.label": "ایموجی داخل کریں",
+  "emoji_button.nature": "قدرت",
+  "emoji_button.not_found": "کوئی مماثل ایموجیز نہیں ملے",
   "emoji_button.objects": "Objects",
   "emoji_button.people": "People",
   "emoji_button.recent": "Frequently used",
@@ -267,43 +274,43 @@
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.duration": "Duration",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
-  "mute_modal.indefinite": "Indefinite",
-  "navigation_bar.apps": "Mobile apps",
-  "navigation_bar.blocks": "Blocked users",
-  "navigation_bar.bookmarks": "Bookmarks",
-  "navigation_bar.community_timeline": "Local timeline",
+  "mute_modal.indefinite": "غیر معینہ",
+  "navigation_bar.apps": "موبائل ایپس",
+  "navigation_bar.blocks": "مسدود صارفین",
+  "navigation_bar.bookmarks": "بُک مارکس",
+  "navigation_bar.community_timeline": "مقامی ٹائم لائن",
   "navigation_bar.compose": "Compose new toot",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.discover": "Discover",
+  "navigation_bar.direct": "براہ راست پیغامات",
+  "navigation_bar.discover": "دریافت کریں",
   "navigation_bar.domain_blocks": "Hidden domains",
-  "navigation_bar.edit_profile": "Edit profile",
-  "navigation_bar.favourites": "Favourites",
-  "navigation_bar.filters": "Muted words",
-  "navigation_bar.follow_requests": "Follow requests",
-  "navigation_bar.follows_and_followers": "Follows and followers",
-  "navigation_bar.info": "About this server",
-  "navigation_bar.keyboard_shortcuts": "Hotkeys",
-  "navigation_bar.lists": "Lists",
-  "navigation_bar.logout": "Logout",
-  "navigation_bar.mutes": "Muted users",
-  "navigation_bar.personal": "Personal",
+  "navigation_bar.edit_profile": "پروفائل میں ترمیم کریں",
+  "navigation_bar.favourites": "پسندیدہ",
+  "navigation_bar.filters": "خاموش کردہ الفاظ",
+  "navigation_bar.follow_requests": "پیروی کی درخواستیں",
+  "navigation_bar.follows_and_followers": "پیروی کردہ اور پیروکار",
+  "navigation_bar.info": "اس سرور کے بارے میں",
+  "navigation_bar.keyboard_shortcuts": "ہوٹ کیز",
+  "navigation_bar.lists": "فہرستیں",
+  "navigation_bar.logout": "لاگ آؤٹ",
+  "navigation_bar.mutes": "خاموش کردہ صارفین",
+  "navigation_bar.personal": "ذاتی",
   "navigation_bar.pins": "Pinned toots",
-  "navigation_bar.preferences": "Preferences",
-  "navigation_bar.public_timeline": "Federated timeline",
-  "navigation_bar.security": "Security",
+  "navigation_bar.preferences": "ترجیحات",
+  "navigation_bar.public_timeline": "وفاقی ٹائم لائن",
+  "navigation_bar.security": "سیکورٹی",
   "notification.favourite": "{name} favourited your status",
-  "notification.follow": "{name} followed you",
-  "notification.follow_request": "{name} has requested to follow you",
-  "notification.mention": "{name} mentioned you",
-  "notification.own_poll": "Your poll has ended",
-  "notification.poll": "A poll you have voted in has ended",
+  "notification.follow": "{name} آپ کی پیروی کی",
+  "notification.follow_request": "{name} نے آپ کی پیروی کی درخواست کی",
+  "notification.mention": "{name} نے آپ کا تذکرہ کیا",
+  "notification.own_poll": "آپ کا پول ختم ہو گیا ہے",
+  "notification.poll": "آپ کا ووٹ دیا گیا ایک پول ختم ہو گیا ہے",
   "notification.reblog": "{name} boosted your status",
-  "notification.status": "{name} just posted",
-  "notifications.clear": "Clear notifications",
-  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
-  "notifications.column_settings.alert": "Desktop notifications",
-  "notifications.column_settings.favourite": "Favourites:",
-  "notifications.column_settings.filter_bar.advanced": "Display all categories",
+  "notification.status": "{name} نے ابھی ابھی پوسٹ کیا",
+  "notifications.clear": "اطلاعات ہٹائیں",
+  "notifications.clear_confirmation": "کیا آپ واقعی اپنی تمام اطلاعات کو صاف کرنا چاہتے ہیں؟",
+  "notifications.column_settings.alert": "ڈیسک ٹاپ اطلاعات",
+  "notifications.column_settings.favourite": "پسندیدہ:",
+  "notifications.column_settings.filter_bar.advanced": "تمام زمرے دکھائیں",
   "notifications.column_settings.filter_bar.category": "Quick filter bar",
   "notifications.column_settings.filter_bar.show": "Show",
   "notifications.column_settings.follow": "New followers:",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Choose image",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json
index 26ee75cf0..9922707be 100644
--- a/app/javascript/mastodon/locales/vi.json
+++ b/app/javascript/mastodon/locales/vi.json
@@ -28,7 +28,7 @@
   "account.locked_info": "Đây là tài khoản riêng tư. Họ sẽ tự mình xét duyệt các yêu cầu theo dõi.",
   "account.media": "Media",
   "account.mention": "Nhắc đến @{name}",
-  "account.moved_to": "{name} đã dời sang:",
+  "account.moved_to": "{name} đã đổi thành:",
   "account.mute": "Ẩn @{name}",
   "account.mute_notifications": "Tắt thông báo từ @{name}",
   "account.muted": "Đã ẩn",
@@ -45,13 +45,18 @@
   "account.unendorse": "Ngưng tôn vinh người này",
   "account.unfollow": "Ngưng theo dõi",
   "account.unmute": "Bỏ ẩn @{name}",
-  "account.unmute_notifications": "Hiển lại thông báo từ @{name}",
+  "account.unmute_notifications": "Mở lại thông báo từ @{name}",
   "account_note.placeholder": "Nhấn để thêm",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Vui lòng thử lại sau {retry_time, time, medium}.",
   "alert.rate_limited.title": "Vượt giới hạn",
   "alert.unexpected.message": "Đã xảy ra lỗi không mong muốn.",
   "alert.unexpected.title": "Ốiii!",
   "announcement.announcement": "Thông báo chung",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} mỗi tuần",
   "boost_modal.combo": "Nhấn {combo} để chia sẻ nhanh hơn",
   "bundle_column_error.body": "Đã có lỗi xảy ra trong khi tải nội dung này.",
@@ -88,7 +93,7 @@
   "compose_form.direct_message_warning": "Tút này sẽ chỉ gửi cho người được nhắc đến.",
   "compose_form.direct_message_warning_learn_more": "Tìm hiểu thêm",
   "compose_form.hashtag_warning": "Tút này sẽ không xuất hiện công khai. Chỉ những tút công khai mới có thể được tìm kiếm thông qua hashtag.",
-  "compose_form.lock_disclaimer": "Tài khoản của bạn không {locked}. Bất cứ ai cũng có thể theo dõi bạn và xem bài viết của bạn dành riêng cho người theo dõi.",
+  "compose_form.lock_disclaimer": "Tài khoản của bạn không {locked}. Bất cứ ai cũng có thể theo dõi và xem tút riêng tư của bạn.",
   "compose_form.lock_disclaimer.lock": "khóa",
   "compose_form.placeholder": "Bạn đang nghĩ gì?",
   "compose_form.poll.add_option": "Thêm lựa chọn",
@@ -110,22 +115,24 @@
   "confirmations.block.confirm": "Chặn",
   "confirmations.block.message": "Bạn có thật sự muốn chặn {name}?",
   "confirmations.delete.confirm": "Xóa bỏ",
-  "confirmations.delete.message": "Bạn có chắc chắn muốn xóa tút này?",
+  "confirmations.delete.message": "Bạn \bthật sự muốn xóa tút này?",
   "confirmations.delete_list.confirm": "Xóa bỏ",
-  "confirmations.delete_list.message": "Bạn có chắc chắn muốn xóa vĩnh viễn danh sách này?",
+  "confirmations.delete_list.message": "Bạn thật sự muốn xóa vĩnh viễn danh sách này?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Ẩn toàn bộ máy chủ",
-  "confirmations.domain_block.message": "Bạn có chắc chắn rằng muốn ẩn toàn bộ nội dung từ {domain}? Sẽ hợp lý hơn nếu bạn chỉ chặn hoặc ẩn một vài tài khoản cụ thể. Ẩn toàn bộ nội dung từ máy chủ sẽ khiến bạn không còn thấy nội dung từ máy chủ đó ở bất kỳ nơi nào, kể cả thông báo. Người quan tâm bạn từ máy chủ đó cũng sẽ bị xóa luôn.",
+  "confirmations.domain_block.message": "Bạn thật sự muốn ẩn toàn bộ nội dung từ {domain}? Sẽ hợp lý hơn nếu bạn chỉ chặn hoặc ẩn một vài tài khoản cụ thể. Ẩn toàn bộ nội dung từ máy chủ sẽ khiến bạn không còn thấy nội dung từ máy chủ đó ở bất kỳ nơi nào, kể cả thông báo. Người quan tâm bạn từ máy chủ đó cũng sẽ bị xóa luôn.",
   "confirmations.logout.confirm": "Đăng xuất",
   "confirmations.logout.message": "Bạn có thật sự muốn thoát?",
   "confirmations.mute.confirm": "Ẩn",
   "confirmations.mute.explanation": "Điều này sẽ khiến tút của họ và những tút có nhắc đến họ bị ẩn, tuy nhiên họ vẫn có thể xem tút của bạn và theo dõi bạn.",
-  "confirmations.mute.message": "Bạn có chắc chắn muốn ẩn {name}?",
+  "confirmations.mute.message": "Bạn thật sự muốn ẩn {name}?",
   "confirmations.redraft.confirm": "Xóa & viết lại",
-  "confirmations.redraft.message": "Bạn có chắc chắn muốn xóa tút và viết lại? Điều này sẽ xóa mất những lượt thích và chia sẻ của tút, cũng như những trả lời sẽ không còn nội dung gốc.",
+  "confirmations.redraft.message": "Bạn thật sự muốn xóa tút và viết lại? Điều này sẽ xóa mất những lượt thích và chia sẻ của tút, cũng như những trả lời sẽ không còn nội dung gốc.",
   "confirmations.reply.confirm": "Trả lời",
   "confirmations.reply.message": "Nội dung bạn đang soạn thảo sẽ bị ghi đè, bạn có tiếp tục?",
   "confirmations.unfollow.confirm": "Ngưng theo dõi",
-  "confirmations.unfollow.message": "Bạn có chắc chắn muốn ngưng theo dõi {name}?",
+  "confirmations.unfollow.message": "Bạn thật sự muốn ngưng theo dõi {name}?",
   "conversation.delete": "Xóa tin nhắn này",
   "conversation.mark_as_read": "Đánh dấu là đã đọc",
   "conversation.open": "Xem toàn bộ tin nhắn",
@@ -142,7 +149,7 @@
   "emoji_button.food": "Ăn uống",
   "emoji_button.label": "Chèn emoji",
   "emoji_button.nature": "Thiên nhiên",
-  "emoji_button.not_found": "Không tìm thấy emoji! (°□°)",
+  "emoji_button.not_found": "Không tìm thấy emoji",
   "emoji_button.objects": "Đồ vật",
   "emoji_button.people": "Mặt cười",
   "emoji_button.recent": "Thường dùng",
@@ -300,7 +307,7 @@
   "notification.reblog": "{name} chia sẻ tút của bạn",
   "notification.status": "{name} vừa đăng",
   "notifications.clear": "Xóa hết thông báo",
-  "notifications.clear_confirmation": "Bạn có chắc chắn muốn xóa vĩnh viễn tất cả thông báo của mình?",
+  "notifications.clear_confirmation": "Bạn thật sự muốn xóa vĩnh viễn tất cả thông báo của mình?",
   "notifications.column_settings.alert": "Thông báo trên máy tính",
   "notifications.column_settings.favourite": "Lượt thích:",
   "notifications.column_settings.filter_bar.advanced": "Toàn bộ",
@@ -339,24 +346,25 @@
   "poll.total_votes": "{count, plural, other {# người bình chọn}}",
   "poll.vote": "Bình chọn",
   "poll.voted": "Bạn đã bình chọn rồi",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "Tạo bình chọn",
   "poll_button.remove_poll": "Hủy cuộc bình chọn",
   "privacy.change": "Thay đổi quyền riêng tư",
-  "privacy.direct.long": "Chỉ người được nhắc đến mới thấy",
+  "privacy.direct.long": "Gửi trực tiếp cho người được nhắc đến",
   "privacy.direct.short": "Tin nhắn",
-  "privacy.private.long": "Chỉ dành cho người theo dõi",
-  "privacy.private.short": "Người theo dõi",
+  "privacy.private.long": "Dành riêng cho người theo dõi",
+  "privacy.private.short": "Riêng tư",
   "privacy.public.long": "Hiện trên bảng tin máy chủ",
   "privacy.public.short": "Công khai",
   "privacy.unlisted.long": "Không hiện trên bảng tin máy chủ",
-  "privacy.unlisted.short": "Riêng tư",
+  "privacy.unlisted.short": "Hạn chế",
   "refresh": "Làm mới",
   "regeneration_indicator.label": "Đang tải…",
   "regeneration_indicator.sublabel": "Bảng tin của bạn đang được cập nhật!",
-  "relative_time.days": "{number}d",
+  "relative_time.days": "{number} ngày",
   "relative_time.hours": "{number} giờ",
   "relative_time.just_now": "vừa xong",
-  "relative_time.minutes": "{number}m",
+  "relative_time.minutes": "{number} phút",
   "relative_time.seconds": "{number}s",
   "relative_time.today": "hôm nay",
   "reply_indicator.cancel": "Hủy bỏ",
@@ -402,7 +410,7 @@
   "status.pinned": "Tút đã ghim",
   "status.read_more": "Đọc tiếp",
   "status.reblog": "Chia sẻ",
-  "status.reblog_private": "Chia sẻ với người có thể xem",
+  "status.reblog_private": "Chia sẻ (Riêng tư)",
   "status.reblogged_by": "{name} chia sẻ",
   "status.reblogs.empty": "Tút này chưa có ai chia sẻ. Nếu có, nó sẽ hiển thị ở đây.",
   "status.redraft": "Xóa và viết lại",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Mô tả cho người mất thị lực hoặc không thể nghe",
   "upload_modal.analyzing_picture": "Phân tích hình ảnh",
   "upload_modal.apply": "Áp dụng",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "Chọn ảnh",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Trích văn bản từ trong ảnh",
diff --git a/app/javascript/mastodon/locales/whitelist_kmr.json b/app/javascript/mastodon/locales/whitelist_kmr.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_kmr.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/zgh.json b/app/javascript/mastodon/locales/zgh.json
index fdbc1c210..22f6823fe 100644
--- a/app/javascript/mastodon/locales/zgh.json
+++ b/app/javascript/mastodon/locales/zgh.json
@@ -47,11 +47,16 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account_note.placeholder": "Click to add a note",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "announcement.announcement": "Announcement",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} ⵙ ⵉⵎⴰⵍⴰⵙⵙ",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "ⵉⵙ ⵏⵉⵜ ⵜⵅⵙⴷ ⴰⴷ ⵜⴽⴽⵙⴷ ⵜⴰⵥⵕⵉⴳⵜ ⴰ?",
   "confirmations.delete_list.confirm": "ⴽⴽⵙ",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
   "confirmations.logout.confirm": "ⴼⴼⵖ",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# ⵓⵙⵜⵜⴰⵢ} other {# ⵉⵙⵜⵜⴰⵢⵏ}}",
   "poll.vote": "Vote",
   "poll.voted": "You voted for this answer",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "ⵔⵏⵓ ⵢⴰⵏ ⵢⵉⴷⵣ",
   "poll_button.remove_poll": "ⵙⵙⵉⵜⵢ ⵉⴷⵣ",
   "privacy.change": "Adjust status privacy",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "ⴷⵖⵔ ⵜⴰⵡⵍⴰⴼⵜ",
   "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
   "upload_modal.detect_text": "Detect text from picture",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 986ebfbdf..286d54fb8 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -4,13 +4,13 @@
   "account.badges.bot": "机器人",
   "account.badges.group": "群组",
   "account.block": "屏蔽 @{name}",
-  "account.block_domain": "隐藏来自 {domain} 的内容",
+  "account.block_domain": "屏蔽 {domain} 实例",
   "account.blocked": "已屏蔽",
   "account.browse_more_on_origin_server": "在原始个人资料页面上浏览详情",
   "account.cancel_follow_request": "取消关注请求",
   "account.direct": "发送私信给 @{name}",
   "account.disable_notifications": "当 @{name} 发嘟时不要通知我",
-  "account.domain_blocked": "网站已屏蔽",
+  "account.domain_blocked": "域名已屏蔽",
   "account.edit_profile": "修改个人资料",
   "account.enable_notifications": "当 @{name} 发嘟时通知我",
   "account.endorse": "在个人资料中推荐此用户",
@@ -41,17 +41,22 @@
   "account.show_reblogs": "显示来自 @{name} 的转嘟",
   "account.statuses_counter": "{counter} 条嘟文",
   "account.unblock": "解除屏蔽 @{name}",
-  "account.unblock_domain": "不再隐藏来自 {domain} 的内容",
+  "account.unblock_domain": "不再屏蔽 {domain} 实例",
   "account.unendorse": "不在个人资料中推荐此用户",
   "account.unfollow": "取消关注",
   "account.unmute": "不再隐藏 @{name}",
   "account.unmute_notifications": "不再隐藏来自 @{name} 的通知",
   "account_note.placeholder": "点击添加备注",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "请在{retry_time, time, medium}后重试。",
   "alert.rate_limited.title": "频率受限",
   "alert.unexpected.message": "发生了意外错误。",
   "alert.unexpected.title": "哎呀!",
   "announcement.announcement": "公告",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "每星期 {count} 条",
   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
   "bundle_column_error.body": "载入这个组件时发生了错误。",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "你确定要删除这条嘟文吗?",
   "confirmations.delete_list.confirm": "删除",
   "confirmations.delete_list.message": "你确定要永久删除这个列表吗?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "隐藏整个网站的内容",
   "confirmations.domain_block.message": "你真的确定要屏蔽所有来自 {domain} 的内容吗?多数情况下,屏蔽或隐藏几个特定的用户就已经足够了。来自该网站的内容将不再出现在你的任何公共时间轴或通知列表里。来自该网站的关注者将会被移除。",
   "confirmations.logout.confirm": "登出",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count} 票",
   "poll.vote": "投票",
   "poll.voted": "你已经对这个答案投过票了",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "发起投票",
   "poll_button.remove_poll": "移除投票",
   "privacy.change": "设置嘟文的可见范围",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "为听障人士和视障人士添加文字描述",
   "upload_modal.analyzing_picture": "分析图片…",
   "upload_modal.apply": "应用",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "选择图像",
   "upload_modal.description_placeholder": "天地玄黄 宇宙洪荒 日月盈仄 辰宿列张",
   "upload_modal.detect_text": "从图片中检测文本",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 68e99886f..e93c81f94 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -14,7 +14,7 @@
   "account.edit_profile": "修改個人資料",
   "account.enable_notifications": "如果 @{name} 發文請通知我",
   "account.endorse": "在個人資料頁推薦對方",
-  "account.follow": "正在關注",
+  "account.follow": "關注",
   "account.followers": "關注者",
   "account.followers.empty": "尚未有人關注這位使用者。",
   "account.followers_counter": "有 {count, plural,one {{counter} 個} other {{counter} 個}}關注者",
@@ -22,7 +22,7 @@
   "account.follows.empty": "這位使用者尚未關注任何人。",
   "account.follows_you": "關注你",
   "account.hide_reblogs": "隱藏 @{name} 的轉推",
-  "account.joined": "Joined {date}",
+  "account.joined": "於 {date} 加入",
   "account.last_status": "上次活躍時間",
   "account.link_verified_on": "此連結的所有權已在 {date} 檢查過",
   "account.locked_info": "這位使用者將私隱設定為「不公開」,會手動審批誰能關注他/她。",
@@ -47,11 +47,16 @@
   "account.unmute": "取消 @{name} 的靜音",
   "account.unmute_notifications": "取消來自 @{name} 通知的靜音",
   "account_note.placeholder": "按此添加備注",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "請在 {retry_time, time, medium} 後重試",
   "alert.rate_limited.title": "已限速",
   "alert.unexpected.message": "發生不可預期的錯誤。",
   "alert.unexpected.title": "噢!",
   "announcement.announcement": "公告",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} / 週",
   "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},",
   "bundle_column_error.body": "加載本組件出錯。",
@@ -113,6 +118,8 @@
   "confirmations.delete.message": "你確定要刪除這文章嗎?",
   "confirmations.delete_list.confirm": "刪除",
   "confirmations.delete_list.message": "你確定要永久刪除這列表嗎?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "封鎖整個網站",
   "confirmations.domain_block.message": "你真的真的確定要封鎖整個 {domain} ?多數情況下,封鎖或靜音幾個特定目標就已經有效,也是比較建議的做法。若然封鎖全站,你將不會再在這裏看到該站的內容和通知。來自該站的關注者亦會被移除。",
   "confirmations.logout.confirm": "登出",
@@ -160,11 +167,11 @@
   "empty_column.domain_blocks": "尚未隱藏任何網域。",
   "empty_column.favourited_statuses": "你還沒收藏任何文章。這裡將會顯示你收藏的嘟文。",
   "empty_column.favourites": "還沒有人收藏這則文章。這裡將會顯示被收藏的嘟文。",
-  "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
+  "empty_column.follow_recommendations": "似乎未能替您產生任何建議。您可以試著搜尋您知道的帳戶或者探索熱門主題標籤",
   "empty_column.follow_requests": "您尚未收到任何關注請求。這裡將會顯示收到的關注請求。",
   "empty_column.hashtag": "這個標籤暫時未有內容。",
   "empty_column.home": "你還沒有關注任何使用者。快看看{public},向其他使用者搭訕吧。",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home.suggestions": "檢視部份建議",
   "empty_column.list": "這個列表暫時未有內容。",
   "empty_column.lists": "你還沒有建立任何名單。這裡將會顯示你所建立的名單。",
   "empty_column.mutes": "你尚未靜音任何使用者。",
@@ -176,9 +183,9 @@
   "error.unexpected_crash.next_steps_addons": "請嘗試停止使用這些附加元件然後重新載入頁面。如果問題沒有解決,你仍然可以使用不同的瀏覽器或 Mastodon 應用程式來檢視。",
   "errors.unexpected_crash.copy_stacktrace": "複製 stacktrace 到剪貼簿",
   "errors.unexpected_crash.report_issue": "舉報問題",
-  "follow_recommendations.done": "Done",
-  "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
-  "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
+  "follow_recommendations.done": "完成",
+  "follow_recommendations.heading": "跟隨人們以看到來自他們的嘟文!這裡有些建議。",
+  "follow_recommendations.lead": "您跟隨對象知嘟文將會以時間順序顯示於您的 home feed 上。別擔心犯下錯誤,您隨時可以取消跟隨人們!",
   "follow_request.authorize": "批准",
   "follow_request.reject": "拒絕",
   "follow_requests.unlocked_explanation": "即使您的帳戶未上鎖,{domain} 的工作人員認為您可能想手動審核來自這些帳戶的關注請求。",
@@ -315,7 +322,7 @@
   "notifications.column_settings.show": "在通知欄顯示",
   "notifications.column_settings.sound": "播放音效",
   "notifications.column_settings.status": "新的文章",
-  "notifications.column_settings.unread_markers.category": "Unread notification markers",
+  "notifications.column_settings.unread_markers.category": "未讀通知標記",
   "notifications.filter.all": "全部",
   "notifications.filter.boosts": "轉推",
   "notifications.filter.favourites": "最愛",
@@ -339,6 +346,7 @@
   "poll.total_votes": "{count, plural, one {# 票} other {# 票}}",
   "poll.vote": "投票",
   "poll.voted": "你已投票給這答案",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "建立投票",
   "poll_button.remove_poll": "移除投票",
   "privacy.change": "調整私隱設定",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "簡單描述給聽障或視障人士",
   "upload_modal.analyzing_picture": "正在分析圖片…",
   "upload_modal.apply": "套用",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "選擇圖片",
   "upload_modal.description_placeholder": "一隻敏捷的狐狸,輕巧地跳過那隻懶洋洋的狗",
   "upload_modal.detect_text": "從圖片偵測文字",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 1e9527cb8..7fdd156ea 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -7,25 +7,25 @@
   "account.block_domain": "封鎖來自 {domain} 網域的所有內容",
   "account.blocked": "已封鎖",
   "account.browse_more_on_origin_server": "在該伺服器的個人檔案頁上瀏覽更多",
-  "account.cancel_follow_request": "取消關注請求",
+  "account.cancel_follow_request": "取消跟隨請求",
   "account.direct": "傳私訊給 @{name}",
   "account.disable_notifications": "取消來自 @{name} 嘟文的通知",
   "account.domain_blocked": "已封鎖網域",
   "account.edit_profile": "編輯個人資料",
   "account.enable_notifications": "當 @{name} 嘟文時通知我",
   "account.endorse": "在個人資料推薦對方",
-  "account.follow": "關注",
-  "account.followers": "關注者",
-  "account.followers.empty": "尚未有人關注這位使用者。",
-  "account.followers_counter": "被 {count, plural,one {{counter} 人}other {{counter} 人}}關注",
-  "account.following_counter": "正在關注 {count, plural,one {{counter}}other {{counter} 人}}",
-  "account.follows.empty": "這位使用者尚未關注任何人。",
-  "account.follows_you": "關注了您",
+  "account.follow": "跟隨",
+  "account.followers": "跟隨者",
+  "account.followers.empty": "尚未有人跟隨這位使用者。",
+  "account.followers_counter": "被 {count, plural,one {{counter} 人}other {{counter} 人}} 跟隨",
+  "account.following_counter": "正在跟隨 {count, plural,one {{counter}}other {{counter} 人}}",
+  "account.follows.empty": "這位使用者尚未跟隨任何人。",
+  "account.follows_you": "跟隨了您",
   "account.hide_reblogs": "隱藏來自 @{name} 的轉嘟",
   "account.joined": "加入於 {date}",
   "account.last_status": "上次活躍時間",
   "account.link_verified_on": "已在 {date} 檢查此連結的擁有者權限",
-  "account.locked_info": "此帳戶的隱私狀態被設為鎖定。該擁有者會手動審核能關注此帳戶的人。",
+  "account.locked_info": "此帳戶的隱私狀態被設為鎖定。該擁有者會手動審核能跟隨此帳戶的人。",
   "account.media": "媒體",
   "account.mention": "提及 @{name}",
   "account.moved_to": "{name} 已遷移至:",
@@ -36,22 +36,27 @@
   "account.posts": "嘟文",
   "account.posts_with_replies": "嘟文與回覆",
   "account.report": "檢舉 @{name}",
-  "account.requested": "正在等待核准。按一下取消關注請求",
+  "account.requested": "正在等待核准。按一下以取消跟隨請求",
   "account.share": "分享 @{name} 的個人資料",
   "account.show_reblogs": "顯示來自 @{name} 的嘟文",
   "account.statuses_counter": "{count, plural,one {{counter} 則}other {{counter} 則}}嘟文",
   "account.unblock": "取消封鎖 @{name}",
   "account.unblock_domain": "取消封鎖域名 {domain}",
   "account.unendorse": "不再於個人資料頁面推薦對方",
-  "account.unfollow": "取消關注",
+  "account.unfollow": "取消跟隨",
   "account.unmute": "取消靜音 @{name}",
   "account.unmute_notifications": "重新接收來自 @{name} 的通知",
   "account_note.placeholder": "按此添加備注",
+  "admin.dashboard.retention": "Retention",
+  "admin.dashboard.retention.average": "Average",
+  "admin.dashboard.retention.cohort": "Sign-up month",
+  "admin.dashboard.retention.cohort_size": "New users",
   "alert.rate_limited.message": "請在 {retry_time, time, medium} 後重試",
   "alert.rate_limited.title": "已限速",
   "alert.unexpected.message": "發生了非預期的錯誤。",
   "alert.unexpected.title": "哎呀!",
   "announcement.announcement": "公告",
+  "attachments_list.unprocessed": "(unprocessed)",
   "autosuggest_hashtag.per_week": "{count} / 週",
   "boost_modal.combo": "下次您可以按 {combo} 跳過",
   "bundle_column_error.body": "載入此元件時發生錯誤。",
@@ -66,8 +71,8 @@
   "column.direct": "私訊",
   "column.directory": "瀏覽個人資料",
   "column.domain_blocks": "已封鎖的網域",
-  "column.favourites": "收藏",
-  "column.follow_requests": "關注請求",
+  "column.favourites": "最愛",
+  "column.follow_requests": "跟隨請求",
   "column.home": "首頁",
   "column.lists": "名單",
   "column.mutes": "已靜音的使用者",
@@ -88,7 +93,7 @@
   "compose_form.direct_message_warning": "這條嘟文只有被提及的使用者才看得到。",
   "compose_form.direct_message_warning_learn_more": "了解更多",
   "compose_form.hashtag_warning": "由於這則嘟文設定為「不公開」,它將不會被列於任何主題標籤下。只有公開的嘟文才能藉由主題標籤找到。",
-  "compose_form.lock_disclaimer": "您的帳戶尚未{locked}。任何人都能關注您並看到您設定成只有關注者能看的嘟文。",
+  "compose_form.lock_disclaimer": "您的帳戶尚未{locked}。任何人都能關注您並看到您設定成只有跟隨者能看的嘟文。",
   "compose_form.lock_disclaimer.lock": "上鎖",
   "compose_form.placeholder": "正在想些什麼嗎?",
   "compose_form.poll.add_option": "新增選項",
@@ -113,19 +118,21 @@
   "confirmations.delete.message": "您確定要刪除這則嘟文?",
   "confirmations.delete_list.confirm": "刪除",
   "confirmations.delete_list.message": "確定永久刪除此名單?",
+  "confirmations.discard_edit_media.confirm": "Discard",
+  "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
   "confirmations.domain_block.confirm": "隱藏整個域名",
-  "confirmations.domain_block.message": "真的非常確定封鎖整個 {domain} 網域嗎?大部分情況下,您只需要封鎖或靜音少數特定的帳帳戶能滿足需求了。您將不能在任何公開的時間軸及通知中看到此網域的內容。您來自該網域的關注者也將被移除。",
+  "confirmations.domain_block.message": "真的非常確定封鎖整個 {domain} 網域嗎?大部分情況下,您只需要封鎖或靜音少數特定的帳帳戶能滿足需求了。您將不能在任何公開的時間軸及通知中看到來自此網域的內容。您來自該網域的跟隨者也將被移除。",
   "confirmations.logout.confirm": "登出",
   "confirmations.logout.message": "確定要登出嗎?",
   "confirmations.mute.confirm": "靜音",
-  "confirmations.mute.explanation": "這將會隱藏來自他們的貼文與通知,但是他們還是可以查閱你的貼文與關注您。",
+  "confirmations.mute.explanation": "這將會隱藏來自他們的嘟文與通知,但是他們還是可以查閱您的嘟文與跟隨您。",
   "confirmations.mute.message": "確定靜音 {name} ?",
   "confirmations.redraft.confirm": "刪除並重新編輯",
-  "confirmations.redraft.message": "確定刪掉這則嘟文並重新編輯嗎?將會失去這則嘟文的轉嘟及收藏,且回覆這則的嘟文將會變成獨立的嘟文。",
+  "confirmations.redraft.message": "確定刪掉這則嘟文並重新編輯嗎?將會失去這則嘟文的轉嘟及最愛,且回覆這則的嘟文將會變成獨立的嘟文。",
   "confirmations.reply.confirm": "回覆",
   "confirmations.reply.message": "現在回覆將蓋掉您目前正在撰寫的訊息。是否仍要回覆?",
-  "confirmations.unfollow.confirm": "取消關注",
-  "confirmations.unfollow.message": "確定要取消關注 {name} 嗎?",
+  "confirmations.unfollow.confirm": "取消跟隨",
+  "confirmations.unfollow.message": "確定要取消跟隨 {name} 嗎?",
   "conversation.delete": "刪除對話",
   "conversation.mark_as_read": "標記為已讀",
   "conversation.open": "檢視對話",
@@ -158,18 +165,18 @@
   "empty_column.community": "本機時間軸是空的。快公開嘟些文搶頭香啊!",
   "empty_column.direct": "您還沒有任何私訊。當您私訊別人或收到私訊時,它將於此顯示。",
   "empty_column.domain_blocks": "尚未封鎖任何網域。",
-  "empty_column.favourited_statuses": "您還沒收藏過任何嘟文。當您收藏嘟文時,它將於此顯示。",
-  "empty_column.favourites": "還沒有人收藏過這則嘟文。當有人收藏嘟文時,它將於此顯示。",
-  "empty_column.follow_recommendations": "似乎未能為您生成任何建議。您可以嘗試使用搜尋來尋找您可能認識的人,或是探索熱門主題標籤。",
-  "empty_column.follow_requests": "您尚未收到任何關注請求。這裡將會顯示收到的關注請求。",
+  "empty_column.favourited_statuses": "您還沒加過任何嘟文至最愛。當您收藏嘟文時,它將於此顯示。",
+  "empty_column.favourites": "還沒有人加過這則嘟文至最愛。當有人收藏嘟文時,它將於此顯示。",
+  "empty_column.follow_recommendations": "似乎未能為您產生任何建議。您可以嘗試使用搜尋來尋找您可能認識的人,或是探索熱門主題標籤。",
+  "empty_column.follow_requests": "您尚未收到任何跟隨請求。這裡將會顯示收到的跟隨請求。",
   "empty_column.hashtag": "這個主題標籤下什麼也沒有。",
-  "empty_column.home": "您的首頁時間軸是空的!前往 {public} 或使用搜尋功能來認識其他人。",
+  "empty_column.home": "您的首頁時間軸是空的!前往 {suggestions} 或使用搜尋功能來認識其他人。",
   "empty_column.home.suggestions": "檢視部份建議",
   "empty_column.list": "這份名單還沒有東西。當此名單的成員嘟出了新的嘟文時,它們就會顯示於此。",
   "empty_column.lists": "您還沒有建立任何名單。這裡將會顯示您所建立的名單。",
   "empty_column.mutes": "您尚未靜音任何使用者。",
   "empty_column.notifications": "您尚未收到任何通知,和別人互動開啟對話吧。",
-  "empty_column.public": "這裡什麼都沒有!嘗試寫些公開的嘟文,或著自己關注其他伺服器的使用者後就會有嘟文出現了",
+  "empty_column.public": "這裡什麼都沒有!嘗試寫些公開的嘟文,或著自己跟隨其他伺服器的使用者後就會有嘟文出現了",
   "error.unexpected_crash.explanation": "由於發生系統故障或瀏覽器相容性問題,無法正常顯示此頁面。",
   "error.unexpected_crash.explanation_addons": "此頁面無法被正常顯示,這可能是由瀏覽器附加元件或網頁自動翻譯工具造成的。",
   "error.unexpected_crash.next_steps": "請嘗試重新整理頁面。如果狀況沒有改善,您可以使用不同的瀏覽器或應用程式來檢視來使用 Mastodon。",
@@ -177,11 +184,11 @@
   "errors.unexpected_crash.copy_stacktrace": "複製 stacktrace 到剪貼簿",
   "errors.unexpected_crash.report_issue": "回報問題",
   "follow_recommendations.done": "完成",
-  "follow_recommendations.heading": "追蹤您想檢視其貼文的人!這裡有一些建議。",
-  "follow_recommendations.lead": "來自您追蹤的人的貼文將會按時間順序顯示在您的家 feed 上。不要害怕犯錯,您隨時都可以取消追蹤其他人!",
+  "follow_recommendations.heading": "跟隨您想檢視其貼文的人!這裡有一些建議。",
+  "follow_recommendations.lead": "來自您跟隨的人的貼文將會按時間順序顯示在您的家 feed 上。不要害怕犯錯,您隨時都可以取消跟隨其他人!",
   "follow_request.authorize": "授權",
   "follow_request.reject": "拒絕",
-  "follow_requests.unlocked_explanation": "即便您的帳戶未被鎖定,{domain} 的員工認為您可能想要自己審核這些帳戶的追蹤請求。",
+  "follow_requests.unlocked_explanation": "即便您的帳戶未被鎖定,{domain} 的管理員認為您可能想要自己審核這些帳戶的跟隨請求。",
   "generic.saved": "已儲存",
   "getting_started.developers": "開發者",
   "getting_started.directory": "個人資料目錄",
@@ -217,8 +224,8 @@
   "keyboard_shortcuts.direct": "開啟私訊欄",
   "keyboard_shortcuts.down": "在名單中往下移動",
   "keyboard_shortcuts.enter": "檢視嘟文",
-  "keyboard_shortcuts.favourite": "加到收藏",
-  "keyboard_shortcuts.favourites": "開啟收藏名單",
+  "keyboard_shortcuts.favourite": "加到最愛",
+  "keyboard_shortcuts.favourites": "開啟最愛名單",
   "keyboard_shortcuts.federated": "開啟聯邦時間軸",
   "keyboard_shortcuts.heading": "鍵盤快速鍵",
   "keyboard_shortcuts.home": "開啟首頁時間軸",
@@ -233,7 +240,7 @@
   "keyboard_shortcuts.pinned": "開啟釘選的嘟文名單",
   "keyboard_shortcuts.profile": "開啟作者的個人資料頁面",
   "keyboard_shortcuts.reply": "回應嘟文",
-  "keyboard_shortcuts.requests": "開啟關注請求名單",
+  "keyboard_shortcuts.requests": "開啟跟隨請求名單",
   "keyboard_shortcuts.search": "將焦點移至搜尋框",
   "keyboard_shortcuts.spoilers": "顯示或隱藏被折疊的正文",
   "keyboard_shortcuts.start": "開啟「開始使用」欄位",
@@ -258,7 +265,7 @@
   "lists.replies_policy.list": "列表成員",
   "lists.replies_policy.none": "沒有人",
   "lists.replies_policy.title": "顯示回覆:",
-  "lists.search": "搜尋您關注的使用者",
+  "lists.search": "搜尋您跟隨的使用者",
   "lists.subheading": "您的名單",
   "load_pending": "{count, plural, one {# 個新項目} other {# 個新項目}}",
   "loading_indicator.label": "讀取中...",
@@ -279,8 +286,8 @@
   "navigation_bar.edit_profile": "編輯個人資料",
   "navigation_bar.favourites": "收藏",
   "navigation_bar.filters": "靜音詞彙",
-  "navigation_bar.follow_requests": "關注請求",
-  "navigation_bar.follows_and_followers": "關注及關注者",
+  "navigation_bar.follow_requests": "跟隨請求",
+  "navigation_bar.follows_and_followers": "跟隨中與跟隨者",
   "navigation_bar.info": "關於此伺服器",
   "navigation_bar.keyboard_shortcuts": "快速鍵",
   "navigation_bar.lists": "名單",
@@ -292,8 +299,8 @@
   "navigation_bar.public_timeline": "聯邦時間軸",
   "navigation_bar.security": "安全性",
   "notification.favourite": "{name} 把您的嘟文加入了最愛",
-  "notification.follow": "{name} 關注了您",
-  "notification.follow_request": "{name} 要求關注您",
+  "notification.follow": "{name} 跟隨了您",
+  "notification.follow_request": "{name} 要求跟隨您",
   "notification.mention": "{name} 提到了您",
   "notification.own_poll": "您的投票已結束",
   "notification.poll": "您曾投過的投票已經結束",
@@ -306,8 +313,8 @@
   "notifications.column_settings.filter_bar.advanced": "顯示所有分類",
   "notifications.column_settings.filter_bar.category": "快速過濾欄",
   "notifications.column_settings.filter_bar.show": "顯示",
-  "notifications.column_settings.follow": "新關注者:",
-  "notifications.column_settings.follow_request": "新的關注請求:",
+  "notifications.column_settings.follow": "新的跟隨者:",
+  "notifications.column_settings.follow_request": "新的跟隨請求:",
   "notifications.column_settings.mention": "提及:",
   "notifications.column_settings.poll": "投票結果:",
   "notifications.column_settings.push": "推播通知",
@@ -319,7 +326,7 @@
   "notifications.filter.all": "全部",
   "notifications.filter.boosts": "轉嘟",
   "notifications.filter.favourites": "最愛",
-  "notifications.filter.follows": "關注的使用者",
+  "notifications.filter.follows": "跟隨的使用者",
   "notifications.filter.mentions": "提及",
   "notifications.filter.polls": "投票結果",
   "notifications.filter.statuses": "已跟隨使用者的最新動態",
@@ -339,13 +346,14 @@
   "poll.total_votes": "{count, plural, one {# 個投票} other {# 個投票}}",
   "poll.vote": "投票",
   "poll.voted": "您已對此問題投票",
+  "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
   "poll_button.add_poll": "建立投票",
   "poll_button.remove_poll": "移除投票",
   "privacy.change": "調整嘟文隱私狀態",
   "privacy.direct.long": "只有被提及的使用者能看到",
   "privacy.direct.short": "私訊",
-  "privacy.private.long": "只有關注您的使用者能看到",
-  "privacy.private.short": "僅關注者",
+  "privacy.private.long": "只有跟隨您的使用者能看到",
+  "privacy.private.short": "僅跟隨者",
   "privacy.public.long": "公開,且顯示於公開時間軸",
   "privacy.public.short": "公開",
   "privacy.unlisted.long": "公開,但不會顯示在公開時間軸",
@@ -368,7 +376,7 @@
   "report.target": "檢舉 {target}",
   "search.placeholder": "搜尋",
   "search_popout.search_format": "進階搜尋格式",
-  "search_popout.tips.full_text": "輸入簡單的文字,搜尋由您撰寫、收藏、轉嘟或提您的嘟文,以及與關鍵詞匹配的使用者名稱、帳戶顯示名稱和主題標籤。",
+  "search_popout.tips.full_text": "輸入簡單的文字,搜尋由您撰寫、最愛、轉嘟或提您的嘟文,以及與關鍵詞匹配的使用者名稱、帳戶顯示名稱和主題標籤。",
   "search_popout.tips.hashtag": "主題標籤",
   "search_popout.tips.status": "嘟文",
   "search_popout.tips.text": "輸入簡單的文字,搜尋符合的使用者名稱,帳戶名稱與標籤",
@@ -433,8 +441,8 @@
   "time_remaining.moments": "剩餘時間",
   "time_remaining.seconds": "剩餘 {number, plural, one {# 秒} other {# 秒}}",
   "timeline_hint.remote_resource_not_displayed": "不會顯示來自其他伺服器的 {resource}",
-  "timeline_hint.resources.followers": "關注者",
-  "timeline_hint.resources.follows": "正在關注",
+  "timeline_hint.resources.followers": "跟隨者",
+  "timeline_hint.resources.follows": "正在跟隨",
   "timeline_hint.resources.statuses": "更早的嘟文",
   "trends.counter_by_accounts": "{count, plural,one {{counter} 人}other {{counter} 人}}正在討論",
   "trends.trending_now": "目前趨勢",
@@ -454,6 +462,7 @@
   "upload_form.video_description": "描述給聽障或視障人士",
   "upload_modal.analyzing_picture": "正在分析圖片…",
   "upload_modal.apply": "套用",
+  "upload_modal.applying": "Applying…",
   "upload_modal.choose_image": "選擇圖片",
   "upload_modal.description_placeholder": "我能吞下玻璃而不傷身體",
   "upload_modal.detect_text": "從圖片中偵測文字",
diff --git a/app/javascript/mastodon/reducers/accounts_map.js b/app/javascript/mastodon/reducers/accounts_map.js
new file mode 100644
index 000000000..e0d42e9cd
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts_map.js
@@ -0,0 +1,15 @@
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function accountsMap(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_IMPORT:
+    return state.set(action.account.acct, action.account.id);
+  case ACCOUNTS_IMPORT:
+    return state.withMutations(map => action.accounts.forEach(account => map.set(account.acct, account.id)));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 4c0ba1c36..06a908e9d 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -21,6 +21,7 @@ import {
   COMPOSE_SUGGESTIONS_CLEAR,
   COMPOSE_SUGGESTIONS_READY,
   COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SUGGESTION_IGNORE,
   COMPOSE_SUGGESTION_TAGS_UPDATE,
   COMPOSE_TAG_HISTORY_UPDATE,
   COMPOSE_SENSITIVITY_CHANGE,
@@ -39,6 +40,9 @@ import {
   COMPOSE_POLL_OPTION_CHANGE,
   COMPOSE_POLL_OPTION_REMOVE,
   COMPOSE_POLL_SETTINGS_CHANGE,
+  INIT_MEDIA_EDIT_MODAL,
+  COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+  COMPOSE_CHANGE_MEDIA_FOCUS,
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STORE_HYDRATE } from '../actions/store';
@@ -76,6 +80,13 @@ const initialState = ImmutableMap({
   resetFileKey: Math.floor((Math.random() * 0x10000)),
   idempotencyKey: null,
   tagHistory: ImmutableList(),
+  media_modal: ImmutableMap({
+    id: null,
+    description: '',
+    focusX: 0,
+    focusY: 0,
+    dirty: false,
+  }),
 });
 
 const initialPoll = ImmutableMap({
@@ -155,6 +166,17 @@ const insertSuggestion = (state, position, token, completion, path) => {
   });
 };
 
+const ignoreSuggestion = (state, position, token, completion, path) => {
+  return state.withMutations(map => {
+    map.updateIn(path, oldText => `${oldText.slice(0, position + token.length)} ${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.set('suggestions', ImmutableList());
+    map.set('focusDate', new Date());
+    map.set('caretPosition', position + token.length + 1);
+    map.set('idempotencyKey', uuid());
+  });
+};
+
 const sortHashtagsByUse = (state, tags) => {
   const personalHistory = state.get('tagHistory');
 
@@ -354,6 +376,19 @@ export default function compose(state = initialState, action) {
 
         return item;
       }));
+  case INIT_MEDIA_EDIT_MODAL:
+    const media =  state.get('media_attachments').find(item => item.get('id') === action.id);
+    return state.set('media_modal', ImmutableMap({
+      id: action.id,
+      description: media.get('description') || '',
+      focusX: media.getIn(['meta', 'focus', 'x'], 0),
+      focusY: media.getIn(['meta', 'focus', 'y'], 0),
+      dirty: false,
+    }));
+  case COMPOSE_CHANGE_MEDIA_DESCRIPTION:
+    return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true);
+  case COMPOSE_CHANGE_MEDIA_FOCUS:
+    return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true);
   case COMPOSE_MENTION:
     return state.withMutations(map => {
       map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
@@ -375,6 +410,8 @@ export default function compose(state = initialState, action) {
     return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
   case COMPOSE_SUGGESTION_SELECT:
     return insertSuggestion(state, action.position, action.token, action.completion, action.path);
+  case COMPOSE_SUGGESTION_IGNORE:
+    return ignoreSuggestion(state, action.position, action.token, action.completion, action.path);
   case COMPOSE_SUGGESTION_TAGS_UPDATE:
     return updateSuggestionTags(state, action.token);
   case COMPOSE_TAG_HISTORY_UPDATE:
@@ -390,6 +427,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_CHANGE_SUCCESS:
     return state
       .set('is_changing_upload', false)
+      .setIn(['media_modal', 'dirty'], false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
           return fromJS(action.media);
diff --git a/app/javascript/mastodon/reducers/identity_proofs.js b/app/javascript/mastodon/reducers/identity_proofs.js
deleted file mode 100644
index 58af0a5fa..000000000
--- a/app/javascript/mastodon/reducers/identity_proofs.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Map as ImmutableMap, fromJS } from 'immutable';
-import {
-  IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
-  IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
-  IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
-} from '../actions/identity_proofs';
-
-const initialState = ImmutableMap();
-
-export default function identityProofsReducer(state = initialState, action) {
-  switch(action.type) {
-  case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST:
-    return state.set('isLoading', true);
-  case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL:
-    return state.set('isLoading', false);
-  case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS:
-    return state.update(identity_proofs => identity_proofs.withMutations(map => {
-      map.set('isLoading', false);
-      map.set('loaded', true);
-      map.set(action.accountId, fromJS(action.identity_proofs));
-    }));
-  default:
-    return state;
-  }
-};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3b3c5ae29..53e2dd681 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -32,12 +32,12 @@ import filters from './filters';
 import conversations from './conversations';
 import suggestions from './suggestions';
 import polls from './polls';
-import identity_proofs from './identity_proofs';
 import trends from './trends';
 import missed_updates from './missed_updates';
 import announcements from './announcements';
 import markers from './markers';
 import picture_in_picture from './picture_in_picture';
+import accounts_map from './accounts_map';
 
 const reducers = {
   announcements,
@@ -52,6 +52,7 @@ const reducers = {
   status_lists,
   accounts,
   accounts_counters,
+  accounts_map,
   statuses,
   relationships,
   settings,
@@ -67,7 +68,6 @@ const reducers = {
   notifications,
   height_cache,
   custom_emojis,
-  identity_proofs,
   lists,
   listEditor,
   listAdder,
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
index cb53887c7..41161a206 100644
--- a/app/javascript/mastodon/reducers/modal.js
+++ b/app/javascript/mastodon/reducers/modal.js
@@ -1,19 +1,18 @@
 import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
 import { TIMELINE_DELETE } from '../actions/timelines';
+import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
+import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
 
-const initialState = {
-  modalType: null,
-  modalProps: {},
-};
-
-export default function modal(state = initialState, action) {
+export default function modal(state = ImmutableStack(), action) {
   switch(action.type) {
   case MODAL_OPEN:
-    return { modalType: action.modalType, modalProps: action.modalProps };
+    return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
   case MODAL_CLOSE:
-    return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
+    return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
   case TIMELINE_DELETE:
-    return (state.modalProps.statusId === action.id) ? initialState : state;
+    return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index 958e5fc12..926c5c4d7 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -90,7 +90,7 @@ const handlePush = (event) => {
       options.tag       = notification.id;
       options.badge     = '/badge.png';
       options.image     = notification.status && notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url || undefined;
-      options.data      = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id, url: notification.status ? `/web/statuses/${notification.status.id}` : `/web/accounts/${notification.account.id}` };
+      options.data      = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id, url: notification.status ? `/web/@${notification.account.acct}/${notification.status.id}` : `/web/@${notification.account.acct}` };
 
       if (notification.status && notification.status.spoiler_text || notification.status.sensitive) {
         options.data.hiddenBody  = htmlToPlainText(notification.status.content);
diff --git a/app/javascript/mastodon/utils/numbers.js b/app/javascript/mastodon/utils/numbers.js
index 6f2505cae..6ef563ad8 100644
--- a/app/javascript/mastodon/utils/numbers.js
+++ b/app/javascript/mastodon/utils/numbers.js
@@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
 
   return Math.trunc(sourceNumber / closestScale) * closestScale;
 }
+
+/**
+ * @param {number} num
+ * @returns {number}
+ */
+export function roundTo10(num) {
+  return Math.round(num * 0.1) / 0.1;
+}
diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js
new file mode 100644
index 000000000..599015000
--- /dev/null
+++ b/app/javascript/packs/admin.js
@@ -0,0 +1,24 @@
+import './public-path';
+import ready from '../mastodon/ready';
+
+ready(() => {
+  const React    = require('react');
+  const ReactDOM = require('react-dom');
+
+  [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
+    const componentName  = element.getAttribute('data-admin-component');
+    const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
+
+    import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
+      return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
+        ReactDOM.render((
+          <AdminComponent locale={locale}>
+            <Component {...componentProps} />
+          </AdminComponent>
+        ), element);
+      });
+    }).catch(error => {
+      console.error(error);
+    });
+  });
+});
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 2166d8df0..7ebe8b4d0 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -103,7 +103,9 @@ function main() {
     delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
       const password = document.getElementById('registration_user_password');
       const confirmation = document.getElementById('registration_user_password_confirmation');
-      if (password.value && password.value !== confirmation.value) {
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
         confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
       } else {
         confirmation.setCustomValidity('');
@@ -115,7 +117,9 @@ function main() {
       const confirmation = document.getElementById('user_password_confirmation');
       if (!confirmation) return;
 
-      if (password.value && password.value !== confirmation.value) {
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
         confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
       } else {
         confirmation.setCustomValidity('');
diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss
index 80c2329b0..2abcb0c2b 100644
--- a/app/javascript/styles/fonts/montserrat.scss
+++ b/app/javascript/styles/fonts/montserrat.scss
@@ -5,6 +5,7 @@
     url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'),
     url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
   font-weight: 400;
+  font-display: swap;
   font-style: normal;
 }
 
@@ -13,5 +14,6 @@
   src: local('Montserrat Medium'),
     url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
   font-weight: 500;
+  font-display: swap;
   font-style: normal;
 }
diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss
index c793aa6ed..a9513dcce 100644
--- a/app/javascript/styles/fonts/roboto-mono.scss
+++ b/app/javascript/styles/fonts/roboto-mono.scss
@@ -6,5 +6,6 @@
     url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
     url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
   font-weight: 400;
+  font-display: swap;
   font-style: normal;
 }
diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss
index b75fdb927..817b448c1 100644
--- a/app/javascript/styles/fonts/roboto.scss
+++ b/app/javascript/styles/fonts/roboto.scss
@@ -6,6 +6,7 @@
     url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
     url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
   font-weight: normal;
+  font-display: swap;
   font-style: italic;
 }
 
@@ -17,6 +18,7 @@
     url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
     url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
   font-weight: bold;
+  font-display: swap;
   font-style: normal;
 }
 
@@ -28,6 +30,7 @@
     url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
     url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
   font-weight: 500;
+  font-display: swap;
   font-style: normal;
 }
 
@@ -39,5 +42,6 @@
     url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
     url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
   font-weight: normal;
+  font-display: swap;
   font-style: normal;
 }
diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index 92c02e847..34852178e 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -533,6 +533,10 @@ ul {
   }
 }
 
+ul.rules-list {
+  padding-top: 0;
+}
+
 @media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) {
   body {
     min-height: 1024px !important;
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index 332b9f420..440e81de9 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -324,3 +324,45 @@
     margin-top: 10px;
   }
 }
+
+.batch-table__row--muted {
+  color: lighten($ui-base-color, 26%);
+}
+
+.batch-table__row--muted .pending-account__header,
+.batch-table__row--muted .accounts-table {
+  &,
+  a,
+  strong {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--muted .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--attention {
+  color: $gold-star;
+}
+
+.batch-table__row--attention .pending-account__header,
+.batch-table__row--attention .accounts-table {
+  &,
+  a,
+  strong {
+    color: $gold-star;
+  }
+}
+
+.batch-table__row--attention .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: $gold-star;
+  }
+}
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 4801a4644..92061585a 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -1,3 +1,5 @@
+@use "sass:math";
+
 $no-columns-breakpoint: 600px;
 $sidebar-width: 240px;
 $content-width: 840px;
@@ -593,39 +595,44 @@ body,
 
 .log-entry {
   line-height: 20px;
-  padding: 15px 0;
+  padding: 15px;
+  padding-left: 15px * 2 + 40px;
   background: $ui-base-color;
-  border-bottom: 1px solid lighten($ui-base-color, 4%);
+  border-bottom: 1px solid darken($ui-base-color, 8%);
+  position: relative;
+
+  &:first-child {
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+  }
 
   &:last-child {
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
     border-bottom: 0;
   }
 
+  &:hover {
+    background: lighten($ui-base-color, 4%);
+  }
+
   &__header {
-    display: flex;
-    justify-content: flex-start;
-    align-items: center;
     color: $darker-text-color;
     font-size: 14px;
-    padding: 0 10px;
   }
 
   &__avatar {
-    margin-right: 10px;
+    position: absolute;
+    left: 15px;
+    top: 15px;
 
     .avatar {
-      display: block;
-      margin: 0;
-      border-radius: 50%;
+      border-radius: 4px;
       width: 40px;
       height: 40px;
     }
   }
 
-  &__content {
-    max-width: calc(100% - 90px);
-  }
-
   &__title {
     word-wrap: break-word;
   }
@@ -641,6 +648,14 @@ body,
     text-decoration: none;
     font-weight: 500;
   }
+
+  a {
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
 }
 
 a.name-tag,
@@ -669,8 +684,9 @@ a.inline-name-tag,
 
 a.name-tag,
 .name-tag {
-  display: flex;
+  display: inline-flex;
   align-items: center;
+  vertical-align: top;
 
   .avatar {
     display: block;
@@ -845,6 +861,7 @@ a.name-tag,
     padding: 0 5px;
     margin-bottom: 10px;
     flex: 1 0 50%;
+    max-width: 100%;
   }
 
   .account__header__fields,
@@ -925,10 +942,489 @@ a.name-tag,
   }
 }
 
+.dashboard__counters.admin-account-counters {
+  margin-top: 10px;
+}
+
 .account-badges {
   margin: -2px 0;
 }
 
-.dashboard__counters.admin-account-counters {
-  margin-top: 10px;
+.retention {
+  overflow: auto;
+
+  > h4 {
+    position: sticky;
+    left: 0;
+  }
+
+  &__table {
+    &__number {
+      color: $secondary-text-color;
+      padding: 10px;
+    }
+
+    &__date {
+      white-space: nowrap;
+      padding: 10px 0;
+      text-align: left;
+      min-width: 120px;
+
+      &.retention__table__average {
+        font-weight: 700;
+      }
+    }
+
+    &__size {
+      text-align: center;
+      padding: 10px;
+    }
+
+    &__label {
+      font-weight: 700;
+      color: $darker-text-color;
+    }
+
+    &__box {
+      box-sizing: border-box;
+      background: $ui-highlight-color;
+      padding: 10px;
+      font-weight: 500;
+      color: $primary-text-color;
+      width: 52px;
+      margin: 1px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
+        }
+      }
+    }
+  }
+}
+
+.sparkline {
+  display: block;
+  text-decoration: none;
+  background: lighten($ui-base-color, 4%);
+  border-radius: 4px;
+  padding: 0;
+  position: relative;
+  padding-bottom: 55px + 20px;
+  overflow: hidden;
+
+  &__value {
+    display: flex;
+    line-height: 33px;
+    align-items: flex-end;
+    padding: 20px;
+    padding-bottom: 10px;
+
+    &__total {
+      display: block;
+      margin-right: 10px;
+      font-weight: 500;
+      font-size: 28px;
+      color: $primary-text-color;
+    }
+
+    &__change {
+      display: block;
+      font-weight: 500;
+      font-size: 18px;
+      color: $darker-text-color;
+      margin-bottom: -3px;
+
+      &.positive {
+        color: $valid-value-color;
+      }
+
+      &.negative {
+        color: $error-value-color;
+      }
+    }
+  }
+
+  &__label {
+    padding: 0 20px;
+    padding-bottom: 10px;
+    text-transform: uppercase;
+    color: $darker-text-color;
+    font-weight: 500;
+  }
+
+  &__graph {
+    position: absolute;
+    bottom: 0;
+    width: 100%;
+
+    svg {
+      display: block;
+      margin: 0;
+    }
+
+    path:first-child {
+      fill: rgba($highlight-text-color, 0.25) !important;
+      fill-opacity: 1 !important;
+    }
+
+    path:last-child {
+      stroke: lighten($highlight-text-color, 6%) !important;
+      fill: none !important;
+    }
+  }
+}
+
+a.sparkline {
+  &:hover,
+  &:focus,
+  &:active {
+    background: lighten($ui-base-color, 6%);
+  }
+}
+
+.skeleton {
+  background-color: lighten($ui-base-color, 8%);
+  background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%));
+  background-size: 200px 100%;
+  background-repeat: no-repeat;
+  border-radius: 4px;
+  display: inline-block;
+  line-height: 1;
+  width: 100%;
+  animation: skeleton 1.2s ease-in-out infinite;
+}
+
+@keyframes skeleton {
+  0% {
+    background-position: -200px 0;
+  }
+
+  100% {
+    background-position: calc(200px + 100%) 0;
+  }
+}
+
+.dimension {
+  table {
+    width: 100%;
+  }
+
+  &__item {
+    border-bottom: 1px solid lighten($ui-base-color, 4%);
+
+    &__key {
+      font-weight: 500;
+      padding: 11px 10px;
+    }
+
+    &__value {
+      text-align: right;
+      color: $darker-text-color;
+      padding: 11px 10px;
+    }
+
+    &__indicator {
+      display: inline-block;
+      width: 8px;
+      height: 8px;
+      border-radius: 50%;
+      background: $ui-highlight-color;
+      margin-right: 10px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
+        }
+      }
+    }
+
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
+.report-reason-selector {
+  border-radius: 4px;
+  background: $ui-base-color;
+  margin-bottom: 20px;
+
+  &__category {
+    cursor: pointer;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__label {
+      padding: 15px;
+    }
+
+    &__rules {
+      margin-left: 30px;
+    }
+  }
+
+  &__rule {
+    cursor: pointer;
+    padding: 15px;
+  }
+}
+
+.report-header {
+  display: grid;
+  grid-gap: 15px;
+  grid-template-columns: minmax(0, 1fr) 300px;
+
+  &__details {
+    &__item {
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+      padding: 15px 0;
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__header {
+        font-weight: 600;
+        padding: 4px 0;
+      }
+    }
+
+    &--horizontal {
+      display: grid;
+      grid-auto-columns: minmax(0, 1fr);
+      grid-auto-flow: column;
+
+      .report-header__details__item {
+        border-bottom: 0;
+      }
+    }
+  }
+}
+
+.account-card {
+  background: $ui-base-color;
+  border-radius: 4px;
+
+  &__header {
+    padding: 4px;
+    border-radius: 4px;
+    height: 128px;
+
+    img {
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      background: darken($ui-base-color, 8%);
+    }
+  }
+
+  &__title {
+    margin-top: -25px;
+    display: flex;
+    align-items: flex-end;
+
+    &__avatar {
+      padding: 15px;
+
+      img {
+        display: block;
+        margin: 0;
+        width: 56px;
+        height: 56px;
+        background: darken($ui-base-color, 8%);
+        border-radius: 8px;
+      }
+    }
+
+    .display-name {
+      color: $darker-text-color;
+      padding-bottom: 15px;
+      font-size: 15px;
+
+      bdi {
+        display: block;
+        color: $primary-text-color;
+        font-weight: 500;
+      }
+    }
+  }
+
+  &__bio {
+    padding: 0 15px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    word-wrap: break-word;
+    max-height: 18px * 2;
+    position: relative;
+
+    &::after {
+      display: block;
+      content: "";
+      width: 50px;
+      height: 18px;
+      position: absolute;
+      bottom: 0;
+      right: 15px;
+      background: linear-gradient(to left, $ui-base-color, transparent);
+      pointer-events: none;
+    }
+  }
+
+  &__actions {
+    display: flex;
+    align-items: center;
+    padding-top: 10px;
+
+    &__button {
+      flex: 0 0 auto;
+      padding: 0 15px;
+    }
+  }
+
+  &__counters {
+    flex: 1 1 auto;
+    display: grid;
+    grid-auto-columns: minmax(0, 1fr);
+    grid-auto-flow: column;
+
+    &__item {
+      padding: 15px;
+      text-align: center;
+      color: $primary-text-color;
+      font-weight: 600;
+      font-size: 15px;
+
+      small {
+        display: block;
+        color: $darker-text-color;
+        font-weight: 400;
+        font-size: 13px;
+      }
+    }
+  }
+}
+
+.report-notes {
+  margin-bottom: 20px;
+
+  &__item {
+    background: $ui-base-color;
+    position: relative;
+    padding: 15px;
+    padding-left: 15px * 2 + 40px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:first-child {
+      border-top-left-radius: 4px;
+      border-top-right-radius: 4px;
+    }
+
+    &:last-child {
+      border-bottom-left-radius: 4px;
+      border-bottom-right-radius: 4px;
+      border-bottom: 0;
+    }
+
+    &:hover {
+      background-color: lighten($ui-base-color, 4%);
+    }
+
+    &__avatar {
+      position: absolute;
+      left: 15px;
+      top: 15px;
+      border-radius: 4px;
+      width: 40px;
+      height: 40px;
+    }
+
+    &__header {
+      color: $darker-text-color;
+      font-size: 15px;
+      line-height: 20px;
+      margin-bottom: 4px;
+
+      .username a {
+        color: $primary-text-color;
+        font-weight: 500;
+        text-decoration: none;
+        margin-right: 5px;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+
+      time {
+        margin-left: 5px;
+        vertical-align: baseline;
+      }
+    }
+
+    &__content {
+      font-size: 15px;
+      line-height: 20px;
+      word-wrap: break-word;
+      font-weight: 400;
+      color: $primary-text-color;
+
+      p {
+        margin-bottom: 20px;
+        white-space: pre-wrap;
+        unicode-bidi: plaintext;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+
+    &__actions {
+      position: absolute;
+      top: 15px;
+      right: 15px;
+      text-align: right;
+    }
+  }
+}
+
+.report-actions {
+  border: 1px solid darken($ui-base-color, 8%);
+
+  &__item {
+    display: flex;
+    align-items: center;
+    line-height: 18px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__button {
+      flex: 0 0 auto;
+      width: 100px;
+      padding: 15px;
+      padding-right: 0;
+
+      .button {
+        display: block;
+        width: 100%;
+      }
+    }
+
+    &__description {
+      padding: 15px;
+      font-size: 14px;
+      color: $dark-text-color;
+    }
+  }
 }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 33f1e2989..919480e7e 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -967,6 +967,17 @@
   }
 }
 
+.status__content__edited-label {
+  display: block;
+  cursor: default;
+  font-size: 15px;
+  line-height: 20px;
+  padding: 0;
+  padding-top: 8px;
+  color: $dark-text-color;
+  font-weight: 500;
+}
+
 .status__content__spoiler-link {
   display: inline-block;
   border-radius: 2px;
@@ -2822,7 +2833,7 @@ a.account__display-name {
   transition: background-color 0.2s ease;
 }
 
-.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+.react-toggle:is(:hover, :focus-within):not(.react-toggle--disabled) .react-toggle-track {
   background-color: darken($ui-base-color, 10%);
 }
 
@@ -2830,7 +2841,7 @@ a.account__display-name {
   background-color: $ui-highlight-color;
 }
 
-.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+.react-toggle--checked:is(:hover, :focus-within):not(.react-toggle--disabled) .react-toggle-track {
   background-color: lighten($ui-highlight-color, 10%);
 }
 
@@ -3022,13 +3033,13 @@ a.account__display-name {
     }
 
     @media screen and (max-height: 810px) {
-      .trends__item:nth-child(3) {
+      .trends__item:nth-of-type(3) {
         display: none;
       }
     }
 
     @media screen and (max-height: 720px) {
-      .trends__item:nth-child(2) {
+      .trends__item:nth-of-type(2) {
         display: none;
       }
     }
@@ -3074,17 +3085,20 @@ a.account__display-name {
   box-sizing: border-box;
   width: 100%;
   margin: 0;
-  color: $inverted-text-color;
-  background: $simple-background-color;
-  padding: 10px;
+  color: $darker-text-color;
+  background: transparent;
+  padding: 7px 0;
   font-family: inherit;
   font-size: 14px;
   resize: vertical;
   border: 0;
+  border-bottom: 2px solid $ui-primary-color;
   outline: 0;
-  border-radius: 4px;
 
-  &:focus {
+  &:focus,
+  &:active {
+    color: $primary-text-color;
+    border-bottom-color: $ui-highlight-color;
     outline: 0;
   }
 
@@ -3548,12 +3562,17 @@ a.status-card.compact:hover {
 }
 
 .column-header__setting-btn {
-  &:hover {
+  &:hover,
+  &:focus {
     color: $darker-text-color;
     text-decoration: underline;
   }
 }
 
+.column-header__collapsible__extra + .column-header__setting-btn {
+  padding-top: 5px;
+}
+
 .column-header__permission-btn {
   display: inline;
   font-weight: inherit;
@@ -3564,10 +3583,15 @@ a.status-card.compact:hover {
   float: right;
 
   .column-header__setting-btn {
-    padding: 0 10px;
+    padding: 5px;
+
+    &:first-child {
+      padding-right: 7px;
+    }
 
     &:last-child {
-      padding-right: 0;
+      padding-left: 7px;
+      margin-left: 5px;
     }
   }
 }
@@ -3912,7 +3936,8 @@ a.status-card.compact:hover {
     }
 
     &__multi-value__label,
-    &__input {
+    &__input,
+    &__input-container {
       color: $darker-text-color;
     }
 
@@ -5542,7 +5567,8 @@ a.status-card.compact:hover {
     opacity: 0.2;
   }
 
-  .video-player__buttons button {
+  .video-player__buttons button,
+  .video-player__buttons a {
     color: currentColor;
     opacity: 0.75;
 
@@ -6944,7 +6970,6 @@ noscript {
     &__current {
       flex: 0 0 auto;
       font-size: 24px;
-      line-height: 36px;
       font-weight: 500;
       text-align: right;
       padding-right: 15px;
@@ -6966,6 +6991,58 @@ noscript {
         fill: none !important;
       }
     }
+
+    &--requires-review {
+      .trends__item__name {
+        color: $gold-star;
+
+        a {
+          color: $gold-star;
+        }
+      }
+
+      .trends__item__current {
+        color: $gold-star;
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba($gold-star, 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten($gold-star, 6%) !important;
+        }
+      }
+    }
+
+    &--disabled {
+      .trends__item__name {
+        color: lighten($ui-base-color, 12%);
+
+        a {
+          color: lighten($ui-base-color, 12%);
+        }
+      }
+
+      .trends__item__current {
+        color: lighten($ui-base-color, 12%);
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
+        }
+      }
+    }
+  }
+
+  &--compact &__item {
+    padding: 10px;
   }
 }
 
@@ -7291,6 +7368,7 @@ noscript {
     &__account {
       display: flex;
       text-decoration: none;
+      overflow: hidden;
     }
 
     .account__avatar {
diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss
index c0944d417..0a881bc10 100644
--- a/app/javascript/styles/mastodon/dashboard.scss
+++ b/app/javascript/styles/mastodon/dashboard.scss
@@ -56,23 +56,70 @@
   }
 }
 
-.dashboard__widgets {
-  display: flex;
-  flex-wrap: wrap;
-  margin: 0 -5px;
+.dashboard {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+  grid-gap: 10px;
 
-  & > div {
-    flex: 0 0 33.333%;
-    margin-bottom: 20px;
+  @media screen and (max-width: 1350px) {
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  }
 
-    & > div {
-      padding: 0 5px;
+  &__item {
+    &--span-double-column {
+      grid-column: span 2;
+    }
+
+    &--span-double-row {
+      grid-row: span 2;
+    }
+
+    h4 {
+      padding-top: 20px;
     }
   }
 
-  a:not(.name-tag) {
-    color: $ui-secondary-color;
-    font-weight: 500;
+  &__quick-access {
+    display: flex;
+    align-items: baseline;
+    border-radius: 4px;
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+    transition: all 100ms ease-in;
+    font-size: 14px;
+    padding: 0 16px;
+    line-height: 36px;
+    height: 36px;
     text-decoration: none;
+    margin-bottom: 4px;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-highlight-color, 10%);
+      transition: all 200ms ease-out;
+    }
+
+    &.positive {
+      background: lighten($ui-base-color, 4%);
+      color: $valid-value-color;
+    }
+
+    &.negative {
+      background: lighten($ui-base-color, 4%);
+      color: $error-value-color;
+    }
+
+    span {
+      flex: 1 1 auto;
+    }
+
+    .fa {
+      flex: 0 0 auto;
+    }
+
+    strong {
+      font-weight: 700;
+    }
   }
 }
diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss
index adddd4533..e73057465 100644
--- a/app/javascript/styles/mastodon/emoji_picker.scss
+++ b/app/javascript/styles/mastodon/emoji_picker.scss
@@ -169,7 +169,7 @@
   }
 
   &:hover::before {
-    z-index: 0;
+    z-index: -1;
     content: "";
     position: absolute;
     top: 0;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 87a4fc2dc..b67565591 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -993,68 +993,6 @@ code {
   }
 }
 
-.connection-prompt {
-  margin-bottom: 25px;
-
-  .fa-link {
-    background-color: darken($ui-base-color, 4%);
-    border-radius: 100%;
-    font-size: 24px;
-    padding: 10px;
-  }
-
-  &__column {
-    align-items: center;
-    display: flex;
-    flex: 1;
-    flex-direction: column;
-    flex-shrink: 1;
-    max-width: 50%;
-
-    &-sep {
-      align-self: center;
-      flex-grow: 0;
-      overflow: visible;
-      position: relative;
-      z-index: 1;
-    }
-
-    p {
-      word-break: break-word;
-    }
-  }
-
-  .account__avatar {
-    margin-bottom: 20px;
-  }
-
-  &__connection {
-    background-color: lighten($ui-base-color, 8%);
-    box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
-    border-radius: 4px;
-    padding: 25px 10px;
-    position: relative;
-    text-align: center;
-
-    &::after {
-      background-color: darken($ui-base-color, 4%);
-      content: '';
-      display: block;
-      height: 100%;
-      left: 50%;
-      position: absolute;
-      top: 0;
-      width: 1px;
-    }
-  }
-
-  &__row {
-    align-items: flex-start;
-    display: flex;
-    flex-direction: row;
-  }
-}
-
 .input.user_confirm_password,
 .input.user_website {
   &:not(.field_with_errors) {
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index ad7088982..e33fc7983 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -143,6 +143,21 @@
     &:active {
       outline: 0 !important;
     }
+
+    &.disabled {
+      border-color: $dark-text-color;
+
+      &.active {
+        background: $dark-text-color;
+      }
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $dark-text-color;
+        border-width: 1px;
+      }
+    }
   }
 
   &__number {
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
index baacf46b9..ea7bb5113 100644
--- a/app/javascript/styles/mastodon/rtl.scss
+++ b/app/javascript/styles/mastodon/rtl.scss
@@ -126,6 +126,20 @@ body.rtl {
 
   .column-header__setting-arrows {
     float: left;
+
+    .column-header__setting-btn {
+      &:first-child {
+        padding-left: 7px;
+        padding-right: 5px;
+      }
+
+      &:last-child {
+        padding-right: 7px;
+        padding-left: 5px;
+        margin-right: 5px;
+        margin-left: 0;
+      }
+    }
   }
 
   .setting-toggle__label {
@@ -451,11 +465,6 @@ body.rtl {
     margin-left: 5px;
   }
 
-  .column-header__setting-arrows .column-header__setting-btn:last-child {
-    padding-left: 0;
-    padding-right: 10px;
-  }
-
   .simple_form .input.radio_buttons .radio > label input {
     left: auto;
     right: 0;
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index 62f5554ff..36bc07a72 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -237,6 +237,11 @@ a.table-action-link {
         flex: 1 1 auto;
       }
 
+      &__quote {
+        padding: 12px;
+        padding-top: 0;
+      }
+
       &__extra {
         flex: 0 0 auto;
         text-align: right;
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index 4e03868a6..43284eb48 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -443,6 +443,24 @@
     }
   }
 
+  tbody td.accounts-table__extra {
+    width: 120px;
+    text-align: right;
+    color: $darker-text-color;
+    padding-right: 16px;
+
+    a {
+      text-decoration: none;
+      color: inherit;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+  }
+
   &__comment {
     width: 50%;
     vertical-align: initial !important;
diff --git a/app/lib/activity_tracker.rb b/app/lib/activity_tracker.rb
index 81303b715..6d3401b37 100644
--- a/app/lib/activity_tracker.rb
+++ b/app/lib/activity_tracker.rb
@@ -1,29 +1,73 @@
 # frozen_string_literal: true
 
 class ActivityTracker
+  include Redisable
+
   EXPIRE_AFTER = 6.months.seconds
 
-  class << self
-    include Redisable
+  def initialize(prefix, type)
+    @prefix = prefix
+    @type   = type
+  end
 
-    def increment(prefix)
-      key = [prefix, current_week].join(':')
+  def add(value = 1, at_time = Time.now.utc)
+    key = key_at(at_time)
 
-      redis.incrby(key, 1)
-      redis.expire(key, EXPIRE_AFTER)
+    case @type
+    when :basic
+      redis.incrby(key, value)
+    when :unique
+      redis.pfadd(key, value)
     end
 
-    def record(prefix, value)
-      key = [prefix, current_week].join(':')
+    redis.expire(key, EXPIRE_AFTER)
+  end
 
-      redis.pfadd(key, value)
-      redis.expire(key, EXPIRE_AFTER)
+  def get(start_at, end_at = Time.now.utc)
+    (start_at.to_date...end_at.to_date).map do |date|
+      key = key_at(date.to_time(:utc))
+
+      value = begin
+        case @type
+        when :basic
+          redis.get(key).to_i
+        when :unique
+          redis.pfcount(key)
+        end
+      end
+
+      [date, value]
+    end
+  end
+
+  def sum(start_at, end_at = Time.now.utc)
+    keys = (start_at.to_date...end_at.to_date).flat_map { |date| [key_at(date.to_time(:utc)), legacy_key_at(date)] }.uniq
+
+    case @type
+    when :basic
+      redis.mget(*keys).map(&:to_i).sum
+    when :unique
+      redis.pfcount(*keys)
     end
+  end
 
-    private
+  class << self
+    def increment(prefix)
+      new(prefix, :basic).add
+    end
 
-    def current_week
-      Time.zone.today.cweek
+    def record(prefix, value)
+      new(prefix, :unique).add(value)
     end
   end
+
+  private
+
+  def key_at(at_time)
+    "#{@prefix}:#{at_time.beginning_of_day.to_i}"
+  end
+
+  def legacy_key_at(at_time)
+    "#{@prefix}:#{at_time.to_date.cweek}"
+  end
 end
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index d2ec122a4..706960f92 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -94,51 +94,6 @@ class ActivityPub::Activity
     equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
   end
 
-  def distribute(status)
-    crawl_links(status)
-
-    notify_about_reblog(status) if reblog_of_local_account?(status) && !reblog_by_following_group_account?(status)
-    notify_about_mentions(status)
-
-    # Only continue if the status is supposed to have arrived in real-time.
-    # Note that if @options[:override_timestamps] isn't set, the status
-    # may have a lower snowflake id than other existing statuses, potentially
-    # "hiding" it from paginated API calls
-    return unless @options[:override_timestamps] || status.within_realtime_window?
-
-    distribute_to_followers(status)
-  end
-
-  def reblog_of_local_account?(status)
-    status.reblog? && status.reblog.account.local?
-  end
-
-  def reblog_by_following_group_account?(status)
-    status.reblog? && status.account.group? && status.reblog.account.following?(status.account)
-  end
-
-  def notify_about_reblog(status)
-    NotifyService.new.call(status.reblog.account, :reblog, status)
-  end
-
-  def notify_about_mentions(status)
-    status.active_mentions.includes(:account).each do |mention|
-      next unless mention.account.local? && audience_includes?(mention.account)
-      NotifyService.new.call(mention.account, :mention, mention)
-    end
-  end
-
-  def crawl_links(status)
-    return if status.spoiler_text?
-
-    # Spread out crawling randomly to avoid DDoSing the link
-    LinkCrawlWorker.perform_in(rand(1..59).seconds, status.id)
-  end
-
-  def distribute_to_followers(status)
-    ::DistributionWorker.perform_async(status.id)
-  end
-
   def delete_arrived_first?(uri)
     redis.exists?("delete_upon_arrival:#{@account.id}:#{uri}")
   end
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
index 7010ff43e..5126e23c6 100644
--- a/app/lib/activitypub/activity/accept.rb
+++ b/app/lib/activitypub/activity/accept.rb
@@ -3,7 +3,7 @@
 class ActivityPub::Activity::Accept < ActivityPub::Activity
   def perform
     return accept_follow_for_relay if relay_follow?
-    return follow_request_from_object.authorize! unless follow_request_from_object.nil?
+    return accept_follow!(follow_request_from_object) unless follow_request_from_object.nil?
 
     case @object['type']
     when 'Follow'
@@ -19,7 +19,16 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
     return if target_account.nil? || !target_account.local?
 
     follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
-    follow_request&.authorize!
+    accept_follow!(follow_request)
+  end
+
+  def accept_follow!(request)
+    return if request.nil?
+
+    is_first_follow = !request.target_account.followers.local.exists?
+    request.authorize!
+
+    RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow
   end
 
   def accept_follow_for_relay
diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb
index 688ab00b3..845eeaef7 100644
--- a/app/lib/activitypub/activity/add.rb
+++ b/app/lib/activitypub/activity/add.rb
@@ -4,8 +4,7 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
   def perform
     return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url
 
-    status   = status_from_uri(object_uri)
-    status ||= fetch_remote_original_status
+    status = status_from_object
 
     return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
 
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 9f778ffb9..1f9319290 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -22,11 +22,10 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
         visibility: visibility_from_audience
       )
 
-      original_status.tags.each do |tag|
-        tag.use!(@account)
-      end
+      Trends.tags.register(@status)
+      Trends.links.register(@status)
 
-      distribute(@status)
+      distribute
     end
 
     @status
@@ -34,6 +33,22 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
 
   private
 
+  def distribute
+    # Notify the author of the original status if that status is local
+    NotifyService.new.call(@status.reblog.account, :reblog, @status) if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status)
+
+    # Distribute into home and list feeds
+    ::DistributionWorker.perform_async(@status.id) if @options[:override_timestamps] || @status.within_realtime_window?
+  end
+
+  def reblog_of_local_account?(status)
+    status.reblog? && status.reblog.account.local?
+  end
+
+  def reblog_by_following_group_account?(status)
+    status.reblog? && status.account.group? && status.reblog.account.following?(status.account)
+  end
+
   def audience_to
     as_array(@json['to']).map { |x| value_or_id(x) }
   end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index cc2d391fb..ad273c20b 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -69,9 +69,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_status
-    @tags     = []
-    @mentions = []
-    @params   = {}
+    @tags                 = []
+    @mentions             = []
+    @silenced_account_ids = []
+    @params               = {}
 
     process_status_params
     process_tags
@@ -84,10 +85,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
     resolve_thread(@status)
     fetch_replies(@status)
-    distribute(@status)
+    distribute
     forward_for_reply
   end
 
+  def distribute
+    # Spread out crawling randomly to avoid DDoSing the link
+    LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id)
+
+    # Distribute into home and list feeds and notify mentioned accounts
+    ::DistributionWorker.perform_async(@status.id, { 'silenced_account_ids' => @silenced_account_ids }) if @options[:override_timestamps] || @status.within_realtime_window?
+  end
+
   def find_existing_status
     status   = status_from_uri(object_uri)
     status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
@@ -95,19 +104,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_status_params
+    @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url)
+
     @params = begin
       {
-        uri: object_uri,
-        url: object_url || object_uri,
+        uri: @status_parser.uri,
+        url: @status_parser.url || @status_parser.uri,
         account: @account,
-        text: text_from_content || '',
-        language: detected_language,
-        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
-        created_at: @object['published'],
+        text: converted_object_type? ? converted_text : (@status_parser.text || ''),
+        language: @status_parser.language || detected_language,
+        spoiler_text: converted_object_type? ? '' : (@status_parser.spoiler_text || ''),
+        created_at: @status_parser.created_at,
+        edited_at: @status_parser.edited_at,
         override_timestamps: @options[:override_timestamps],
-        reply: @object['inReplyTo'].present?,
-        sensitive: @account.sensitized? || @object['sensitive'] || false,
-        visibility: visibility_from_audience,
+        reply: @status_parser.reply,
+        sensitive: @account.sensitized? || @status_parser.sensitive || false,
+        visibility: @status_parser.visibility,
         thread: replied_to_status,
         conversation: conversation_from_uri(@object['conversation']),
         media_attachment_ids: process_attachments.take(4).map(&:id),
@@ -117,56 +129,63 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_audience
-    (audience_to + audience_cc).uniq.each do |audience|
-      next if ActivityPub::TagManager.instance.public_collection?(audience)
+    # Unlike with tags, there is no point in resolving accounts we don't already
+    # know here, because silent mentions would only be used for local access control anyway
+    accounts_in_audience = (audience_to + audience_cc).uniq.filter_map do |audience|
+      account_from_uri(audience) unless ActivityPub::TagManager.instance.public_collection?(audience)
+    end
 
-      # Unlike with tags, there is no point in resolving accounts we don't already
-      # know here, because silent mentions would only be used for local access
-      # control anyway
-      account = account_from_uri(audience)
+    # If the payload was delivered to a specific inbox, the inbox owner must have
+    # access to it, unless they already have access to it anyway
+    if @options[:delivered_to_account_id]
+      accounts_in_audience << delivered_to_account
+      accounts_in_audience.uniq!
+    end
 
-      next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id }
+    accounts_in_audience.each do |account|
+      # This runs after tags are processed, and those translate into non-silent
+      # mentions, which take precedence
+      next if @mentions.any? { |mention| mention.account_id == account.id }
 
       @mentions << Mention.new(account: account, silent: true)
 
       # If there is at least one silent mention, then the status can be considered
       # as a limited-audience status, and not strictly a direct message, but only
       # if we considered a direct message in the first place
-      next unless @params[:visibility] == :direct && direct_message.nil?
-
-      @params[:visibility] = :limited
+      @params[:visibility] = :limited if @params[:visibility] == :direct && !@object['directMessage']
     end
 
-    # If the payload was delivered to a specific inbox, the inbox owner must have
-    # access to it, unless they already have access to it anyway
-    return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] }
-
-    @mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true)
-
-    return unless @params[:visibility] == :direct && direct_message.nil?
-
-    @params[:visibility] = :limited
+    # Accounts that are tagged but are not in the audience are not
+    # supposed to be notified explicitly
+    @silenced_account_ids = @mentions.map(&:account_id) - accounts_in_audience.map(&:id)
   end
 
   def postprocess_audience_and_deliver
     return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id])
 
-    delivered_to_account = Account.find(@options[:delivered_to_account_id])
-
     @status.mentions.create(account: delivered_to_account, silent: true)
-    @status.update(visibility: :limited) if @status.direct_visibility? && direct_message.nil?
+    @status.update(visibility: :limited) if @status.direct_visibility? && !@object['directMessage']
 
     return unless delivered_to_account.following?(@account)
 
-    FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home)
+    FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, 'home')
+  end
+
+  def delivered_to_account
+    @delivered_to_account ||= Account.find(@options[:delivered_to_account_id])
   end
 
   def attach_tags(status)
     @tags.each do |tag|
       status.tags << tag
-      tag.use!(@account, status: status, at_time: status.created_at) if status.public_visibility?
+      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
     end
 
+    # If we're processing an old status, this may register tags as being used now
+    # as opposed to when the status was really published, but this is probably
+    # not a big deal
+    Trends.tags.register(status)
+
     @mentions.each do |mention|
       mention.status = status
       mention.save
@@ -210,21 +229,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def process_emoji(tag)
     return if skip_download?
-    return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
 
-    shortcode = tag['name'].delete(':')
-    image_url = tag['icon']['url']
-    uri       = tag['id']
-    updated   = tag['updated']
-    emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
+    custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
 
-    return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
+    return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
 
-    emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
-    emoji.image_remote_url = image_url
-    emoji.save
-  rescue Seahorse::Client::NetworkingError
-    nil
+    emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
+
+    return unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
+
+    begin
+      emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri)
+      emoji.image_remote_url = custom_emoji_parser.image_remote_url
+      emoji.save
+    rescue Seahorse::Client::NetworkingError => e
+      Rails.logger.warn "Error storing emoji: #{e}"
+    end
   end
 
   def process_attachments
@@ -233,22 +253,31 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     media_attachments = []
 
     as_array(@object['attachment']).each do |attachment|
-      next if attachment['url'].blank? || media_attachments.size >= 4
+      media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
+
+      next if media_attachment_parser.remote_url.blank? || media_attachments.size >= 4
 
       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.create(
+          account: @account,
+          remote_url: media_attachment_parser.remote_url,
+          thumbnail_remote_url: media_attachment_parser.thumbnail_remote_url,
+          description: media_attachment_parser.description,
+          focus: media_attachment_parser.focus,
+          blurhash: media_attachment_parser.blurhash
+        )
+
         media_attachments << media_attachment
 
-        next if unsupported_media_type?(attachment['mediaType']) || skip_download?
+        next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
 
         media_attachment.download_file!
         media_attachment.download_thumbnail!
         media_attachment.save
       rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
         RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
-      rescue Seahorse::Client::NetworkingError
-        nil
+      rescue Seahorse::Client::NetworkingError => e
+        Rails.logger.warn "Error storing media attachment: #{e}"
       end
     end
 
@@ -258,42 +287,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     media_attachments
   end
 
-  def icon_url_from_attachment(attachment)
-    url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon']
-    Addressable::URI.parse(url).normalize.to_s if url.present?
-  rescue Addressable::URI::InvalidURIError
-    nil
-  end
-
   def process_poll
-    return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array))
-
-    expires_at = begin
-      if @object['closed'].is_a?(String)
-        @object['closed']
-      elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass)
-        Time.now.utc
-      else
-        @object['endTime']
-      end
-    end
-
-    if @object['anyOf'].is_a?(Array)
-      multiple = true
-      items    = @object['anyOf']
-    else
-      multiple = false
-      items    = @object['oneOf']
-    end
+    poll_parser = ActivityPub::Parser::PollParser.new(@object)
 
-    voters_count = @object['votersCount']
+    return unless poll_parser.valid?
 
     @account.polls.new(
-      multiple: multiple,
-      expires_at: expires_at,
-      options: items.map { |item| item['name'].presence || item['content'] }.compact,
-      cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
-      voters_count: voters_count
+      multiple: poll_parser.multiple,
+      expires_at: poll_parser.expires_at,
+      options: poll_parser.options,
+      cached_tallies: poll_parser.cached_tallies,
+      voters_count: poll_parser.voters_count
     )
   end
 
@@ -346,29 +350,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     end
   end
 
-  def visibility_from_audience
-    if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
-      :public
-    elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
-      :unlisted
-    elsif audience_to.include?(@account.followers_url)
-      :private
-    elsif direct_message == false
-      :limited
-    else
-      :direct
-    end
-  end
-
-  def audience_includes?(account)
-    uri = ActivityPub::TagManager.instance.uri_for(account)
-    audience_to.include?(uri) || audience_cc.include?(uri)
-  end
-
-  def direct_message
-    @object['directMessage']
-  end
-
   def replied_to_status
     return @replied_to_status if defined?(@replied_to_status)
 
@@ -385,77 +366,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     value_or_id(@object['inReplyTo'])
   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?
-
-    if @object['content'].present?
-      @object['content']
-    elsif content_language_map?
-      @object['contentMap'].values.first
-    end
-  end
-
-  def text_from_summary
-    if @object['summary'].present?
-      @object['summary']
-    elsif summary_language_map?
-      @object['summaryMap'].values.first
-    end
-  end
-
-  def text_from_name
-    if @object['name'].present?
-      @object['name']
-    elsif name_language_map?
-      @object['nameMap'].values.first
-    end
+  def converted_text
+    Formatter.instance.linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n"))
   end
 
   def detected_language
-    if content_language_map?
-      @object['contentMap'].keys.first
-    elsif name_language_map?
-      @object['nameMap'].keys.first
-    elsif summary_language_map?
-      @object['summaryMap'].keys.first
-    elsif supported_object_type?
-      LanguageDetector.instance.detect(text_from_content, @account)
-    end
-  end
-
-  def object_url
-    return if @object['url'].blank?
-
-    url_candidate = url_to_href(@object['url'], 'text/html')
-
-    if invalid_origin?(url_candidate)
-      nil
-    else
-      url_candidate
-    end
-  end
-
-  def summary_language_map?
-    @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
-  end
-
-  def content_language_map?
-    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
-  end
-
-  def name_language_map?
-    @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
+    LanguageDetector.instance.detect(@status_parser.text, @account) if supported_object_type?
   end
 
   def unsupported_media_type?(mime_type)
     mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
   end
 
-  def supported_blurhash?(blurhash)
-    components = blurhash.blank? ? nil : Blurhash.components(blurhash)
-    components.present? && components.none? { |comp| comp > 5 }
-  end
-
   def skip_download?
     return @skip_download if defined?(@skip_download)
 
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
index 018e2df54..f04ad321b 100644
--- a/app/lib/activitypub/activity/update.rb
+++ b/app/lib/activitypub/activity/update.rb
@@ -1,32 +1,31 @@
 # frozen_string_literal: true
 
 class ActivityPub::Activity::Update < ActivityPub::Activity
-  SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
-
   def perform
     dereference_object!
 
-    if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
+    if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service))
       update_account
-    elsif equals_or_includes_any?(@object['type'], %w(Question))
-      update_poll
+    elsif equals_or_includes_any?(@object['type'], %w(Note Question))
+      update_status
     end
   end
 
   private
 
   def update_account
-    return if @account.uri != object_uri
+    return reject_payload! if @account.uri != object_uri
 
     ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
   end
 
-  def update_poll
+  def update_status
     return reject_payload! if invalid_origin?(@object['id'])
 
     status = Status.find_by(uri: object_uri, account_id: @account.id)
-    return if status.nil? || status.preloadable_poll.nil?
 
-    ActivityPub::ProcessPollService.new.call(status.preloadable_poll, @object)
+    return if status.nil?
+
+    ActivityPub::ProcessStatusUpdateService.new.call(status, @object)
   end
 end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index ef00a4e2e..d8b0c63b2 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -19,7 +19,6 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     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' } },
-    identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
     blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
     discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
     voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
diff --git a/app/lib/activitypub/parser/custom_emoji_parser.rb b/app/lib/activitypub/parser/custom_emoji_parser.rb
new file mode 100644
index 000000000..724c60215
--- /dev/null
+++ b/app/lib/activitypub/parser/custom_emoji_parser.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::CustomEmojiParser
+  include JsonLdHelper
+
+  def initialize(json)
+    @json = json
+  end
+
+  def uri
+    @json['id']
+  end
+
+  def shortcode
+    @json['name']&.delete(':')
+  end
+
+  def image_remote_url
+    @json.dig('icon', 'url')
+  end
+
+  def updated_at
+    @json['updated']&.to_datetime
+  rescue ArgumentError
+    nil
+  end
+end
diff --git a/app/lib/activitypub/parser/media_attachment_parser.rb b/app/lib/activitypub/parser/media_attachment_parser.rb
new file mode 100644
index 000000000..1798e58a4
--- /dev/null
+++ b/app/lib/activitypub/parser/media_attachment_parser.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::MediaAttachmentParser
+  include JsonLdHelper
+
+  def initialize(json)
+    @json = json
+  end
+
+  # @param [MediaAttachment] previous_record
+  def significantly_changes?(previous_record)
+    remote_url != previous_record.remote_url ||
+      thumbnail_remote_url != previous_record.thumbnail_remote_url ||
+      description != previous_record.description
+  end
+
+  def remote_url
+    Addressable::URI.parse(@json['url'])&.normalize&.to_s
+  rescue Addressable::URI::InvalidURIError
+    nil
+  end
+
+  def thumbnail_remote_url
+    Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
+  rescue Addressable::URI::InvalidURIError
+    nil
+  end
+
+  def description
+    @json['summary'].presence || @json['name'].presence
+  end
+
+  def focus
+    @json['focalPoint']
+  end
+
+  def blurhash
+    supported_blurhash? ? @json['blurhash'] : nil
+  end
+
+  def file_content_type
+    @json['mediaType']
+  end
+
+  private
+
+  def supported_blurhash?
+    components = begin
+      blurhash = @json['blurhash']
+
+      if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash)
+        Blurhash.components(blurhash)
+      end
+    end
+
+    components.present? && components.none? { |comp| comp > 5 }
+  end
+end
diff --git a/app/lib/activitypub/parser/poll_parser.rb b/app/lib/activitypub/parser/poll_parser.rb
new file mode 100644
index 000000000..758c03f07
--- /dev/null
+++ b/app/lib/activitypub/parser/poll_parser.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::PollParser
+  include JsonLdHelper
+
+  def initialize(json)
+    @json = json
+  end
+
+  def valid?
+    equals_or_includes?(@json['type'], 'Question') && items.is_a?(Array)
+  end
+
+  # @param [Poll] previous_record
+  def significantly_changes?(previous_record)
+    options != previous_record.options ||
+      multiple != previous_record.multiple
+  end
+
+  def options
+    items.filter_map { |item| item['name'].presence || item['content'] }
+  end
+
+  def multiple
+    @json['anyOf'].is_a?(Array)
+  end
+
+  def expires_at
+    if @json['closed'].is_a?(String)
+      @json['closed'].to_datetime
+    elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
+      Time.now.utc
+    else
+      @json['endTime']&.to_datetime
+    end
+  rescue ArgumentError
+    nil
+  end
+
+  def voters_count
+    @json['votersCount']
+  end
+
+  def cached_tallies
+    items.map { |item| item.dig('replies', 'totalItems') || 0 }
+  end
+
+  private
+
+  def items
+    @json['anyOf'] || @json['oneOf']
+  end
+end
diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb
new file mode 100644
index 000000000..75b8f3d5c
--- /dev/null
+++ b/app/lib/activitypub/parser/status_parser.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::StatusParser
+  include JsonLdHelper
+
+  # @param [Hash] json
+  # @param [Hash] magic_values
+  # @option magic_values [String] :followers_collection
+  def initialize(json, magic_values = {})
+    @json         = json
+    @object       = json['object'] || json
+    @magic_values = magic_values
+  end
+
+  def uri
+    id = @object['id']
+
+    if id&.start_with?('bear:')
+      Addressable::URI.parse(id).query_values['u']
+    else
+      id
+    end
+  rescue Addressable::URI::InvalidURIError
+    id
+  end
+
+  def url
+    url_to_href(@object['url'], 'text/html') if @object['url'].present?
+  end
+
+  def text
+    if @object['content'].present?
+      @object['content']
+    elsif content_language_map?
+      @object['contentMap'].values.first
+    end
+  end
+
+  def spoiler_text
+    if @object['summary'].present?
+      @object['summary']
+    elsif summary_language_map?
+      @object['summaryMap'].values.first
+    end
+  end
+
+  def title
+    if @object['name'].present?
+      @object['name']
+    elsif name_language_map?
+      @object['nameMap'].values.first
+    end
+  end
+
+  def created_at
+    @object['published']&.to_datetime
+  rescue ArgumentError
+    nil
+  end
+
+  def edited_at
+    @object['updated']&.to_datetime
+  rescue ArgumentError
+    nil
+  end
+
+  def reply
+    @object['inReplyTo'].present?
+  end
+
+  def sensitive
+    @object['sensitive']
+  end
+
+  def visibility
+    if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
+      :public
+    elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
+      :unlisted
+    elsif audience_to.include?(@magic_values[:followers_collection])
+      :private
+    elsif direct_message == false
+      :limited
+    else
+      :direct
+    end
+  end
+
+  def language
+    if content_language_map?
+      @object['contentMap'].keys.first
+    elsif name_language_map?
+      @object['nameMap'].keys.first
+    elsif summary_language_map?
+      @object['summaryMap'].keys.first
+    end
+  end
+
+  def direct_message
+    @object['directMessage']
+  end
+
+  private
+
+  def audience_to
+    as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
+  end
+
+  def audience_cc
+    as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
+  end
+
+  def summary_language_map?
+    @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
+  end
+
+  def content_language_map?
+    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
+  end
+
+  def name_language_map?
+    @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
+  end
+end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index f6b5e10d3..f6b9741fa 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -64,6 +64,10 @@ class ActivityPub::TagManager
     account_status_replies_url(target.account, target, page_params)
   end
 
+  def followers_uri_for(target)
+    target.local? ? account_followers_url(target) : target.followers_url.presence
+  end
+
   # Primary audience of a status
   # Public statuses go out to primarily the public collection
   # Unlisted and private statuses go out primarily to the followers collection
@@ -80,17 +84,17 @@ class ActivityPub::TagManager
         account_ids = status.active_mentions.pluck(:account_id)
         to = status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
           result << uri_for(account)
-          result << account_followers_url(account) if account.group?
+          result << followers_uri_for(account) if account.group?
         end
         to.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
           result << uri_for(request.account)
-          result << account_followers_url(request.account) if request.account.group?
-        end)
+          result << followers_uri_for(request.account) if request.account.group?
+        end).compact
       else
         status.active_mentions.each_with_object([]) do |mention, result|
           result << uri_for(mention.account)
-          result << account_followers_url(mention.account) if mention.account.group?
-        end
+          result << followers_uri_for(mention.account) if mention.account.group?
+        end.compact
       end
     end
   end
@@ -118,17 +122,17 @@ class ActivityPub::TagManager
         account_ids = status.active_mentions.pluck(:account_id)
         cc.concat(status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
           result << uri_for(account)
-          result << account_followers_url(account) if account.group?
-        end)
+          result << followers_uri_for(account) if account.group?
+        end.compact)
         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)
+          result << followers_uri_for(request.account) if request.account.group?
+        end.compact)
       else
         cc.concat(status.active_mentions.each_with_object([]) do |mention, result|
           result << uri_for(mention.account)
-          result << account_followers_url(mention.account) if mention.account.group?
-        end)
+          result << followers_uri_for(mention.account) if mention.account.group?
+        end.compact)
       end
     end
 
diff --git a/app/lib/admin/metrics/dimension.rb b/app/lib/admin/metrics/dimension.rb
new file mode 100644
index 000000000..d8392ddfc
--- /dev/null
+++ b/app/lib/admin/metrics/dimension.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension
+  DIMENSIONS = {
+    languages: Admin::Metrics::Dimension::LanguagesDimension,
+    sources: Admin::Metrics::Dimension::SourcesDimension,
+    servers: Admin::Metrics::Dimension::ServersDimension,
+    space_usage: Admin::Metrics::Dimension::SpaceUsageDimension,
+    software_versions: Admin::Metrics::Dimension::SoftwareVersionsDimension,
+    tag_servers: Admin::Metrics::Dimension::TagServersDimension,
+    tag_languages: Admin::Metrics::Dimension::TagLanguagesDimension,
+  }.freeze
+
+  def self.retrieve(dimension_keys, start_at, end_at, limit, params)
+    Array(dimension_keys).map do |key|
+      klass = DIMENSIONS[key.to_sym]
+      klass&.new(start_at, end_at, limit, klass.with_params? ? params.require(key.to_sym) : nil)
+    end.compact
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/base_dimension.rb b/app/lib/admin/metrics/dimension/base_dimension.rb
new file mode 100644
index 000000000..5872c22cb
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/base_dimension.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::BaseDimension
+  def self.with_params?
+    false
+  end
+
+  def initialize(start_at, end_at, limit, params)
+    @start_at = start_at&.to_datetime
+    @end_at   = end_at&.to_datetime
+    @limit    = limit&.to_i
+    @params   = params
+  end
+
+  def key
+    raise NotImplementedError
+  end
+
+  def data
+    raise NotImplementedError
+  end
+
+  def self.model_name
+    self.class.name
+  end
+
+  def read_attribute_for_serialization(key)
+    send(key) if respond_to?(key)
+  end
+
+  protected
+
+  def time_period
+    (@start_at..@end_at)
+  end
+
+  def params
+    raise NotImplementedError
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/languages_dimension.rb b/app/lib/admin/metrics/dimension/languages_dimension.rb
new file mode 100644
index 000000000..a6aaf5d21
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/languages_dimension.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension
+  include LanguagesHelper
+
+  def key
+    'languages'
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT locale, count(*) AS value
+      FROM users
+      WHERE current_sign_in_at BETWEEN $1 AND $2
+        AND locale IS NOT NULL
+      GROUP BY locale
+      ORDER BY count(*) DESC
+      LIMIT $3
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
+
+    rows.map { |row| { key: row['locale'], human_key: human_locale(row['locale']), value: row['value'].to_s } }
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/servers_dimension.rb b/app/lib/admin/metrics/dimension/servers_dimension.rb
new file mode 100644
index 000000000..3e80b6625
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/servers_dimension.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension
+  def key
+    'servers'
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT accounts.domain, count(*) AS value
+      FROM statuses
+      INNER JOIN accounts ON accounts.id = statuses.account_id
+      WHERE statuses.id BETWEEN $1 AND $2
+      GROUP BY accounts.domain
+      ORDER BY count(*) DESC
+      LIMIT $3
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at)], [nil, Mastodon::Snowflake.id_at(@end_at)], [nil, @limit]])
+
+    rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/software_versions_dimension.rb b/app/lib/admin/metrics/dimension/software_versions_dimension.rb
new file mode 100644
index 000000000..34917404d
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/software_versions_dimension.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dimension::BaseDimension
+  include Redisable
+
+  def key
+    'software_versions'
+  end
+
+  def data
+    [mastodon_version, ruby_version, postgresql_version, redis_version]
+  end
+
+  private
+
+  def mastodon_version
+    value = Mastodon::Version.to_s
+
+    {
+      key: 'mastodon',
+      human_key: 'Mastodon',
+      value: value,
+      human_value: value,
+    }
+  end
+
+  def ruby_version
+    value = "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
+
+    {
+      key: 'ruby',
+      human_key: 'Ruby',
+      value: value,
+      human_value: value,
+    }
+  end
+
+  def postgresql_version
+    value = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
+
+    {
+      key: 'postgresql',
+      human_key: 'PostgreSQL',
+      value: value,
+      human_value: value,
+    }
+  end
+
+  def redis_version
+    value = redis_info['redis_version']
+
+    {
+      key: 'redis',
+      human_key: 'Redis',
+      value: value,
+      human_value: value,
+    }
+  end
+
+  def redis_info
+    @redis_info ||= begin
+      if redis.is_a?(Redis::Namespace)
+        redis.redis.info
+      else
+        redis.info
+      end
+    end
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/sources_dimension.rb b/app/lib/admin/metrics/dimension/sources_dimension.rb
new file mode 100644
index 000000000..a9f061809
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/sources_dimension.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension
+  def key
+    'sources'
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT oauth_applications.name, count(*) AS value
+      FROM users
+      LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id
+      WHERE users.created_at BETWEEN $1 AND $2
+      GROUP BY oauth_applications.name
+      ORDER BY count(*) DESC
+      LIMIT $3
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
+
+    rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/space_usage_dimension.rb b/app/lib/admin/metrics/dimension/space_usage_dimension.rb
new file mode 100644
index 000000000..aa00a2e18
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/space_usage_dimension.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension::BaseDimension
+  include Redisable
+  include ActionView::Helpers::NumberHelper
+
+  def key
+    'space_usage'
+  end
+
+  def data
+    [postgresql_size, redis_size, media_size]
+  end
+
+  private
+
+  def postgresql_size
+    value = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
+
+    {
+      key: 'postgresql',
+      human_key: 'PostgreSQL',
+      value: value.to_s,
+      unit: 'bytes',
+      human_value: number_to_human_size(value),
+    }
+  end
+
+  def redis_size
+    value = redis_info['used_memory']
+
+    {
+      key: 'redis',
+      human_key: 'Redis',
+      value: value.to_s,
+      unit: 'bytes',
+      human_value: number_to_human_size(value),
+    }
+  end
+
+  def media_size
+    value = [
+      MediaAttachment.sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')),
+      CustomEmoji.sum(:image_file_size),
+      PreviewCard.sum(:image_file_size),
+      Account.sum(Arel.sql('COALESCE(avatar_file_size, 0) + COALESCE(header_file_size, 0)')),
+      Backup.sum(:dump_file_size),
+      Import.sum(:data_file_size),
+      SiteUpload.sum(:file_file_size),
+    ].sum
+
+    {
+      key: 'media',
+      human_key: I18n.t('admin.dashboard.media_storage'),
+      value: value.to_s,
+      unit: 'bytes',
+      human_value: number_to_human_size(value),
+    }
+  end
+
+  def redis_info
+    @redis_info ||= begin
+      if redis.is_a?(Redis::Namespace)
+        redis.redis.info
+      else
+        redis.info
+      end
+    end
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/tag_languages_dimension.rb b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
new file mode 100644
index 000000000..1cfa07478
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
+  include LanguagesHelper
+
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_languages'
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
+      FROM statuses
+      INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
+      WHERE statuses_tags.tag_id = $1
+        AND statuses.id BETWEEN $2 AND $3
+      GROUP BY COALESCE(statuses.language, 'und')
+      ORDER BY count(*) DESC
+      LIMIT $4
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
+
+    rows.map { |row| { key: row['language'], human_key: human_locale(row['language']), value: row['value'].to_s } }
+  end
+
+  private
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/tag_servers_dimension.rb b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb
new file mode 100644
index 000000000..12c5980d7
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension::BaseDimension
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_servers'
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT accounts.domain, count(*) AS value
+      FROM statuses
+      INNER JOIN accounts ON accounts.id = statuses.account_id
+      INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
+      WHERE statuses_tags.tag_id = $1
+        AND statuses.id BETWEEN $2 AND $3
+      GROUP BY accounts.domain
+      ORDER BY count(*) DESC
+      LIMIT $4
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
+
+    rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
+  end
+
+  private
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/measure.rb b/app/lib/admin/metrics/measure.rb
new file mode 100644
index 000000000..a839498a1
--- /dev/null
+++ b/app/lib/admin/metrics/measure.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure
+  MEASURES = {
+    active_users: Admin::Metrics::Measure::ActiveUsersMeasure,
+    new_users: Admin::Metrics::Measure::NewUsersMeasure,
+    interactions: Admin::Metrics::Measure::InteractionsMeasure,
+    opened_reports: Admin::Metrics::Measure::OpenedReportsMeasure,
+    resolved_reports: Admin::Metrics::Measure::ResolvedReportsMeasure,
+    tag_accounts: Admin::Metrics::Measure::TagAccountsMeasure,
+    tag_uses: Admin::Metrics::Measure::TagUsesMeasure,
+    tag_servers: Admin::Metrics::Measure::TagServersMeasure,
+  }.freeze
+
+  def self.retrieve(measure_keys, start_at, end_at, params)
+    Array(measure_keys).map do |key|
+      klass = MEASURES[key.to_sym]
+      klass&.new(start_at, end_at, klass.with_params? ? params.require(key.to_sym) : nil)
+    end.compact
+  end
+end
diff --git a/app/lib/admin/metrics/measure/active_users_measure.rb b/app/lib/admin/metrics/measure/active_users_measure.rb
new file mode 100644
index 000000000..513189780
--- /dev/null
+++ b/app/lib/admin/metrics/measure/active_users_measure.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::ActiveUsersMeasure < Admin::Metrics::Measure::BaseMeasure
+  def key
+    'active_users'
+  end
+
+  def total
+    activity_tracker.sum(time_period.first, time_period.last)
+  end
+
+  def previous_total
+    activity_tracker.sum(previous_time_period.first, previous_time_period.last)
+  end
+
+  def data
+    activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } }
+  end
+
+  protected
+
+  def activity_tracker
+    @activity_tracker ||= ActivityTracker.new('activity:logins', :unique)
+  end
+
+  def time_period
+    (@start_at.to_date..@end_at.to_date)
+  end
+
+  def previous_time_period
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
+  end
+end
diff --git a/app/lib/admin/metrics/measure/base_measure.rb b/app/lib/admin/metrics/measure/base_measure.rb
new file mode 100644
index 000000000..0107ffd9c
--- /dev/null
+++ b/app/lib/admin/metrics/measure/base_measure.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::BaseMeasure
+  def self.with_params?
+    false
+  end
+
+  def initialize(start_at, end_at, params)
+    @start_at = start_at&.to_datetime
+    @end_at   = end_at&.to_datetime
+    @params   = params
+  end
+
+  def key
+    raise NotImplementedError
+  end
+
+  def total
+    raise NotImplementedError
+  end
+
+  def previous_total
+    raise NotImplementedError
+  end
+
+  def data
+    raise NotImplementedError
+  end
+
+  def self.model_name
+    self.class.name
+  end
+
+  def read_attribute_for_serialization(key)
+    send(key) if respond_to?(key)
+  end
+
+  protected
+
+  def time_period
+    (@start_at..@end_at)
+  end
+
+  def previous_time_period
+    ((@start_at - length_of_period)..(@end_at - length_of_period))
+  end
+
+  def length_of_period
+    @length_of_period ||= @end_at - @start_at
+  end
+
+  def params
+    raise NotImplementedError
+  end
+end
diff --git a/app/lib/admin/metrics/measure/interactions_measure.rb b/app/lib/admin/metrics/measure/interactions_measure.rb
new file mode 100644
index 000000000..b928fdb8f
--- /dev/null
+++ b/app/lib/admin/metrics/measure/interactions_measure.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::InteractionsMeasure < Admin::Metrics::Measure::BaseMeasure
+  def key
+    'interactions'
+  end
+
+  def total
+    activity_tracker.sum(time_period.first, time_period.last)
+  end
+
+  def previous_total
+    activity_tracker.sum(previous_time_period.first, previous_time_period.last)
+  end
+
+  def data
+    activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } }
+  end
+
+  protected
+
+  def activity_tracker
+    @activity_tracker ||= ActivityTracker.new('activity:interactions', :basic)
+  end
+
+  def time_period
+    (@start_at.to_date..@end_at.to_date)
+  end
+
+  def previous_time_period
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
+  end
+end
diff --git a/app/lib/admin/metrics/measure/new_users_measure.rb b/app/lib/admin/metrics/measure/new_users_measure.rb
new file mode 100644
index 000000000..b31679ad3
--- /dev/null
+++ b/app/lib/admin/metrics/measure/new_users_measure.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::NewUsersMeasure < Admin::Metrics::Measure::BaseMeasure
+  def key
+    'new_users'
+  end
+
+  def total
+    User.where(created_at: time_period).count
+  end
+
+  def previous_total
+    User.where(created_at: previous_time_period).count
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT axis.*, (
+        WITH new_users AS (
+          SELECT users.id
+          FROM users
+          WHERE date_trunc('day', users.created_at)::date = axis.period
+        )
+        SELECT count(*) FROM new_users
+      ) AS value
+      FROM (
+        SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+      ) AS axis
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
+
+    rows.map { |row| { date: row['period'], value: row['value'].to_s } }
+  end
+end
diff --git a/app/lib/admin/metrics/measure/opened_reports_measure.rb b/app/lib/admin/metrics/measure/opened_reports_measure.rb
new file mode 100644
index 000000000..9acc2c33d
--- /dev/null
+++ b/app/lib/admin/metrics/measure/opened_reports_measure.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::OpenedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
+  def key
+    'opened_reports'
+  end
+
+  def total
+    Report.where(created_at: time_period).count
+  end
+
+  def previous_total
+    Report.where(created_at: previous_time_period).count
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT axis.*, (
+        WITH new_reports AS (
+          SELECT reports.id
+          FROM reports
+          WHERE date_trunc('day', reports.created_at)::date = axis.period
+        )
+        SELECT count(*) FROM new_reports
+      ) AS value
+      FROM (
+        SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+      ) AS axis
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
+
+    rows.map { |row| { date: row['period'], value: row['value'].to_s } }
+  end
+end
diff --git a/app/lib/admin/metrics/measure/resolved_reports_measure.rb b/app/lib/admin/metrics/measure/resolved_reports_measure.rb
new file mode 100644
index 000000000..00cb24f7e
--- /dev/null
+++ b/app/lib/admin/metrics/measure/resolved_reports_measure.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
+  def key
+    'resolved_reports'
+  end
+
+  def total
+    Report.resolved.where(action_taken_at: time_period).count
+  end
+
+  def previous_total
+    Report.resolved.where(action_taken_at: previous_time_period).count
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT axis.*, (
+        WITH resolved_reports AS (
+          SELECT reports.id
+          FROM reports
+          WHERE date_trunc('day', reports.action_taken_at)::date = axis.period
+        )
+        SELECT count(*) FROM resolved_reports
+      ) AS value
+      FROM (
+        SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
+      ) AS axis
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
+
+    rows.map { |row| { date: row['period'], value: row['value'].to_s } }
+  end
+end
diff --git a/app/lib/admin/metrics/measure/tag_accounts_measure.rb b/app/lib/admin/metrics/measure/tag_accounts_measure.rb
new file mode 100644
index 000000000..ef773081b
--- /dev/null
+++ b/app/lib/admin/metrics/measure/tag_accounts_measure.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::TagAccountsMeasure < Admin::Metrics::Measure::BaseMeasure
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_accounts'
+  end
+
+  def total
+    tag.history.aggregate(time_period).accounts
+  end
+
+  def previous_total
+    tag.history.aggregate(previous_time_period).accounts
+  end
+
+  def data
+    time_period.map { |date| { date: date.to_time(:utc).iso8601, value: tag.history.get(date).accounts.to_s } }
+  end
+
+  protected
+
+  def tag
+    @tag ||= Tag.find(params[:id])
+  end
+
+  def time_period
+    (@start_at.to_date..@end_at.to_date)
+  end
+
+  def previous_time_period
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
+  end
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/measure/tag_servers_measure.rb b/app/lib/admin/metrics/measure/tag_servers_measure.rb
new file mode 100644
index 000000000..cc064f63f
--- /dev/null
+++ b/app/lib/admin/metrics/measure/tag_servers_measure.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::BaseMeasure
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_servers'
+  end
+
+  def total
+    tag.statuses.where('statuses.id BETWEEN ? AND ?', Mastodon::Snowflake.id_at(@start_at, with_random: false), Mastodon::Snowflake.id_at(@end_at, with_random: false)).joins(:account).count('distinct accounts.domain')
+  end
+
+  def previous_total
+    tag.statuses.where('statuses.id BETWEEN ? AND ?', Mastodon::Snowflake.id_at(@start_at - length_of_period, with_random: false), Mastodon::Snowflake.id_at(@end_at - length_of_period, with_random: false)).joins(:account).count('distinct accounts.domain')
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT axis.*, (
+        SELECT count(distinct accounts.domain) AS value
+        FROM statuses
+        INNER JOIN statuses_tags ON statuses.id = statuses_tags.status_id
+        INNER JOIN accounts ON statuses.account_id = accounts.id
+        WHERE statuses_tags.tag_id = $1
+          AND statuses.id BETWEEN $2 AND $3
+          AND date_trunc('day', statuses.created_at)::date = axis.day
+      )
+      FROM (
+        SELECT generate_series(date_trunc('day', $4::timestamp)::date, date_trunc('day', $5::timestamp)::date, ('1 day')::interval) AS day
+      ) as axis
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id].to_i], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @start_at], [nil, @end_at]])
+
+    rows.map { |row| { date: row['day'], value: row['value'].to_s } }
+  end
+
+  protected
+
+  def tag
+    @tag ||= Tag.find(params[:id])
+  end
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/measure/tag_uses_measure.rb b/app/lib/admin/metrics/measure/tag_uses_measure.rb
new file mode 100644
index 000000000..b7667bc6c
--- /dev/null
+++ b/app/lib/admin/metrics/measure/tag_uses_measure.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::TagUsesMeasure < Admin::Metrics::Measure::BaseMeasure
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_uses'
+  end
+
+  def total
+    tag.history.aggregate(time_period).uses
+  end
+
+  def previous_total
+    tag.history.aggregate(previous_time_period).uses
+  end
+
+  def data
+    time_period.map { |date| { date: date.to_time(:utc).iso8601, value: tag.history.get(date).uses.to_s } }
+  end
+
+  protected
+
+  def tag
+    @tag ||= Tag.find(params[:id])
+  end
+
+  def time_period
+    (@start_at.to_date..@end_at.to_date)
+  end
+
+  def previous_time_period
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
+  end
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/retention.rb b/app/lib/admin/metrics/retention.rb
new file mode 100644
index 000000000..0179a6e28
--- /dev/null
+++ b/app/lib/admin/metrics/retention.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Retention
+  class Cohort < ActiveModelSerializers::Model
+    attributes :period, :frequency, :data
+  end
+
+  class CohortData < ActiveModelSerializers::Model
+    attributes :date, :rate, :value
+  end
+
+  def initialize(start_at, end_at, frequency)
+    @start_at  = start_at&.to_date
+    @end_at    = end_at&.to_date
+    @frequency = %w(day month).include?(frequency) ? frequency : 'day'
+  end
+
+  def cohorts
+    sql = <<-SQL.squish
+      SELECT axis.*, (
+        WITH new_users AS (
+          SELECT users.id
+          FROM users
+          WHERE date_trunc($3, users.created_at)::date = axis.cohort_period
+        ),
+        retained_users AS (
+          SELECT users.id
+          FROM users
+          INNER JOIN new_users on new_users.id = users.id
+          WHERE date_trunc($3, users.current_sign_in_at) >= axis.retention_period
+        )
+        SELECT ARRAY[count(*), (count(*))::float / (SELECT GREATEST(count(*), 1) FROM new_users)] AS retention_value_and_rate
+        FROM retained_users
+      )
+      FROM (
+        WITH cohort_periods AS (
+          SELECT generate_series(date_trunc($3, $1::timestamp)::date, date_trunc($3, $2::timestamp)::date, ('1 ' || $3)::interval) AS cohort_period
+        ),
+        retention_periods AS (
+          SELECT cohort_period AS retention_period FROM cohort_periods
+        )
+        SELECT *
+        FROM cohort_periods, retention_periods
+        WHERE retention_period >= cohort_period
+      ) as axis
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @frequency]])
+
+    rows.each_with_object([]) do |row, arr|
+      current_cohort = arr.last
+
+      if current_cohort.nil? || current_cohort.period != row['cohort_period']
+        current_cohort = Cohort.new(period: row['cohort_period'], frequency: @frequency, data: [])
+        arr << current_cohort
+      end
+
+      value, rate = row['retention_value_and_rate'].delete('{}').split(',')
+
+      current_cohort.data << CohortData.new(
+        date: row['retention_period'],
+        rate: rate.to_f,
+        value: value.to_s
+      )
+    end
+  end
+end
diff --git a/app/lib/fast_geometry_parser.rb b/app/lib/fast_geometry_parser.rb
index 5209c2bc5..f3395a833 100644
--- a/app/lib/fast_geometry_parser.rb
+++ b/app/lib/fast_geometry_parser.rb
@@ -2,7 +2,7 @@
 
 class FastGeometryParser
   def self.from_file(file)
-    width, height = FastImage.size(file.path)
+    width, height = FastImage.size(file)
 
     raise Paperclip::Errors::NotIdentifiedByImageMagickError if width.nil?
 
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index d57508ef9..0713aa471 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -55,46 +55,50 @@ class FeedManager
   # Add a status to a home feed and send a streaming API update
   # @param [Account] account
   # @param [Status] status
+  # @param [Boolean] update
   # @return [Boolean]
-  def push_to_home(account, status)
+  def push_to_home(account, status, update: false)
     return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
 
     trim(:home, account.id)
-    PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}")
+    PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", { 'update' => update }) if push_update_required?("timeline:#{account.id}")
     true
   end
 
   # Remove a status from a home feed and send a streaming API update
   # @param [Account] account
   # @param [Status] status
+  # @param [Boolean] update
   # @return [Boolean]
-  def unpush_from_home(account, status)
+  def unpush_from_home(account, status, update: false)
     return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
 
-    redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
+    redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update
     true
   end
 
   # Add a status to a list feed and send a streaming API update
   # @param [List] list
   # @param [Status] status
+  # @param [Boolean] update
   # @return [Boolean]
-  def push_to_list(list, status)
+  def push_to_list(list, status, update: false)
     return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
 
     trim(:list, list.id)
-    PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
+    PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", { 'update' => update }) if push_update_required?("timeline:list:#{list.id}")
     true
   end
 
   # Remove a status from a list feed and send a streaming API update
   # @param [List] list
   # @param [Status] status
+  # @param [Boolean] update
   # @return [Boolean]
-  def unpush_from_list(list, status)
+  def unpush_from_list(list, status, update: false)
     return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
 
-    redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
+    redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update
     true
   end
 
@@ -102,11 +106,11 @@ class FeedManager
   # @param [Account] account
   # @param [Status] status
   # @return [Boolean]
-  def push_to_direct(account, status)
+  def push_to_direct(account, status, update: false)
     return false unless add_to_feed(:direct, account.id, status)
 
     trim(:direct, account.id)
-    PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}")
+    PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}") unless update
     true
   end
 
@@ -114,10 +118,10 @@ class FeedManager
   # @param [List] list
   # @param [Status] status
   # @return [Boolean]
-  def unpush_from_direct(account, status)
+  def unpush_from_direct(account, status, update: false)
     return false unless remove_from_feed(:direct, account.id, status)
 
-    redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
+    redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update
     true
   end
 
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index b26138642..f2c4beed5 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -279,39 +279,10 @@ class Formatter
     result.flatten.join
   end
 
-  UNICODE_ESCAPE_BLACKLIST_RE = /\p{Z}|\p{P}/
-
   def utf8_friendly_extractor(text, options = {})
-    old_to_new_index = [0]
-
-    escaped = text.chars.map do |c|
-      output = begin
-        if c.ord.to_s(16).length > 2 && !UNICODE_ESCAPE_BLACKLIST_RE.match?(c)
-          CGI.escape(c)
-        else
-          c
-        end
-      end
-
-      old_to_new_index << old_to_new_index.last + output.length
-
-      output
-    end.join
-
     # Note: I couldn't obtain list_slug with @user/list-name format
     # for mention so this requires additional check
-    special = Extractor.extract_urls_with_indices(escaped, options).map do |extract|
-      new_indices = [
-        old_to_new_index.find_index(extract[:indices].first),
-        old_to_new_index.find_index(extract[:indices].last),
-      ]
-
-      next extract.merge(
-        indices: new_indices,
-        url: text[new_indices.first..new_indices.last - 1]
-      )
-    end
-
+    special = Extractor.extract_urls_with_indices(text, options)
     standard = Extractor.extract_entities_with_indices(text, options)
     extra = Extractor.extract_extra_uris_with_indices(text, options)
 
diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb
new file mode 100644
index 000000000..56ad0717b
--- /dev/null
+++ b/app/lib/link_details_extractor.rb
@@ -0,0 +1,249 @@
+# frozen_string_literal: true
+
+class LinkDetailsExtractor
+  include ActionView::Helpers::TagHelper
+
+  class StructuredData
+    SUPPORTED_TYPES = %w(
+      NewsArticle
+      WebPage
+    ).freeze
+
+    def initialize(data)
+      @data = data
+    end
+
+    def headline
+      json['headline']
+    end
+
+    def description
+      json['description']
+    end
+
+    def language
+      json['inLanguage']
+    end
+
+    def type
+      json['@type']
+    end
+
+    def image
+      obj = first_of_value(json['image'])
+
+      return obj['url'] if obj.is_a?(Hash)
+
+      obj
+    end
+
+    def date_published
+      json['datePublished']
+    end
+
+    def date_modified
+      json['dateModified']
+    end
+
+    def author_name
+      author['name']
+    end
+
+    def author_url
+      author['url']
+    end
+
+    def publisher_name
+      publisher['name']
+    end
+
+    def publisher_logo
+      publisher.dig('logo', 'url')
+    end
+
+    private
+
+    def author
+      first_of_value(json['author']) || {}
+    end
+
+    def publisher
+      first_of_value(json['publisher']) || {}
+    end
+
+    def first_of_value(arr)
+      arr.is_a?(Array) ? arr.first : arr
+    end
+
+    def root_array(root)
+      root.is_a?(Array) ? root : [root]
+    end
+
+    def json
+      @json ||= root_array(Oj.load(@data)).find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {}
+    end
+  end
+
+  def initialize(original_url, html, html_charset)
+    @original_url = Addressable::URI.parse(original_url)
+    @html         = html
+    @html_charset = html_charset
+  end
+
+  def to_preview_card_attributes
+    {
+      title: title || '',
+      description: description || '',
+      image_remote_url: image,
+      type: type,
+      link_type: link_type,
+      width: width || 0,
+      height: height || 0,
+      html: html || '',
+      provider_name: provider_name || '',
+      provider_url: provider_url || '',
+      author_name: author_name || '',
+      author_url: author_url || '',
+      embed_url: embed_url || '',
+      language: language,
+    }
+  end
+
+  def type
+    player_url.present? ? :video : :link
+  end
+
+  def link_type
+    if structured_data&.type == 'NewsArticle' || opengraph_tag('og:type') == 'article'
+      :article
+    else
+      :unknown
+    end
+  end
+
+  def html
+    player_url.present? ? content_tag(:iframe, nil, src: player_url, width: width, height: height, allowtransparency: 'true', scrolling: 'no', frameborder: '0') : nil
+  end
+
+  def width
+    opengraph_tag('twitter:player:width')
+  end
+
+  def height
+    opengraph_tag('twitter:player:height')
+  end
+
+  def title
+    structured_data&.headline || opengraph_tag('og:title') || document.xpath('//title').map(&:content).first
+  end
+
+  def description
+    structured_data&.description || opengraph_tag('og:description') || meta_tag('description')
+  end
+
+  def image
+    valid_url_or_nil(opengraph_tag('og:image'))
+  end
+
+  def canonical_url
+    valid_url_or_nil(opengraph_tag('og:url') || link_tag('canonical'), same_origin_only: true) || @original_url.to_s
+  end
+
+  def provider_name
+    structured_data&.publisher_name || opengraph_tag('og:site_name')
+  end
+
+  def provider_url
+    valid_url_or_nil(host_to_url(opengraph_tag('og:site')))
+  end
+
+  def author_name
+    structured_data&.author_name || opengraph_tag('og:author') || opengraph_tag('og:author:username')
+  end
+
+  def author_url
+    structured_data&.author_url
+  end
+
+  def embed_url
+    valid_url_or_nil(opengraph_tag('twitter:player:stream'))
+  end
+
+  def language
+    valid_locale_or_nil(structured_data&.language || opengraph_tag('og:locale') || document.xpath('//html').map { |element| element['lang'] }.first)
+  end
+
+  def icon
+    valid_url_or_nil(structured_data&.publisher_icon || link_tag('apple-touch-icon') || link_tag('shortcut icon'))
+  end
+
+  private
+
+  def player_url
+    valid_url_or_nil(opengraph_tag('twitter:player'))
+  end
+
+  def host_to_url(str)
+    return if str.blank?
+
+    str.start_with?(/https?:\/\//) ? str : "http://#{str}"
+  end
+
+  def valid_url_or_nil(str, same_origin_only: false)
+    return if str.blank?
+
+    url = @original_url + Addressable::URI.parse(str)
+
+    return if url.host.blank? || !%w(http https).include?(url.scheme) || (same_origin_only && url.host != @original_url.host)
+
+    url.to_s
+  rescue Addressable::URI::InvalidURIError
+    nil
+  end
+
+  def valid_locale_or_nil(str)
+    return nil if str.blank?
+
+    code,  = str.split(/_-/) # Strip out the region from e.g. en_US or ja-JA
+    locale = ISO_639.find(code)
+    locale&.alpha2
+  end
+
+  def link_tag(name)
+    document.xpath("//link[@rel=\"#{name}\"]").map { |link| link['href'] }.first
+  end
+
+  def opengraph_tag(name)
+    document.xpath("//meta[@property=\"#{name}\" or @name=\"#{name}\"]").map { |meta| meta['content'] }.first
+  end
+
+  def meta_tag(name)
+    document.xpath("//meta[@name=\"#{name}\"]").map { |meta| meta['content'] }.first
+  end
+
+  def structured_data
+    @structured_data ||= begin
+      json_ld = document.xpath('//script[@type="application/ld+json"]').map(&:content).first
+      json_ld.present? ? StructuredData.new(json_ld) : nil
+    rescue Oj::ParseError
+      nil
+    end
+  end
+
+  def document
+    @document ||= Nokogiri::HTML(@html, nil, encoding)
+  end
+
+  def encoding
+    @encoding ||= begin
+      guess = detector.detect(@html, @html_charset)
+      guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil
+    end
+  end
+
+  def detector
+    @detector ||= CharlockHolmes::EncodingDetector.new.tap do |detector|
+      detector.strip_tags = true
+    end
+  end
+end
diff --git a/app/lib/permalink_redirector.rb b/app/lib/permalink_redirector.rb
new file mode 100644
index 000000000..e48bce060
--- /dev/null
+++ b/app/lib/permalink_redirector.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+class PermalinkRedirector
+  include RoutingHelper
+
+  def initialize(path)
+    @path = path
+  end
+
+  def redirect_path
+    if path_segments[0] == 'web'
+      if path_segments[1].present? && path_segments[1].start_with?('@') && path_segments[2] =~ /\d/
+        find_status_url_by_id(path_segments[2])
+      elsif path_segments[1].present? && path_segments[1].start_with?('@')
+        find_account_url_by_name(path_segments[1])
+      elsif path_segments[1] == 'statuses' && path_segments[2] =~ /\d/
+        find_status_url_by_id(path_segments[2])
+      elsif path_segments[1] == 'accounts' && path_segments[2] =~ /\d/
+        find_account_url_by_id(path_segments[2])
+      elsif path_segments[1] == 'timelines' && path_segments[2] == 'tag' && path_segments[3].present?
+        find_tag_url_by_name(path_segments[3])
+      elsif path_segments[1] == 'tags' && path_segments[2].present?
+        find_tag_url_by_name(path_segments[2])
+      end
+    end
+  end
+
+  private
+
+  def path_segments
+    @path_segments ||= @path.gsub(/\A\//, '').split('/')
+  end
+
+  def find_status_url_by_id(id)
+    status = Status.find_by(id: id)
+
+    return unless status&.distributable?
+
+    ActivityPub::TagManager.instance.url_for(status)
+  end
+
+  def find_account_url_by_id(id)
+    account = Account.find_by(id: id)
+
+    return unless account
+
+    ActivityPub::TagManager.instance.url_for(account)
+  end
+
+  def find_account_url_by_name(name)
+    username, domain = name.gsub(/\A@/, '').split('@')
+    domain           = nil if TagManager.instance.local_domain?(domain)
+    account          = Account.find_remote(username, domain)
+
+    return unless account
+
+    ActivityPub::TagManager.instance.url_for(account)
+  end
+
+  def find_tag_url_by_name(name)
+    tag_path(CGI.unescape(name))
+  end
+end
diff --git a/app/lib/proof_provider.rb b/app/lib/proof_provider.rb
deleted file mode 100644
index 102c50f4f..000000000
--- a/app/lib/proof_provider.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-module ProofProvider
-  SUPPORTED_PROVIDERS = %w(keybase).freeze
-
-  def self.find(identifier, proof = nil)
-    case identifier
-    when 'keybase'
-      ProofProvider::Keybase.new(proof)
-    end
-  end
-end
diff --git a/app/lib/proof_provider/keybase.rb b/app/lib/proof_provider/keybase.rb
deleted file mode 100644
index 8e51d7146..000000000
--- a/app/lib/proof_provider/keybase.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-class ProofProvider::Keybase
-  BASE_URL = ENV.fetch('KEYBASE_BASE_URL', 'https://keybase.io')
-  DOMAIN   = ENV.fetch('KEYBASE_DOMAIN', Rails.configuration.x.web_domain)
-
-  class Error < StandardError; end
-
-  class ExpectedProofLiveError < Error; end
-
-  class UnexpectedResponseError < Error; end
-
-  def initialize(proof = nil)
-    @proof = proof
-  end
-
-  def serializer_class
-    ProofProvider::Keybase::Serializer
-  end
-
-  def worker_class
-    ProofProvider::Keybase::Worker
-  end
-
-  def validate!
-    unless @proof.token&.size == 66
-      @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.invalid_token'))
-      return
-    end
-
-    # Do not perform synchronous validation for remote accounts
-    return if @proof.provider_username.blank? || !@proof.account.local?
-
-    if verifier.valid?
-      @proof.verified = true
-      @proof.live     = false
-    else
-      @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.verification_failed', kb_username: @proof.provider_username))
-    end
-  end
-
-  def refresh!
-    worker_class.new.perform(@proof)
-  rescue ProofProvider::Keybase::Error
-    nil
-  end
-
-  def on_success_path(user_agent = nil)
-    verifier.on_success_path(user_agent)
-  end
-
-  def badge
-    @badge ||= ProofProvider::Keybase::Badge.new(@proof.account.username, @proof.provider_username, @proof.token, domain)
-  end
-
-  def verifier
-    @verifier ||= ProofProvider::Keybase::Verifier.new(@proof.account.username, @proof.provider_username, @proof.token, domain)
-  end
-
-  private
-
-  def domain
-    if @proof.account.local?
-      DOMAIN
-    else
-      @proof.account.domain
-    end
-  end
-end
diff --git a/app/lib/proof_provider/keybase/badge.rb b/app/lib/proof_provider/keybase/badge.rb
deleted file mode 100644
index f587b1cc7..000000000
--- a/app/lib/proof_provider/keybase/badge.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-class ProofProvider::Keybase::Badge
-  include RoutingHelper
-
-  def initialize(local_username, provider_username, token, domain)
-    @local_username    = local_username
-    @provider_username = provider_username
-    @token             = token
-    @domain            = domain
-  end
-
-  def proof_url
-    "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/sigchain\##{@token}"
-  end
-
-  def profile_url
-    "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}"
-  end
-
-  def icon_url
-    "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/proof_badge/#{@token}?username=#{@local_username}&domain=#{@domain}"
-  end
-
-  def avatar_url
-    Rails.cache.fetch("proof_providers/keybase/#{@provider_username}/avatar_url", expires_in: 5.minutes) { remote_avatar_url } || default_avatar_url
-  end
-
-  private
-
-  def remote_avatar_url
-    request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/user/pic_url.json", params: { username: @provider_username })
-
-    request.perform do |res|
-      json = Oj.load(res.body_with_limit, mode: :strict)
-      json['pic_url'] if json.is_a?(Hash)
-    end
-  rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
-    nil
-  end
-
-  def default_avatar_url
-    asset_pack_path('media/images/proof_providers/keybase.png')
-  end
-end
diff --git a/app/lib/proof_provider/keybase/config_serializer.rb b/app/lib/proof_provider/keybase/config_serializer.rb
deleted file mode 100644
index c6c364d31..000000000
--- a/app/lib/proof_provider/keybase/config_serializer.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-# frozen_string_literal: true
-
-class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer
-  include RoutingHelper
-  include ActionView::Helpers::TextHelper
-
-  attributes :version, :domain, :display_name, :username,
-             :brand_color, :logo, :description, :prefill_url,
-             :profile_url, :check_url, :check_path, :avatar_path,
-             :contact
-
-  def version
-    1
-  end
-
-  def domain
-    ProofProvider::Keybase::DOMAIN
-  end
-
-  def display_name
-    Setting.site_title
-  end
-
-  def logo
-    {
-      svg_black: full_asset_url(asset_pack_path('media/images/logo_transparent_black.svg')),
-      svg_white: full_asset_url(asset_pack_path('media/images/logo_transparent_white.svg')),
-      svg_full: full_asset_url(asset_pack_path('media/images/logo.svg')),
-      svg_full_darkmode: full_asset_url(asset_pack_path('media/images/logo.svg')),
-    }
-  end
-
-  def brand_color
-    '#282c37'
-  end
-
-  def description
-    strip_tags(Setting.site_short_description.presence || I18n.t('about.about_mastodon_html'))
-  end
-
-  def username
-    { min: 1, max: 30, re: '[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?' }
-  end
-
-  def prefill_url
-    params = {
-      provider: 'keybase',
-      token: '%{sig_hash}',
-      provider_username: '%{kb_username}',
-      username: '%{username}',
-      user_agent: '%{kb_ua}',
-    }
-
-    CGI.unescape(new_settings_identity_proof_url(params))
-  end
-
-  def profile_url
-    CGI.unescape(short_account_url('%{username}'))
-  end
-
-  def check_url
-    CGI.unescape(api_proofs_url(username: '%{username}', provider: 'keybase'))
-  end
-
-  def check_path
-    ['signatures']
-  end
-
-  def avatar_path
-    ['avatar']
-  end
-
-  def contact
-    [Setting.site_contact_email.presence || 'unknown'].compact
-  end
-end
diff --git a/app/lib/proof_provider/keybase/serializer.rb b/app/lib/proof_provider/keybase/serializer.rb
deleted file mode 100644
index d29283600..000000000
--- a/app/lib/proof_provider/keybase/serializer.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-class ProofProvider::Keybase::Serializer < ActiveModel::Serializer
-  include RoutingHelper
-
-  attribute :avatar
-
-  has_many :identity_proofs, key: :signatures
-
-  def avatar
-    full_asset_url(object.avatar_original_url)
-  end
-
-  class AccountIdentityProofSerializer < ActiveModel::Serializer
-    attributes :sig_hash, :kb_username
-
-    def sig_hash
-      object.token
-    end
-
-    def kb_username
-      object.provider_username
-    end
-  end
-end
diff --git a/app/lib/proof_provider/keybase/verifier.rb b/app/lib/proof_provider/keybase/verifier.rb
deleted file mode 100644
index af69b1bfc..000000000
--- a/app/lib/proof_provider/keybase/verifier.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-class ProofProvider::Keybase::Verifier
-  def initialize(local_username, provider_username, token, domain)
-    @local_username    = local_username
-    @provider_username = provider_username
-    @token             = token
-    @domain            = domain
-  end
-
-  def valid?
-    request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_valid.json", params: query_params)
-
-    request.perform do |res|
-      json = Oj.load(res.body_with_limit, mode: :strict)
-
-      if json.is_a?(Hash)
-        json.fetch('proof_valid', false)
-      else
-        false
-      end
-    end
-  rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
-    false
-  end
-
-  def on_success_path(user_agent = nil)
-    url = Addressable::URI.parse("#{ProofProvider::Keybase::BASE_URL}/_/proof_creation_success")
-    url.query_values = query_params.merge(kb_ua: user_agent || 'unknown')
-    url.to_s
-  end
-
-  def status
-    request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_live.json", params: query_params)
-
-    request.perform do |res|
-      raise ProofProvider::Keybase::UnexpectedResponseError unless res.code == 200
-
-      json = Oj.load(res.body_with_limit, mode: :strict)
-
-      raise ProofProvider::Keybase::UnexpectedResponseError unless json.is_a?(Hash) && json.key?('proof_valid') && json.key?('proof_live')
-
-      json
-    end
-  rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
-    raise ProofProvider::Keybase::UnexpectedResponseError
-  end
-
-  private
-
-  def query_params
-    {
-      domain: @domain,
-      kb_username: @provider_username,
-      username: @local_username,
-      sig_hash: @token,
-    }
-  end
-end
diff --git a/app/lib/proof_provider/keybase/worker.rb b/app/lib/proof_provider/keybase/worker.rb
deleted file mode 100644
index bcdd18cc5..000000000
--- a/app/lib/proof_provider/keybase/worker.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-class ProofProvider::Keybase::Worker
-  include Sidekiq::Worker
-
-  sidekiq_options queue: 'pull', retry: 20, unique: :until_executed
-
-  sidekiq_retry_in do |count, exception|
-    # Retry aggressively when the proof is valid but not live in Keybase.
-    # This is likely because Keybase just hasn't noticed the proof being
-    # served from here yet.
-
-    if exception.class == ProofProvider::Keybase::ExpectedProofLiveError
-      case count
-      when 0..2 then 0.seconds
-      when 2..6 then 1.second
-      end
-    end
-  end
-
-  def perform(proof_id)
-    proof  = proof_id.is_a?(AccountIdentityProof) ? proof_id : AccountIdentityProof.find(proof_id)
-    status = proof.provider_instance.verifier.status
-
-    # If Keybase thinks the proof is valid, and it exists here in Mastodon,
-    # then it should be live. Keybase just has to notice that it's here
-    # and then update its state. That might take a couple seconds.
-    raise ProofProvider::Keybase::ExpectedProofLiveError if status['proof_valid'] && !status['proof_live']
-
-    proof.update!(verified: status['proof_valid'], live: status['proof_live'])
-  end
-end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 125dee3ea..4289da933 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -94,7 +94,7 @@ class Request
     end
 
     def http_client
-      HTTP.use(:auto_inflate).timeout(TIMEOUT.dup).follow(max_hops: 2)
+      HTTP.use(:auto_inflate).timeout(TIMEOUT.dup).follow(max_hops: 3)
     end
   end
 
diff --git a/app/lib/sidekiq_error_handler.rb b/app/lib/sidekiq_error_handler.rb
deleted file mode 100644
index ab555b1be..000000000
--- a/app/lib/sidekiq_error_handler.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-class SidekiqErrorHandler
-  BACKTRACE_LIMIT = 3
-
-  def call(*)
-    yield
-  rescue Mastodon::HostValidationError
-    # Do not retry
-  rescue => e
-    limit_backtrace_and_raise(e)
-  ensure
-    socket = Thread.current[:statsd_socket]
-    socket&.close
-    Thread.current[:statsd_socket] = nil
-  end
-
-  private
-
-  # rubocop:disable Naming/MethodParameterName
-  def limit_backtrace_and_raise(e)
-    e.set_backtrace(e.backtrace.first(BACKTRACE_LIMIT))
-    raise e
-  end
-  # rubocop:enable Naming/MethodParameterName
-end
diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb
index 735d66a4f..98e502bb6 100644
--- a/app/lib/status_reach_finder.rb
+++ b/app/lib/status_reach_finder.rb
@@ -1,8 +1,12 @@
 # frozen_string_literal: true
 
 class StatusReachFinder
-  def initialize(status)
-    @status = status
+  # @param [Status] status
+  # @param [Hash] options
+  # @option options [Boolean] :unsafe
+  def initialize(status, options = {})
+    @status  = status
+    @options = options
   end
 
   def inboxes
@@ -38,7 +42,7 @@ class StatusReachFinder
   end
 
   def replied_to_account_id
-    @status.in_reply_to_account_id
+    @status.in_reply_to_account_id if distributable?
   end
 
   def reblog_of_account_id
@@ -49,21 +53,26 @@ class StatusReachFinder
     @status.mentions.pluck(:account_id)
   end
 
+  # Beware: Reblogs can be created without the author having had access to the status
   def reblogs_account_ids
-    @status.reblogs.pluck(:account_id)
+    @status.reblogs.pluck(:account_id) if distributable? || unsafe?
   end
 
+  # Beware: Favourites can be created without the author having had access to the status
   def favourites_account_ids
-    @status.favourites.pluck(:account_id)
+    @status.favourites.pluck(:account_id) if distributable? || unsafe?
   end
 
+  # Beware: Replies can be created without the author having had access to the status
   def replies_account_ids
-    @status.replies.pluck(:account_id)
+    @status.replies.pluck(:account_id) if distributable? || unsafe?
   end
 
   def followers_inboxes
-    if @status.in_reply_to_local_account? && @status.distributable?
+    if @status.in_reply_to_local_account? && distributable?
       @status.account.followers.or(@status.thread.account.followers).inboxes
+    elsif @status.direct_visibility? || @status.limited_visibility?
+      []
     else
       @status.account.followers.inboxes
     end
@@ -76,4 +85,12 @@ class StatusReachFinder
       []
     end
   end
+
+  def distributable?
+    @status.public_visibility? || @status.unlisted_visibility?
+  end
+
+  def unsafe?
+    @options[:unsafe]
+  end
 end
diff --git a/app/lib/themes.rb b/app/lib/themes.rb
index 2147904e4..81e016d4a 100644
--- a/app/lib/themes.rb
+++ b/app/lib/themes.rb
@@ -14,16 +14,20 @@ class Themes
     result = Hash.new
     Dir.glob(Rails.root.join('app', 'javascript', 'flavours', '*', 'theme.yml')) do |path|
       data = YAML.load_file(path)
+      next unless data['pack']
+
       dir = File.dirname(path)
       name = File.basename(dir)
       locales = []
       screenshots = []
+
       if data['locales']
         Dir.glob(File.join(dir, data['locales'], '*.{js,json}')) do |locale|
           localeName = File.basename(locale, File.extname(locale))
           locales.push(localeName) unless localeName.match(/defaultMessages|whitelist|index/)
         end
       end
+
       if data['screenshot']
         if data['screenshot'].is_a? Array
           screenshots = data['screenshot']
@@ -31,38 +35,37 @@ class Themes
           screenshots.push(data['screenshot'])
         end
       end
-      if data['pack']
-        data['name'] = name
-        data['locales'] = locales
-        data['screenshot'] = screenshots
-        data['skin'] = { 'default' => [] }
-        result[name] = data
-      end
+
+      data['name'] = name
+      data['locales'] = locales
+      data['screenshot'] = screenshots
+      data['skin'] = { 'default' => [] }
+      result[name] = data
     end
 
     Dir.glob(Rails.root.join('app', 'javascript', 'skins', '*', '*')) do |path|
       ext = File.extname(path)
       skin = File.basename(path)
       name = File.basename(File.dirname(path))
-      if result[name]
-        if File.directory?(path)
-          pack = []
-          Dir.glob(File.join(path, '*.{css,scss}')) do |sheet|
-            pack.push(File.basename(sheet, File.extname(sheet)))
-          end
-        elsif ext.match(/^\.s?css$/i)
-          skin = File.basename(path, ext)
-          pack = ['common']
-        end
-        if skin != 'default'
-          result[name]['skin'][skin] = pack
+      next unless result[name]
+
+      if File.directory?(path)
+        pack = []
+        Dir.glob(File.join(path, '*.{css,scss}')) do |sheet|
+          pack.push(File.basename(sheet, File.extname(sheet)))
         end
+      elsif ext.match(/^\.s?css$/i)
+        skin = File.basename(path, ext)
+        pack = ['common']
+      end
+
+      if skin != 'default'
+        result[name]['skin'][skin] = pack
       end
     end
 
     @core = core
     @conf = result
-
   end
 
   def core
diff --git a/app/lib/webfinger.rb b/app/lib/webfinger.rb
index e0e022cea..1ffb5b4bf 100644
--- a/app/lib/webfinger.rb
+++ b/app/lib/webfinger.rb
@@ -46,7 +46,9 @@ class Webfinger
   def body_from_webfinger(url = standard_url, use_fallback = true)
     webfinger_request(url).perform do |res|
       if res.code == 200
-        res.body_with_limit
+        body = res.body_with_limit
+        raise Webfinger::Error, "Request for #{@uri} returned empty response" if body.empty?
+        body
       elsif res.code == 404 && use_fallback
         body_from_host_meta
       elsif res.code == 410
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index 11fd09e30..b23bd1296 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -25,13 +25,25 @@ class AdminMailer < ApplicationMailer
     end
   end
 
-  def new_trending_tag(recipient, tag)
-    @tag      = tag
-    @me       = recipient
-    @instance = Rails.configuration.x.local_domain
+  def new_trending_tags(recipient, tags)
+    @tags                = tags
+    @me                  = recipient
+    @instance            = Rails.configuration.x.local_domain
+    @lowest_trending_tag = Trends.tags.get(true, Trends.tags.options[:review_threshold]).last
+
+    locale_for_account(@me) do
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tags.subject', instance: @instance)
+    end
+  end
+
+  def new_trending_links(recipient, links)
+    @links                = links
+    @me                   = recipient
+    @instance             = Rails.configuration.x.local_domain
+    @lowest_trending_link = Trends.links.get(true, Trends.links.options[:review_threshold]).last
 
     locale_for_account(@me) do
-      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tag.subject', instance: @instance, name: @tag.name)
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance)
     end
   end
 end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 68d1c4507..5221a4892 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -160,11 +160,11 @@ class UserMailer < Devise::Mailer
     end
   end
 
-  def warning(user, warning, status_ids = nil)
+  def warning(user, warning)
     @resource = user
     @warning  = warning
     @instance = Rails.configuration.x.local_domain
-    @statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array)
+    @statuses = @warning.statuses.includes(:account, :preloadable_poll, :media_attachments, active_mentions: [:account])
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email,
diff --git a/app/models/account.rb b/app/models/account.rb
index 96f23979f..e41fdf003 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -58,15 +58,16 @@ class Account < ApplicationRecord
     hub_url
   )
 
-  USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
-  MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]\.\-]+[a-z0-9]+)?)/i
+  USERNAME_RE   = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
+  MENTION_RE    = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]\.\-]+[[:word:]]+)?)/i
+  URL_PREFIX_RE = /\Ahttp(s?):\/\/[^\/]+/
 
+  include Attachmentable
   include AccountAssociations
   include AccountAvatar
   include AccountFinderConcern
   include AccountHeader
   include AccountInteractions
-  include Attachmentable
   include Paginable
   include AccountCounters
   include DomainNormalizable
@@ -126,8 +127,9 @@ class Account < ApplicationRecord
 
   delegate :email,
            :unconfirmed_email,
-           :current_sign_in_ip,
            :current_sign_in_at,
+           :created_at,
+           :sign_up_ip,
            :confirmed?,
            :approved?,
            :pending?,
@@ -146,7 +148,7 @@ class Account < ApplicationRecord
 
   delegate :chosen_languages, to: :user, prefix: false, allow_nil: true
 
-  update_index('accounts#account', :self)
+  update_index('accounts', :self)
 
   def local?
     domain.nil?
@@ -236,11 +238,11 @@ class Account < ApplicationRecord
     suspended? && deletion_request.present?
   end
 
-  def suspend!(date: Time.now.utc, origin: :local)
+  def suspend!(date: Time.now.utc, origin: :local, block_email: true)
     transaction do
       create_deletion_request!
       update!(suspended_at: date, suspension_origin: origin)
-      create_canonical_email_block!
+      create_canonical_email_block! if block_email
     end
   end
 
@@ -299,7 +301,11 @@ class Account < ApplicationRecord
   end
 
   def fields
-    (self[:fields] || []).map { |f| Field.new(self, f) }
+    (self[:fields] || []).map do |f|
+      Field.new(self, f)
+    rescue
+      nil
+    end.compact
   end
 
   def fields_attributes=(attributes)
@@ -377,7 +383,7 @@ class Account < ApplicationRecord
   def synchronization_uri_prefix
     return 'local' if local?
 
-    @synchronization_uri_prefix ||= uri[/http(s?):\/\/[^\/]+\//]
+    @synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/"
   end
 
   class Field < ActiveModelSerializers::Model
@@ -423,6 +429,9 @@ class Account < ApplicationRecord
   end
 
   class << self
+    DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/.freeze
+    TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
+
     def readonly_attributes
       super - %w(statuses_count following_count followers_count)
     end
@@ -433,97 +442,99 @@ class Account < ApplicationRecord
     end
 
     def search_for(terms, limit = 10, offset = 0)
-      textsearch, query = generate_query_for_search(terms)
+      tsquery = generate_query_for_search(terms)
 
       sql = <<-SQL.squish
         SELECT
           accounts.*,
-          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
+          ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
         FROM accounts
-        WHERE #{query} @@ #{textsearch}
+        WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
           AND accounts.suspended_at IS NULL
           AND accounts.moved_to_account_id IS NULL
         ORDER BY rank DESC
-        LIMIT ? OFFSET ?
+        LIMIT :limit OFFSET :offset
       SQL
 
-      records = find_by_sql([sql, limit, offset])
+      records = find_by_sql([sql, limit: limit, offset: offset, tsquery: tsquery])
       ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
       records
     end
 
     def advanced_search_for(terms, account, limit = 10, following = false, offset = 0)
-      textsearch, query = generate_query_for_search(terms)
+      tsquery = generate_query_for_search(terms)
+      sql = advanced_search_for_sql_template(following)
+      records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
+      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
+      records
+    end
+
+    def from_text(text)
+      return [] if text.blank?
+
+      text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.filter_map do |(username, domain)|
+        domain = begin
+          if TagManager.instance.local_domain?(domain)
+            nil
+          else
+            TagManager.instance.normalize_domain(domain)
+          end
+        end
+        EntityCache.instance.mention(username, domain)
+      end
+    end
+
+    private
+
+    def generate_query_for_search(unsanitized_terms)
+      terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
+
+      # The final ":*" is for prefix search.
+      # The trailing space does not seem to fit any purpose, but `to_tsquery`
+      # behaves differently with and without a leading space if the terms start
+      # with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
+      # the same query.
+      "' #{terms} ':*"
+    end
 
+    def advanced_search_for_sql_template(following)
       if following
-        sql = <<-SQL.squish
+        <<-SQL.squish
           WITH first_degree AS (
             SELECT target_account_id
             FROM follows
-            WHERE account_id = ?
+            WHERE account_id = :id
             UNION ALL
-            SELECT ?
+            SELECT :id
           )
           SELECT
             accounts.*,
-            (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
+            (count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
           FROM accounts
-          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)
+          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
           WHERE accounts.id IN (SELECT * FROM first_degree)
-            AND #{query} @@ #{textsearch}
+            AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
             AND accounts.suspended_at IS NULL
             AND accounts.moved_to_account_id IS NULL
           GROUP BY accounts.id
           ORDER BY rank DESC
-          LIMIT ? OFFSET ?
+          LIMIT :limit OFFSET :offset
         SQL
-
-        records = find_by_sql([sql, account.id, account.id, account.id, limit, offset])
       else
-        sql = <<-SQL.squish
+        <<-SQL.squish
           SELECT
             accounts.*,
-            (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
+            (count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
           FROM accounts
-          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
-          WHERE #{query} @@ #{textsearch}
+          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
+          WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
             AND accounts.suspended_at IS NULL
             AND accounts.moved_to_account_id IS NULL
           GROUP BY accounts.id
           ORDER BY rank DESC
-          LIMIT ? OFFSET ?
+          LIMIT :limit OFFSET :offset
         SQL
-
-        records = find_by_sql([sql, account.id, account.id, limit, offset])
       end
-
-      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
-      records
-    end
-
-    def from_text(text)
-      return [] if text.blank?
-
-      text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.filter_map do |(username, domain)|
-        domain = begin
-          if TagManager.instance.local_domain?(domain)
-            nil
-          else
-            TagManager.instance.normalize_domain(domain)
-          end
-        end
-        EntityCache.instance.mention(username, domain)
-      end
-    end
-
-    private
-
-    def generate_query_for_search(terms)
-      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
-      textsearch = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
-      query      = "to_tsquery('simple', ''' ' || #{terms} || ' ''' || ':*')"
-
-      [textsearch, query]
     end
   end
 
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index 2b001385f..dcb174122 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -2,18 +2,15 @@
 
 class AccountFilter
   KEYS = %i(
-    local
-    remote
-    by_domain
-    active
-    pending
-    silenced
-    suspended
+    origin
+    status
+    permissions
     username
+    by_domain
     display_name
     email
     ip
-    staff
+    invited_by
     order
   ).freeze
 
@@ -21,11 +18,10 @@ class AccountFilter
 
   def initialize(params)
     @params = params
-    set_defaults!
   end
 
   def results
-    scope = Account.includes(:user).reorder(nil)
+    scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil)
 
     params.each do |key, value|
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
@@ -36,30 +32,16 @@ class AccountFilter
 
   private
 
-  def set_defaults!
-    params['local']  = '1' if params['remote'].blank?
-    params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? && params['pending'].blank?
-    params['order']  = 'recent' if params['order'].blank?
-  end
-
   def scope_for(key, value)
     case key.to_s
-    when 'local'
-      Account.local.without_instance_actor
-    when 'remote'
-      Account.remote
+    when 'origin'
+      origin_scope(value)
+    when 'permissions'
+      permissions_scope(value)
+    when 'status'
+      status_scope(value)
     when 'by_domain'
       Account.where(domain: value)
-    when 'active'
-      Account.without_suspended
-    when 'pending'
-      accounts_with_users.merge(User.pending)
-    when 'disabled'
-      accounts_with_users.merge(User.disabled)
-    when 'silenced'
-      Account.silenced
-    when 'suspended'
-      Account.suspended
     when 'username'
       Account.matches_username(value)
     when 'display_name'
@@ -68,8 +50,8 @@ class AccountFilter
       accounts_with_users.merge(User.matches_email(value))
     when 'ip'
       valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none
-    when 'staff'
-      accounts_with_users.merge(User.staff)
+    when 'invited_by'
+      invited_by_scope(value)
     when 'order'
       order_scope(value)
     else
@@ -77,21 +59,56 @@ class AccountFilter
     end
   end
 
+  def origin_scope(value)
+    case value.to_s
+    when 'local'
+      Account.local
+    when 'remote'
+      Account.remote
+    else
+      raise "Unknown origin: #{value}"
+    end
+  end
+
+  def status_scope(value)
+    case value.to_s
+    when 'active'
+      Account.without_suspended
+    when 'pending'
+      accounts_with_users.merge(User.pending)
+    when 'suspended'
+      Account.suspended
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
+
   def order_scope(value)
-    case value
+    case value.to_s
     when 'active'
-      params['remote'] ? Account.joins(:account_stat).by_recent_status : Account.joins(:user).by_recent_sign_in
+      accounts_with_users.left_joins(:account_stat).order(Arel.sql('coalesce(users.current_sign_in_at, account_stats.last_status_at, to_timestamp(0)) desc, accounts.id desc'))
     when 'recent'
       Account.recent
-    when 'alphabetic'
-      Account.alphabetic
     else
       raise "Unknown order: #{value}"
     end
   end
 
+  def invited_by_scope(value)
+    Account.left_joins(user: :invite).merge(Invite.where(user_id: value.to_s))
+  end
+
+  def permissions_scope(value)
+    case value.to_s
+    when 'staff'
+      accounts_with_users.merge(User.staff)
+    else
+      raise "Unknown permissions: #{value}"
+    end
+  end
+
   def accounts_with_users
-    Account.joins(:user)
+    Account.left_joins(:user)
   end
 
   def valid_ip?(value)
diff --git a/app/models/account_identity_proof.rb b/app/models/account_identity_proof.rb
deleted file mode 100644
index 10b66cccf..000000000
--- a/app/models/account_identity_proof.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-# == Schema Information
-#
-# Table name: account_identity_proofs
-#
-#  id                :bigint(8)        not null, primary key
-#  account_id        :bigint(8)
-#  provider          :string           default(""), not null
-#  provider_username :string           default(""), not null
-#  token             :text             default(""), not null
-#  verified          :boolean          default(FALSE), not null
-#  live              :boolean          default(FALSE), not null
-#  created_at        :datetime         not null
-#  updated_at        :datetime         not null
-#
-
-class AccountIdentityProof < ApplicationRecord
-  belongs_to :account
-
-  validates :provider, inclusion: { in: ProofProvider::SUPPORTED_PROVIDERS }
-  validates :provider_username, format: { with: /\A[a-z0-9_]+\z/i }, length: { minimum: 2, maximum: 30 }
-  validates :provider_username, uniqueness: { scope: [:account_id, :provider] }
-  validates :token, format: { with: /\A[a-f0-9]+\z/ }, length: { maximum: 66 }
-
-  validate :validate_with_provider, if: :token_changed?
-
-  scope :active, -> { where(verified: true, live: true) }
-
-  after_commit :queue_worker, if: :saved_change_to_token?
-
-  delegate :refresh!, :on_success_path, :badge, to: :provider_instance
-
-  def provider_instance
-    @provider_instance ||= ProofProvider.find(provider, self)
-  end
-
-  private
-
-  def queue_worker
-    provider_instance.worker_class.perform_async(id)
-  end
-
-  def validate_with_provider
-    provider_instance.validate!
-  end
-end
diff --git a/app/models/account_note.rb b/app/models/account_note.rb
index bf61df923..b338bc92f 100644
--- a/app/models/account_note.rb
+++ b/app/models/account_note.rb
@@ -17,4 +17,5 @@ class AccountNote < ApplicationRecord
   belongs_to :target_account, class_name: 'Account'
 
   validates :account_id, uniqueness: { scope: :target_account_id }
+  validates :comment, length: { maximum: 2_000 }
 end
diff --git a/app/models/account_stat.rb b/app/models/account_stat.rb
index 44da4f0d0..b49827267 100644
--- a/app/models/account_stat.rb
+++ b/app/models/account_stat.rb
@@ -15,8 +15,9 @@
 
 class AccountStat < ApplicationRecord
   self.locking_column = nil
+  self.ignored_columns = %w(lock_version)
 
   belongs_to :account, inverse_of: :account_stat
 
-  update_index('accounts#account', :account)
+  update_index('accounts', :account)
 end
diff --git a/app/models/account_statuses_cleanup_policy.rb b/app/models/account_statuses_cleanup_policy.rb
new file mode 100644
index 000000000..0f78c1a54
--- /dev/null
+++ b/app/models/account_statuses_cleanup_policy.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: account_statuses_cleanup_policies
+#
+#  id                 :bigint(8)        not null, primary key
+#  account_id         :bigint(8)        not null
+#  enabled            :boolean          default(TRUE), not null
+#  min_status_age     :integer          default(1209600), not null
+#  keep_direct        :boolean          default(TRUE), not null
+#  keep_pinned        :boolean          default(TRUE), not null
+#  keep_polls         :boolean          default(FALSE), not null
+#  keep_media         :boolean          default(FALSE), not null
+#  keep_self_fav      :boolean          default(TRUE), not null
+#  keep_self_bookmark :boolean          default(TRUE), not null
+#  min_favs           :integer
+#  min_reblogs        :integer
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
+#
+class AccountStatusesCleanupPolicy < ApplicationRecord
+  include Redisable
+
+  ALLOWED_MIN_STATUS_AGE = [
+    2.weeks.seconds,
+    1.month.seconds,
+    2.months.seconds,
+    3.months.seconds,
+    6.months.seconds,
+    1.year.seconds,
+    2.years.seconds,
+  ].freeze
+
+  EXCEPTION_BOOLS      = %w(keep_direct keep_pinned keep_polls keep_media keep_self_fav keep_self_bookmark).freeze
+  EXCEPTION_THRESHOLDS = %w(min_favs min_reblogs).freeze
+
+  # Depending on the cleanup policy, the query to discover the next
+  # statuses to delete my get expensive if the account has a lot of old
+  # statuses otherwise excluded from deletion by the other exceptions.
+  #
+  # Therefore, `EARLY_SEARCH_CUTOFF` is meant to be the maximum number of
+  # old statuses to be considered for deletion prior to checking exceptions.
+  #
+  # This is used in `compute_cutoff_id` to provide a `max_id` to
+  # `statuses_to_delete`.
+  EARLY_SEARCH_CUTOFF = 5_000
+
+  belongs_to :account
+
+  validates :min_status_age, inclusion: { in: ALLOWED_MIN_STATUS_AGE }
+  validates :min_favs, numericality: { greater_than_or_equal_to: 1, allow_nil: true }
+  validates :min_reblogs, numericality: { greater_than_or_equal_to: 1, allow_nil: true }
+  validate :validate_local_account
+
+  before_save :update_last_inspected
+
+  def statuses_to_delete(limit = 50, max_id = nil, min_id = nil)
+    scope = account.statuses
+    scope.merge!(old_enough_scope(max_id))
+    scope = scope.where(Status.arel_table[:id].gteq(min_id)) if min_id.present?
+    scope.merge!(without_popular_scope) unless min_favs.nil? && min_reblogs.nil?
+    scope.merge!(without_direct_scope) if keep_direct?
+    scope.merge!(without_pinned_scope) if keep_pinned?
+    scope.merge!(without_poll_scope) if keep_polls?
+    scope.merge!(without_media_scope) if keep_media?
+    scope.merge!(without_self_fav_scope) if keep_self_fav?
+    scope.merge!(without_self_bookmark_scope) if keep_self_bookmark?
+
+    scope.reorder(id: :asc).limit(limit)
+  end
+
+  # This computes a toot id such that:
+  # - the toot would be old enough to be candidate for deletion
+  # - there are at most EARLY_SEARCH_CUTOFF toots between the last inspected toot and this one
+  #
+  # The idea is to limit expensive SQL queries when an account has lots of toots excluded from
+  # deletion, while not starting anew on each run.
+  def compute_cutoff_id
+    min_id = last_inspected || 0
+    max_id = Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)
+    subquery = account.statuses.where(Status.arel_table[:id].gteq(min_id)).where(Status.arel_table[:id].lteq(max_id))
+    subquery = subquery.select(:id).reorder(id: :asc).limit(EARLY_SEARCH_CUTOFF)
+
+    # We're textually interpolating a subquery here as ActiveRecord seem to not provide
+    # a way to apply the limit to the subquery
+    Status.connection.execute("SELECT MAX(id) FROM (#{subquery.to_sql}) t").values.first.first
+  end
+
+  # The most important thing about `last_inspected` is that any toot older than it is guaranteed
+  # not to be kept by the policy regardless of its age.
+  def record_last_inspected(last_id)
+    redis.set("account_cleanup:#{account.id}", last_id, ex: 1.week.seconds)
+  end
+
+  def last_inspected
+    redis.get("account_cleanup:#{account.id}")&.to_i
+  end
+
+  def invalidate_last_inspected(status, action)
+    last_value = last_inspected
+    return if last_value.nil? || status.id > last_value || status.account_id != account_id
+
+    case action
+    when :unbookmark
+      return unless keep_self_bookmark?
+    when :unfav
+      return unless keep_self_fav?
+    when :unpin
+      return unless keep_pinned?
+    end
+
+    record_last_inspected(status.id)
+  end
+
+  private
+
+  def update_last_inspected
+    if EXCEPTION_BOOLS.map { |name| attribute_change_to_be_saved(name) }.compact.include?([true, false])
+      # Policy has been widened in such a way that any previously-inspected status
+      # may need to be deleted, so we'll have to start again.
+      redis.del("account_cleanup:#{account.id}")
+    end
+    if EXCEPTION_THRESHOLDS.map { |name| attribute_change_to_be_saved(name) }.compact.any? { |old, new| old.present? && (new.nil? || new > old) }
+      redis.del("account_cleanup:#{account.id}")
+    end
+  end
+
+  def validate_local_account
+    errors.add(:account, :invalid) unless account&.local?
+  end
+
+  def without_direct_scope
+    Status.where.not(visibility: :direct)
+  end
+
+  def old_enough_scope(max_id = nil)
+    # Filtering on `id` rather than `min_status_age` ago will treat
+    # non-snowflake statuses as older than they really are, but Mastodon
+    # has switched to snowflake IDs significantly over 2 years ago anyway.
+    max_id = [max_id, Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)].compact.min
+    Status.where(Status.arel_table[:id].lteq(max_id))
+  end
+
+  def without_self_fav_scope
+    Status.where('NOT EXISTS (SELECT * FROM favourites fav WHERE fav.account_id = statuses.account_id AND fav.status_id = statuses.id)')
+  end
+
+  def without_self_bookmark_scope
+    Status.where('NOT EXISTS (SELECT * FROM bookmarks bookmark WHERE bookmark.account_id = statuses.account_id AND bookmark.status_id = statuses.id)')
+  end
+
+  def without_pinned_scope
+    Status.where('NOT EXISTS (SELECT * FROM status_pins pin WHERE pin.account_id = statuses.account_id AND pin.status_id = statuses.id)')
+  end
+
+  def without_media_scope
+    Status.where('NOT EXISTS (SELECT * FROM media_attachments media WHERE media.status_id = statuses.id)')
+  end
+
+  def without_poll_scope
+    Status.where(poll_id: nil)
+  end
+
+  def without_popular_scope
+    scope = Status.left_joins(:status_stat)
+    scope = scope.where('COALESCE(status_stats.reblogs_count, 0) < ?', min_reblogs) unless min_reblogs.nil?
+    scope = scope.where('COALESCE(status_stats.favourites_count, 0) < ?', min_favs) unless min_favs.nil?
+    scope
+  end
+end
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
index 5efc924d5..fc0d988fd 100644
--- a/app/models/account_warning.rb
+++ b/app/models/account_warning.rb
@@ -10,14 +10,30 @@
 #  text              :text             default(""), not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
+#  report_id         :bigint(8)
+#  status_ids        :string           is an Array
 #
 
 class AccountWarning < ApplicationRecord
-  enum action: %i(none disable sensitive silence suspend), _suffix: :action
+  enum action: {
+    none:            0,
+    disable:         1_000,
+    delete_statuses: 1_500,
+    sensitive:       2_000,
+    silence:         3_000,
+    suspend:         4_000,
+  }, _suffix: :action
 
   belongs_to :account, inverse_of: :account_warnings
-  belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings
+  belongs_to :target_account, class_name: 'Account', inverse_of: :strikes
+  belongs_to :report, optional: true
 
-  scope :latest, -> { order(created_at: :desc) }
+  has_one :appeal, dependent: :destroy
+
+  scope :latest, -> { order(id: :desc) }
   scope :custom, -> { where.not(text: '') }
+
+  def statuses
+    Status.with_discarded.where(id: status_ids || [])
+  end
 end
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index bf222391f..d3be4be3f 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -33,7 +33,7 @@ class Admin::AccountAction
   def save!
     ApplicationRecord.transaction do
       process_action!
-      process_warning!
+      process_strike!
     end
 
     process_email!
@@ -74,20 +74,14 @@ class Admin::AccountAction
     end
   end
 
-  def process_warning!
-    return unless warnable?
-
-    authorize(target_account, :warn?)
-
-    @warning = AccountWarning.create!(target_account: target_account,
-                                      account: current_account,
-                                      action: type,
-                                      text: text_for_warning)
-
-    # A log entry is only interesting if the warning contains
-    # custom text from someone. Otherwise it's just noise.
-
-    log_action(:create, warning) if warning.text.present?
+  def process_strike!
+    @warning = target_account.strikes.create!(
+      account: current_account,
+      report: report,
+      action: type,
+      text: text_for_warning,
+      status_ids: status_ids
+    )
   end
 
   def process_reports!
@@ -143,7 +137,7 @@ class Admin::AccountAction
   end
 
   def process_email!
-    UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable?
+    UserMailer.warning(target_account.user, warning).deliver_later! if warnable?
   end
 
   def warnable?
@@ -151,7 +145,7 @@ class Admin::AccountAction
   end
 
   def status_ids
-    report.status_ids if report && include_statuses
+    report.status_ids if with_report? && include_statuses
   end
 
   def reports
diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb
index 1d1db1b7a..852bff713 100644
--- a/app/models/admin/action_log.rb
+++ b/app/models/admin/action_log.rb
@@ -17,7 +17,7 @@ class Admin::ActionLog < ApplicationRecord
   serialize :recorded_changes
 
   belongs_to :account
-  belongs_to :target, polymorphic: true
+  belongs_to :target, polymorphic: true, optional: true
 
   default_scope -> { order('id desc') }
 
diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb
index a1c156a8b..12136223b 100644
--- a/app/models/admin/action_log_filter.rb
+++ b/app/models/admin/action_log_filter.rb
@@ -11,6 +11,8 @@ class Admin::ActionLogFilter
     assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
     change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
     confirm_user: { target_type: 'User', action: 'confirm' }.freeze,
+    approve_user: { target_type: 'User', action: 'approve' }.freeze,
+    reject_user: { target_type: 'User', action: 'reject' }.freeze,
     create_account_warning: { target_type: 'AccountWarning', action: 'create' }.freeze,
     create_announcement: { target_type: 'Announcement', action: 'create' }.freeze,
     create_custom_emoji: { target_type: 'CustomEmoji', action: 'create' }.freeze,
@@ -24,6 +26,7 @@ class Admin::ActionLogFilter
     destroy_domain_allow: { target_type: 'DomainAllow', action: 'destroy' }.freeze,
     destroy_domain_block: { target_type: 'DomainBlock', action: 'destroy' }.freeze,
     destroy_email_domain_block: { target_type: 'EmailDomainBlock', action: 'destroy' }.freeze,
+    destroy_instance: { target_type: 'Instance', action: 'destroy' }.freeze,
     destroy_unavailable_domain: { target_type: 'UnavailableDomain', action: 'destroy' }.freeze,
     destroy_status: { target_type: 'Status', action: 'destroy' }.freeze,
     disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze,
@@ -47,6 +50,7 @@ class Admin::ActionLogFilter
     update_announcement: { target_type: 'Announcement', action: 'update' }.freeze,
     update_custom_emoji: { target_type: 'CustomEmoji', action: 'update' }.freeze,
     update_status: { target_type: 'Status', action: 'update' }.freeze,
+    unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
   }.freeze
 
   attr_reader :params
@@ -76,7 +80,7 @@ class Admin::ActionLogFilter
     when 'account_id'
       Admin::ActionLog.where(account_id: value)
     when 'target_account_id'
-      account = Account.find(value)
+      account = Account.find_or_initialize_by(id: value)
       Admin::ActionLog.where(target: [account, account.user].compact)
     else
       raise "Unknown filter: #{key}"
diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb
new file mode 100644
index 000000000..85822214b
--- /dev/null
+++ b/app/models/admin/status_batch_action.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+class Admin::StatusBatchAction
+  include ActiveModel::Model
+  include AccountableConcern
+  include Authorization
+
+  attr_accessor :current_account, :type,
+                :status_ids, :report_id
+
+  def save!
+    process_action!
+  end
+
+  private
+
+  def statuses
+    Status.with_discarded.where(id: status_ids)
+  end
+
+  def process_action!
+    return if status_ids.empty?
+
+    case type
+    when 'delete'
+      handle_delete!
+    when 'report'
+      handle_report!
+    when 'remove_from_report'
+      handle_remove_from_report!
+    end
+  end
+
+  def handle_delete!
+    statuses.each { |status| authorize(status, :destroy?) }
+
+    ApplicationRecord.transaction do
+      statuses.each do |status|
+        status.discard
+        log_action(:destroy, status)
+      end
+
+      if with_report?
+        report.resolve!(current_account)
+        log_action(:resolve, report)
+      end
+
+      @warning = target_account.strikes.create!(
+        action: :delete_statuses,
+        account: current_account,
+        report: report,
+        status_ids: status_ids
+      )
+
+      statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
+    end
+
+    UserMailer.warning(target_account.user, @warning).deliver_later! if target_account.local?
+    RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, { 'preserve' => target_account.local?, 'immediate' => !target_account.local? }] }
+  end
+
+  def handle_report!
+    @report = Report.new(report_params) unless with_report?
+    @report.status_ids = (@report.status_ids + status_ids.map(&:to_i)).uniq
+    @report.save!
+
+    @report_id = @report.id
+  end
+
+  def handle_remove_from_report!
+    return unless with_report?
+
+    report.status_ids -= status_ids.map(&:to_i)
+    report.save!
+  end
+
+  def report
+    @report ||= Report.find(report_id) if report_id.present?
+  end
+
+  def with_report?
+    !report.nil?
+  end
+
+  def target_account
+    @target_account ||= statuses.first.account
+  end
+
+  def report_params
+    { account: current_account, target_account: target_account }
+  end
+end
diff --git a/app/models/admin/status_filter.rb b/app/models/admin/status_filter.rb
new file mode 100644
index 000000000..ce5bb5f46
--- /dev/null
+++ b/app/models/admin/status_filter.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::StatusFilter
+  KEYS = %i(
+    media
+    id
+    report_id
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(account, params)
+    @account = account
+    @params  = params
+  end
+
+  def results
+    scope = @account.statuses.where(visibility: [:public, :unlisted])
+
+    params.each do |key, value|
+      next if %w(page report_id).include?(key.to_s)
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'media'
+      Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
+    when 'id'
+      Status.where(id: value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
index 916261a17..6334ef0df 100644
--- a/app/models/bookmark.rb
+++ b/app/models/bookmark.rb
@@ -13,7 +13,7 @@
 class Bookmark < ApplicationRecord
   include Paginable
 
-  update_index('statuses#status', :status) if Chewy.enabled?
+  update_index('statuses', :status) if Chewy.enabled?
 
   belongs_to :account, inverse_of: :bookmarks
   belongs_to :status,  inverse_of: :bookmarks
@@ -23,4 +23,12 @@ class Bookmark < ApplicationRecord
   before_validation do
     self.status = status.reblog if status&.reblog?
   end
+
+  after_destroy :invalidate_cleanup_info
+
+  def invalidate_cleanup_info
+    return unless status&.account_id == account_id && account.local?
+
+    account.statuses_cleanup_policy&.invalidate_last_inspected(status, :unbookmark)
+  end
 end
diff --git a/app/models/canonical_email_block.rb b/app/models/canonical_email_block.rb
index a8546d65a..94781386c 100644
--- a/app/models/canonical_email_block.rb
+++ b/app/models/canonical_email_block.rb
@@ -15,7 +15,7 @@ class CanonicalEmailBlock < ApplicationRecord
 
   belongs_to :reference_account, class_name: 'Account'
 
-  validates :canonical_email_hash, presence: true
+  validates :canonical_email_hash, presence: true, uniqueness: true
 
   def email=(email)
     self.canonical_email_hash = email_to_canonical_email_hash(email)
@@ -24,4 +24,8 @@ class CanonicalEmailBlock < ApplicationRecord
   def self.block?(email)
     where(canonical_email_hash: email_to_canonical_email_hash(email)).exists?
   end
+
+  def self.find_blocks(email)
+    where(canonical_email_hash: email_to_canonical_email_hash(email))
+  end
 end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index aaf371ebd..bbe269e8f 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -7,8 +7,7 @@ module AccountAssociations
     # Local users
     has_one :user, inverse_of: :account, dependent: :destroy
 
-    # Identity proofs
-    has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account
+    # E2EE
     has_many :devices, dependent: :destroy, inverse_of: :account
 
     # Timelines
@@ -43,7 +42,7 @@ module AccountAssociations
     has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
     has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
     has_many :account_warnings, dependent: :destroy, inverse_of: :account
-    has_many :targeted_account_warnings, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
+    has_many :strikes, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
 
     # Lists (that the account is on, not owned by the account)
     has_many :list_accounts, inverse_of: :account, dependent: :destroy
@@ -66,5 +65,8 @@ module AccountAssociations
 
     # Follow recommendations
     has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
+
+    # Account statuses cleanup policy
+    has_one :statuses_cleanup_policy, class_name: 'AccountStatusesCleanupPolicy', inverse_of: :account, dependent: :destroy
   end
 end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 958f6c78e..ad1665dc4 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -81,6 +81,9 @@ module AccountInteractions
     has_many :following, -> { order('follows.id desc') }, through: :active_relationships,  source: :target_account
     has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
 
+    # Account notes
+    has_many :account_notes, dependent: :destroy
+
     # Block relationships
     has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
     has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
@@ -192,6 +195,10 @@ module AccountInteractions
     !following_anyone?
   end
 
+  def followed_by?(other_account)
+    other_account.following?(self)
+  end
+
   def blocking?(other_account)
     block_relationships.where(target_account: other_account).exists?
   end
@@ -251,10 +258,13 @@ module AccountInteractions
          .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
   end
 
-  def remote_followers_hash(url_prefix)
-    Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}") do
+  def remote_followers_hash(url)
+    url_prefix = url[Account::URL_PREFIX_RE]
+    return if url_prefix.blank?
+
+    Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}/") do
       digest = "\x00" * 32
-      followers.where(Account.arel_table[:uri].matches(url_prefix + '%', false, true)).pluck_each(:uri) do |uri|
+      followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(url_prefix)}/%", false, true)).or(followers.where(uri: url_prefix)).pluck_each(:uri) do |uri|
         Xorcist.xor!(digest, Digest::SHA256.digest(uri))
       end
       digest.unpack('H*')[0]
diff --git a/app/models/concerns/account_merging.rb b/app/models/concerns/account_merging.rb
index 8d37c6e56..119773e6b 100644
--- a/app/models/concerns/account_merging.rb
+++ b/app/models/concerns/account_merging.rb
@@ -13,7 +13,7 @@ module AccountMerging
 
     owned_classes = [
       Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite,
-      Follow, FollowRequest, Block, Mute, AccountIdentityProof,
+      Follow, FollowRequest, Block, Mute,
       AccountModerationNote, AccountPin, AccountStat, ListAccount,
       PollVote, Mention, AccountDeletionRequest, AccountNote, FollowRecommendationSuppression
     ]
diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb
index c5febb828..01fae4236 100644
--- a/app/models/concerns/attachmentable.rb
+++ b/app/models/concerns/attachmentable.rb
@@ -15,50 +15,47 @@ module Attachmentable
   # those files, it is necessary to use the output of the
   # `file` utility instead
   INCORRECT_CONTENT_TYPES = %w(
+    audio/vorbis
     video/ogg
     video/webm
   ).freeze
 
   included do
-    before_post_process :obfuscate_file_name
-    before_post_process :set_file_extensions
-    before_post_process :check_image_dimensions
-    before_post_process :set_file_content_type
+    def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName
+      options = { validate_media_type: false }.merge(options)
+      super(name, options)
+      send(:"before_#{name}_post_process") do
+        attachment = send(name)
+        check_image_dimension(attachment)
+        set_file_content_type(attachment)
+        obfuscate_file_name(attachment)
+        set_file_extension(attachment)
+        Paperclip::Validators::MediaTypeSpoofDetectionValidator.new(attributes: [name]).validate(self)
+      end
+    end
   end
 
   private
 
-  def set_file_content_type
-    self.class.attachment_definitions.each_key do |attachment_name|
-      attachment = send(attachment_name)
-
-      next if attachment.blank? || attachment.queued_for_write[:original].blank? || !INCORRECT_CONTENT_TYPES.include?(attachment.instance_read(:content_type))
+  def set_file_content_type(attachment) # rubocop:disable Naming/AccessorMethodName
+    return if attachment.blank? || attachment.queued_for_write[:original].blank? || !INCORRECT_CONTENT_TYPES.include?(attachment.instance_read(:content_type))
 
-      attachment.instance_write :content_type, calculated_content_type(attachment)
-    end
+    attachment.instance_write :content_type, calculated_content_type(attachment)
   end
 
-  def set_file_extensions
-    self.class.attachment_definitions.each_key do |attachment_name|
-      attachment = send(attachment_name)
+  def set_file_extension(attachment) # rubocop:disable Naming/AccessorMethodName
+    return if attachment.blank?
 
-      next if attachment.blank?
-
-      attachment.instance_write :file_name, [Paperclip::Interpolations.basename(attachment, :original), appropriate_extension(attachment)].delete_if(&:blank?).join('.')
-    end
+    attachment.instance_write :file_name, [Paperclip::Interpolations.basename(attachment, :original), appropriate_extension(attachment)].delete_if(&:blank?).join('.')
   end
 
-  def check_image_dimensions
-    self.class.attachment_definitions.each_key do |attachment_name|
-      attachment = send(attachment_name)
+  def check_image_dimension(attachment)
+    return if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank?
 
-      next if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank?
+    width, height = FastImage.size(attachment.queued_for_write[:original].path)
+    matrix_limit  = attachment.content_type == 'image/gif' ? GIF_MATRIX_LIMIT : MAX_MATRIX_LIMIT
 
-      width, height = FastImage.size(attachment.queued_for_write[:original].path)
-      matrix_limit  = attachment.content_type == 'image/gif' ? GIF_MATRIX_LIMIT : MAX_MATRIX_LIMIT
-
-      raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height > matrix_limit)
-    end
+    raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height > matrix_limit)
   end
 
   def appropriate_extension(attachment)
@@ -79,13 +76,9 @@ module Attachmentable
     ''
   end
 
-  def obfuscate_file_name
-    self.class.attachment_definitions.each_key do |attachment_name|
-      attachment = send(attachment_name)
+  def obfuscate_file_name(attachment)
+    return if attachment.blank? || attachment.queued_for_write[:original].blank? || attachment.options[:preserve_files]
 
-      next if attachment.blank? || attachment.queued_for_write[:original].blank? || attachment.options[:preserve_files]
-
-      attachment.instance_write :file_name, SecureRandom.hex(8) + File.extname(attachment.instance_read(:file_name))
-    end
+    attachment.instance_write :file_name, SecureRandom.hex(8) + File.extname(attachment.instance_read(:file_name))
   end
 end
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index f14357932..d7349dc6a 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -21,6 +21,8 @@
 #
 
 class CustomEmoji < ApplicationRecord
+  include Attachmentable
+
   LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 50.kilobytes).to_i
   LIMIT       = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 200.kilobytes).to_i].max
 
@@ -35,7 +37,7 @@ class CustomEmoji < ApplicationRecord
   belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
   has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
 
-  has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
+  has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }, validate_media_type: false
 
   before_validation :downcase_domain
 
@@ -52,8 +54,6 @@ class CustomEmoji < ApplicationRecord
 
   remotable_attachment :image, LIMIT
 
-  include Attachmentable
-
   after_commit :remove_entity_cache
 
   def local?
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 35028b7dd..2f355739a 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -13,7 +13,7 @@
 class Favourite < ApplicationRecord
   include Paginable
 
-  update_index('statuses#status', :status)
+  update_index('statuses', :status)
 
   belongs_to :account, inverse_of: :favourites
   belongs_to :status,  inverse_of: :favourites
@@ -28,6 +28,7 @@ class Favourite < ApplicationRecord
 
   after_create :increment_cache_counters
   after_destroy :decrement_cache_counters
+  after_destroy :invalidate_cleanup_info
 
   private
 
@@ -39,4 +40,10 @@ class Favourite < ApplicationRecord
     return if association(:status).loaded? && status.marked_for_destruction?
     status&.decrement_count!(:favourites_count)
   end
+
+  def invalidate_cleanup_info
+    return unless status&.account_id == account_id && account.local?
+
+    account.statuses_cleanup_policy&.invalidate_last_inspected(status, :unfav)
+  end
 end
diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb
index 698933c9f..dcf155840 100644
--- a/app/models/form/account_batch.rb
+++ b/app/models/form/account_batch.rb
@@ -3,6 +3,7 @@
 class Form::AccountBatch
   include ActiveModel::Model
   include Authorization
+  include AccountableConcern
   include Payloadable
 
   attr_accessor :account_ids, :action, :current_account
@@ -25,27 +26,27 @@ class Form::AccountBatch
       suppress_follow_recommendation!
     when 'unsuppress_follow_recommendation'
       unsuppress_follow_recommendation!
+    when 'suspend'
+      suspend!
     end
   end
 
   private
 
   def follow!
-    accounts.find_each do |target_account|
+    accounts.each do |target_account|
       FollowService.new.call(current_account, target_account)
     end
   end
 
   def unfollow!
-    accounts.find_each do |target_account|
+    accounts.each do |target_account|
       UnfollowService.new.call(current_account, target_account)
     end
   end
 
   def remove_from_followers!
-    current_account.passive_relationships.where(account_id: account_ids).find_each do |follow|
-      reject_follow!(follow)
-    end
+    RemoveFromFollowersService.new.call(current_account, account_ids)
   end
 
   def block_domains!
@@ -62,32 +63,32 @@ class Form::AccountBatch
     Account.where(id: account_ids)
   end
 
-  def reject_follow!(follow)
-    follow.destroy
-
-    return unless follow.account.activitypub?
-
-    ActivityPub::DeliveryWorker.perform_async(Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), current_account.id, follow.account.inbox_url)
-  end
-
   def approve!
-    users = accounts.includes(:user).map(&:user)
-
-    users.each { |user| authorize(user, :approve?) }
-         .each(&:approve!)
+    accounts.includes(:user).find_each do |account|
+      approve_account(account)
+    end
   end
 
   def reject!
-    records = accounts.includes(:user)
+    accounts.includes(:user).find_each do |account|
+      reject_account(account)
+    end
+  end
 
-    records.each { |account| authorize(account.user, :reject?) }
-           .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) }
+  def suspend!
+    accounts.find_each do |account|
+      if account.user_pending?
+        reject_account(account)
+      else
+        suspend_account(account)
+      end
+    end
   end
 
   def suppress_follow_recommendation!
     authorize(:follow_recommendation, :suppress?)
 
-    accounts.each do |account|
+    accounts.find_each do |account|
       FollowRecommendationSuppression.create(account: account)
     end
   end
@@ -97,4 +98,24 @@ class Form::AccountBatch
 
     FollowRecommendationSuppression.where(account_id: account_ids).destroy_all
   end
+
+  def reject_account(account)
+    authorize(account.user, :reject?)
+    log_action(:reject, account.user, username: account.username)
+    account.suspend!(origin: :local)
+    AccountDeletionWorker.perform_async(account.id, { 'reserve_username' => false })
+  end
+
+  def suspend_account(account)
+    authorize(account, :suspend?)
+    log_action(:suspend, account)
+    account.suspend!(origin: :local)
+    Admin::SuspensionWorker.perform_async(account.id)
+  end
+
+  def approve_account(account)
+    authorize(account.user, :approve?)
+    log_action(:approve, account.user)
+    account.user.approve!
+  end
 end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 0276ec058..34f14e312 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -27,7 +27,6 @@ class Form::AdminSettings
     custom_css
     profile_directory
     hide_followers_count
-    enable_keybase
     flavour_and_skin
     thumbnail
     hero
@@ -41,6 +40,7 @@ class Form::AdminSettings
     noindex
     outgoing_spoilers
     require_invite_text
+    captcha_enabled
   ).freeze
 
   BOOLEAN_KEYS = %i(
@@ -53,13 +53,13 @@ class Form::AdminSettings
     preview_sensitive_media
     profile_directory
     hide_followers_count
-    enable_keybase
     show_reblogs_in_public_timelines
     show_replies_in_public_timelines
     trends
     trendable_by_default
     noindex
     require_invite_text
+    captcha_enabled
   ).freeze
 
   UPLOAD_KEYS = %i(
diff --git a/app/models/form/preview_card_batch.rb b/app/models/form/preview_card_batch.rb
new file mode 100644
index 000000000..5f6e6522a
--- /dev/null
+++ b/app/models/form/preview_card_batch.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class Form::PreviewCardBatch
+  include ActiveModel::Model
+  include Authorization
+
+  attr_accessor :preview_card_ids, :action, :current_account, :precision
+
+  def save
+    case action
+    when 'approve'
+      approve!
+    when 'approve_all'
+      approve_all!
+    when 'reject'
+      reject!
+    when 'reject_all'
+      reject_all!
+    end
+  end
+
+  private
+
+  def preview_cards
+    @preview_cards ||= PreviewCard.where(id: preview_card_ids)
+  end
+
+  def preview_card_providers
+    @preview_card_providers ||= preview_cards.map(&:domain).uniq.map { |domain| PreviewCardProvider.matching_domain(domain) || PreviewCardProvider.new(domain: domain) }
+  end
+
+  def approve!
+    preview_cards.each { |preview_card| authorize(preview_card, :update?) }
+    preview_cards.update_all(trendable: true)
+  end
+
+  def approve_all!
+    preview_card_providers.each do |provider|
+      authorize(provider, :update?)
+      provider.update(trendable: true, reviewed_at: action_time)
+    end
+
+    # Reset any individual overrides
+    preview_cards.update_all(trendable: nil)
+  end
+
+  def reject!
+    preview_cards.each { |preview_card| authorize(preview_card, :update?) }
+    preview_cards.update_all(trendable: false)
+  end
+
+  def reject_all!
+    preview_card_providers.each do |provider|
+      authorize(provider, :update?)
+      provider.update(trendable: false, reviewed_at: action_time)
+    end
+
+    # Reset any individual overrides
+    preview_cards.update_all(trendable: nil)
+  end
+
+  def action_time
+    @action_time ||= Time.now.utc
+  end
+end
diff --git a/app/models/form/preview_card_provider_batch.rb b/app/models/form/preview_card_provider_batch.rb
new file mode 100644
index 000000000..e6ab3d8fa
--- /dev/null
+++ b/app/models/form/preview_card_provider_batch.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Form::PreviewCardProviderBatch
+  include ActiveModel::Model
+  include Authorization
+
+  attr_accessor :preview_card_provider_ids, :action, :current_account
+
+  def save
+    case action
+    when 'approve'
+      approve!
+    when 'reject'
+      reject!
+    end
+  end
+
+  private
+
+  def preview_card_providers
+    PreviewCardProvider.where(id: preview_card_provider_ids)
+  end
+
+  def approve!
+    preview_card_providers.each { |provider| authorize(provider, :update?) }
+    preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc)
+  end
+
+  def reject!
+    preview_card_providers.each { |provider| authorize(provider, :update?) }
+    preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc)
+  end
+end
diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb
deleted file mode 100644
index c4943a7ea..000000000
--- a/app/models/form/status_batch.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-class Form::StatusBatch
-  include ActiveModel::Model
-  include AccountableConcern
-
-  attr_accessor :status_ids, :action, :current_account
-
-  def save
-    case action
-    when 'nsfw_on', 'nsfw_off'
-      change_sensitive(action == 'nsfw_on')
-    when 'delete'
-      delete_statuses
-    end
-  end
-
-  private
-
-  def change_sensitive(sensitive)
-    media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id)
-
-    ApplicationRecord.transaction do
-      Status.where(id: media_attached_status_ids).reorder(nil).find_each do |status|
-        status.update!(sensitive: sensitive)
-        log_action :update, status
-      end
-    end
-
-    true
-  rescue ActiveRecord::RecordInvalid
-    false
-  end
-
-  def delete_statuses
-    Status.where(id: status_ids).reorder(nil).find_each do |status|
-      status.discard
-      RemovalWorker.perform_async(status.id, immediate: true)
-      Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
-      log_action :destroy, status
-    end
-
-    true
-  end
-end
diff --git a/app/models/form/tag_batch.rb b/app/models/form/tag_batch.rb
index fd517a1a6..b9330745f 100644
--- a/app/models/form/tag_batch.rb
+++ b/app/models/form/tag_batch.rb
@@ -23,11 +23,15 @@ class Form::TagBatch
 
   def approve!
     tags.each { |tag| authorize(tag, :update?) }
-    tags.update_all(trendable: true, reviewed_at: Time.now.utc)
+    tags.update_all(trendable: true, reviewed_at: action_time)
   end
 
   def reject!
     tags.each { |tag| authorize(tag, :update?) }
-    tags.update_all(trendable: false, reviewed_at: Time.now.utc)
+    tags.update_all(trendable: false, reviewed_at: action_time)
+  end
+
+  def action_time
+    @action_time ||= Time.now.utc
   end
 end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index a6ab22f61..14e6cabae 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -31,6 +31,8 @@
 class MediaAttachment < ApplicationRecord
   self.inheritance_column = nil
 
+  include Attachmentable
+
   enum type: [:image, :gifv, :video, :unknown, :audio]
   enum processing: [:queued, :in_progress, :complete, :failed], _prefix: true
 
@@ -50,7 +52,7 @@ class MediaAttachment < ApplicationRecord
   IMAGE_MIME_TYPES             = %w(image/jpeg image/png image/gif).freeze
   VIDEO_MIME_TYPES             = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
   VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
-  AUDIO_MIME_TYPES             = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
+  AUDIO_MIME_TYPES             = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/vorbis audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
 
   BLURHASH_OPTIONS = {
     x_comp: 4,
@@ -165,12 +167,11 @@ class MediaAttachment < ApplicationRecord
                     processors: ->(f) { file_processors f },
                     convert_options: GLOBAL_CONVERT_OPTIONS
 
-  before_file_post_process :set_type_and_extension
-  before_file_post_process :check_video_dimensions
+  before_file_validate :set_type_and_extension
+  before_file_validate :check_video_dimensions
 
   validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
-  validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
-  validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :larger_media_format?
+  validates_attachment_size :file, less_than: ->(m) { m.larger_media_format? ? VIDEO_LIMIT : IMAGE_LIMIT }
   remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false, download_on_assign: false, attribute_name: :remote_url
 
   has_attached_file :thumbnail,
@@ -182,8 +183,6 @@ class MediaAttachment < ApplicationRecord
   validates_attachment_size :thumbnail, less_than: IMAGE_LIMIT
   remotable_attachment :thumbnail, IMAGE_LIMIT, suppress_errors: true, download_on_assign: false
 
-  include Attachmentable
-
   validates :account, presence: true
   validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
   validates :file, presence: true, if: :local?
@@ -218,7 +217,7 @@ class MediaAttachment < ApplicationRecord
   end
 
   def to_param
-    shortcode
+    shortcode.presence || id&.to_s
   end
 
   def focus=(point)
@@ -255,7 +254,7 @@ class MediaAttachment < ApplicationRecord
   after_commit :reset_parent_cache, on: :update
 
   before_create :prepare_description, unless: :local?
-  before_create :set_shortcode
+  before_create :set_unknown_type
   before_create :set_processing
 
   after_post_process :set_meta
@@ -298,15 +297,8 @@ class MediaAttachment < ApplicationRecord
 
   private
 
-  def set_shortcode
+  def set_unknown_type
     self.type = :unknown if file.blank? && !type_changed?
-
-    return unless local?
-
-    loop do
-      self.shortcode = SecureRandom.urlsafe_base64(14)
-      break if MediaAttachment.find_by(shortcode: shortcode).nil?
-    end
   end
 
   def prepare_description
diff --git a/app/models/poll.rb b/app/models/poll.rb
index d2a17277b..71b5e191f 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -26,6 +26,7 @@ class Poll < ApplicationRecord
   belongs_to :status
 
   has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all
+  has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account
 
   has_many :notifications, as: :activity, dependent: :destroy
 
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index a6ec839f8..0f9e23fa1 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -24,9 +24,16 @@
 #  embed_url                    :string           default(""), not null
 #  image_storage_schema_version :integer
 #  blurhash                     :string
+#  language                     :string
+#  max_score                    :float
+#  max_score_at                 :datetime
+#  trendable                    :boolean
+#  link_type                    :integer
 #
 
 class PreviewCard < ApplicationRecord
+  include Attachmentable
+
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   LIMIT = 1.megabytes
 
@@ -38,12 +45,11 @@ class PreviewCard < ApplicationRecord
   self.inheritance_column = false
 
   enum type: [:link, :photo, :video, :rich]
+  enum link_type: [:unknown, :article]
 
   has_and_belongs_to_many :statuses
 
-  has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
-
-  include Attachmentable
+  has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }, validate_media_type: false
 
   validates :url, presence: true, uniqueness: true
   validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
@@ -54,6 +60,36 @@ class PreviewCard < ApplicationRecord
 
   before_save :extract_dimensions, if: :link?
 
+  def appropriate_for_trends?
+    link? && article? && title.present? && description.present? && image.present? && provider_name.present?
+  end
+
+  def domain
+    @domain ||= Addressable::URI.parse(url).normalized_host
+  end
+
+  def provider
+    @provider ||= PreviewCardProvider.matching_domain(domain)
+  end
+
+  def trendable?
+    if attributes['trendable'].nil?
+      provider&.trendable?
+    else
+      attributes['trendable']
+    end
+  end
+
+  def requires_review_notification?
+    attributes['trendable'].nil? && (provider.nil? || provider.requires_review_notification?)
+  end
+
+  def decaying?
+    max_score_at && max_score_at >= Trends.links.options[:max_score_cooldown].ago && max_score_at < 1.day.ago
+  end
+
+  attr_writer :provider
+
   def local?
     false
   end
@@ -69,11 +105,14 @@ class PreviewCard < ApplicationRecord
     save!
   end
 
+  def history
+    @history ||= Trends::History.new('links', id)
+  end
+
   class << self
     private
 
-    # rubocop:disable Naming/MethodParameterName
-    def image_styles(f)
+    def image_styles(file)
       styles = {
         original: {
           geometry: '400x400>',
@@ -83,10 +122,9 @@ class PreviewCard < ApplicationRecord
         },
       }
 
-      styles[:original][:format] = 'jpg' if f.instance.image_content_type == 'image/gif'
+      styles[:original][:format] = 'jpg' if file.instance.image_content_type == 'image/gif'
       styles
     end
-    # rubocop:enable Naming/MethodParameterName
   end
 
   private
diff --git a/app/models/preview_card_filter.rb b/app/models/preview_card_filter.rb
new file mode 100644
index 000000000..8dda9989c
--- /dev/null
+++ b/app/models/preview_card_filter.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class PreviewCardFilter
+  KEYS = %i(
+    trending
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = PreviewCard.unscoped
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'trending'
+      trending_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def trending_scope(value)
+    ids = begin
+      case value.to_s
+      when 'allowed'
+        Trends.links.currently_trending_ids(true, -1)
+      else
+        Trends.links.currently_trending_ids(false, -1)
+      end
+    end
+
+    if ids.empty?
+      PreviewCard.none
+    else
+      PreviewCard.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on preview_cards.id = x.id").order('x.ordering')
+    end
+  end
+end
diff --git a/app/models/preview_card_provider.rb b/app/models/preview_card_provider.rb
new file mode 100644
index 000000000..15b24e2bd
--- /dev/null
+++ b/app/models/preview_card_provider.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: preview_card_providers
+#
+#  id                  :bigint(8)        not null, primary key
+#  domain              :string           default(""), not null
+#  icon_file_name      :string
+#  icon_content_type   :string
+#  icon_file_size      :bigint(8)
+#  icon_updated_at     :datetime
+#  trendable           :boolean
+#  reviewed_at         :datetime
+#  requested_review_at :datetime
+#  created_at          :datetime         not null
+#  updated_at          :datetime         not null
+#
+
+class PreviewCardProvider < ApplicationRecord
+  include DomainNormalizable
+  include Attachmentable
+
+  ICON_MIME_TYPES = %w(image/x-icon image/vnd.microsoft.icon image/png).freeze
+  LIMIT = 1.megabyte
+
+  validates :domain, presence: true, uniqueness: true, domain: true
+
+  has_attached_file :icon, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }, validate_media_type: false
+  validates_attachment :icon, content_type: { content_type: ICON_MIME_TYPES }, size: { less_than: LIMIT }
+  remotable_attachment :icon, LIMIT
+
+  scope :trendable, -> { where(trendable: true) }
+  scope :not_trendable, -> { where(trendable: false) }
+  scope :reviewed, -> { where.not(reviewed_at: nil) }
+  scope :pending_review, -> { where(reviewed_at: nil) }
+
+  def requires_review?
+    reviewed_at.nil?
+  end
+
+  def reviewed?
+    reviewed_at.present?
+  end
+
+  def requested_review?
+    requested_review_at.present?
+  end
+
+  def requires_review_notification?
+    requires_review? && !requested_review?
+  end
+
+  def self.matching_domain(domain)
+    segments = domain.split('.')
+    where(domain: segments.map.with_index { |_, i| segments[i..-1].join('.') }).order(Arel.sql('char_length(domain) desc')).first
+  end
+end
diff --git a/app/models/preview_card_provider_filter.rb b/app/models/preview_card_provider_filter.rb
new file mode 100644
index 000000000..1e90d3c9d
--- /dev/null
+++ b/app/models/preview_card_provider_filter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class PreviewCardProviderFilter
+  KEYS = %i(
+    status
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = PreviewCardProvider.unscoped
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope.order(domain: :asc)
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'status'
+      status_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def status_scope(value)
+    case value.to_s
+    when 'approved'
+      PreviewCardProvider.trendable
+    when 'rejected'
+      PreviewCardProvider.not_trendable
+    when 'pending_review'
+      PreviewCardProvider.pending_review
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
+end
diff --git a/app/models/report.rb b/app/models/report.rb
index ef41547d9..ceb15133b 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -6,7 +6,6 @@
 #  id                         :bigint(8)        not null, primary key
 #  status_ids                 :bigint(8)        default([]), not null, is an Array
 #  comment                    :text             default(""), not null
-#  action_taken               :boolean          default(FALSE), not null
 #  created_at                 :datetime         not null
 #  updated_at                 :datetime         not null
 #  account_id                 :bigint(8)        not null
@@ -15,9 +14,14 @@
 #  assigned_account_id        :bigint(8)
 #  uri                        :string
 #  forwarded                  :boolean
+#  category                   :integer          default("other"), not null
+#  action_taken_at            :datetime
+#  rule_ids                   :bigint(8)        is an Array
 #
 
 class Report < ApplicationRecord
+  self.ignored_columns = %w(action_taken)
+
   include Paginable
   include RateLimitable
 
@@ -30,11 +34,17 @@ class Report < ApplicationRecord
 
   has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy
 
-  scope :unresolved, -> { where(action_taken: false) }
-  scope :resolved,   -> { where(action_taken: true) }
+  scope :unresolved, -> { where(action_taken_at: nil) }
+  scope :resolved,   -> { where.not(action_taken_at: nil) }
   scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }
 
-  validates :comment, length: { maximum: 1000 }
+  validates :comment, length: { maximum: 1_000 }
+
+  enum category: {
+    other: 0,
+    spam: 1_000,
+    violation: 2_000,
+  }
 
   def local?
     false # Force uri_for to use uri attribute
@@ -47,13 +57,17 @@ class Report < ApplicationRecord
   end
 
   def statuses
-    Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions)
+    Status.with_discarded.where(id: status_ids)
   end
 
   def media_attachments
     MediaAttachment.where(status_id: status_ids)
   end
 
+  def rules
+    Rule.with_discarded.where(id: rule_ids)
+  end
+
   def assign_to_self!(current_account)
     update!(assigned_account_id: current_account.id)
   end
@@ -63,22 +77,19 @@ class Report < ApplicationRecord
   end
 
   def resolve!(acting_account)
-    if account_id == -99 && target_account.trust_level == Account::TRUST_LEVELS[:untrusted]
-      # This is an automated report and it is being dismissed, so it's
-      # a false positive, in which case update the account's trust level
-      # to prevent further spam checks
-
-      target_account.update(trust_level: Account::TRUST_LEVELS[:trusted])
-    end
-
-    RemovalWorker.push_bulk(Status.with_discarded.discarded.where(id: status_ids).pluck(:id)) { |status_id| [status_id, { immediate: true }] }
-    update!(action_taken: true, action_taken_by_account_id: acting_account.id)
+    update!(action_taken_at: Time.now.utc, action_taken_by_account_id: acting_account.id)
   end
 
   def unresolve!
-    update!(action_taken: false, action_taken_by_account_id: nil)
+    update!(action_taken_at: nil, action_taken_by_account_id: nil)
+  end
+
+  def action_taken?
+    action_taken_at.present?
   end
 
+  alias action_taken action_taken?
+
   def unresolved?
     !action_taken?
   end
@@ -88,29 +99,24 @@ class Report < ApplicationRecord
   end
 
   def history
-    time_range = created_at..updated_at
-
-    sql = [
+    subquery = [
       Admin::ActionLog.where(
         target_type: 'Report',
-        target_id: id,
-        created_at: time_range
-      ).unscope(:order),
+        target_id: id
+      ).unscope(:order).arel,
 
       Admin::ActionLog.where(
         target_type: 'Account',
-        target_id: target_account_id,
-        created_at: time_range
-      ).unscope(:order),
+        target_id: target_account_id
+      ).unscope(:order).arel,
 
       Admin::ActionLog.where(
         target_type: 'Status',
-        target_id: status_ids,
-        created_at: time_range
-      ).unscope(:order),
-    ].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ')
+        target_id: status_ids
+      ).unscope(:order).arel,
+    ].reduce { |union, query| Arel::Nodes::UnionAll.new(union, query) }
 
-    Admin::ActionLog.from("(#{sql}) AS admin_action_logs")
+    Admin::ActionLog.from(Arel::Nodes::As.new(subquery, Admin::ActionLog.arel_table))
   end
 
   def set_uri
diff --git a/app/models/report_filter.rb b/app/models/report_filter.rb
index c32d4359e..dc444a552 100644
--- a/app/models/report_filter.rb
+++ b/app/models/report_filter.rb
@@ -6,6 +6,7 @@ class ReportFilter
     account_id
     target_account_id
     by_target_domain
+    target_origin
   ).freeze
 
   attr_reader :params
@@ -18,7 +19,7 @@ class ReportFilter
     scope = Report.unresolved
 
     params.each do |key, value|
-      scope = scope.merge scope_for(key, value)
+      scope = scope.merge scope_for(key, value), rewhere: true
     end
 
     scope
@@ -34,8 +35,21 @@ class ReportFilter
       Report.where(account_id: value)
     when :target_account_id
       Report.where(target_account_id: value)
+    when :target_origin
+      target_origin_scope(value)
     else
       raise "Unknown filter: #{key}"
     end
   end
+
+  def target_origin_scope(value)
+    case value.to_sym
+    when :local
+      Report.where(target_account: Account.local)
+    when :remote
+      Report.where(target_account: Account.remote)
+    else
+      raise "Unknown value: #{value}"
+    end
+  end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 9f673ee53..9bb2b3746 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -26,6 +26,7 @@
 #  poll_id                :bigint(8)
 #  content_type           :string
 #  deleted_at             :datetime
+#  edited_at              :datetime
 #
 
 class Status < ApplicationRecord
@@ -45,7 +46,7 @@ class Status < ApplicationRecord
   # will be based on current time instead of `created_at`
   attr_accessor :override_timestamps
 
-  update_index('statuses#status', :proper)
+  update_index('statuses', :proper)
 
   enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
 
@@ -59,6 +60,8 @@ class Status < ApplicationRecord
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
   belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
 
+  has_many :edits, class_name: 'StatusEdit', inverse_of: :status, dependent: :destroy
+
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :bookmarks, inverse_of: :status, dependent: :destroy
   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
@@ -100,15 +103,12 @@ class Status < ApplicationRecord
   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
   scope :tagged_with_all, ->(tag_ids) {
-    Array(tag_ids).reduce(self) do |result, id|
+    Array(tag_ids).map(&:to_i).reduce(self) do |result, id|
       result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
     end
   }
   scope :tagged_with_none, ->(tag_ids) {
-    Array(tag_ids).reduce(self) do |result, id|
-      result.joins("LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
-            .where("t#{id}.tag_id IS NULL")
-    end
+    where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
   }
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
@@ -215,6 +215,10 @@ class Status < ApplicationRecord
     public_visibility? || unlisted_visibility?
   end
 
+  def edited?
+    edited_at.present?
+  end
+
   alias sign? distributable?
 
   def with_media?
@@ -391,7 +395,7 @@ class Status < ApplicationRecord
     def from_text(text)
       return [] if text.blank?
 
-      text.scan(FetchLinkCardService::URL_PATTERN).map(&:first).uniq.filter_map do |url|
+      text.scan(FetchLinkCardService::URL_PATTERN).map(&:second).uniq.filter_map do |url|
         status = begin
           if TagManager.instance.local_url?(url)
             ActivityPub::TagManager.instance.uri_to_resource(url, Status)
@@ -494,7 +498,7 @@ class Status < ApplicationRecord
   end
 
   def decrement_counter_caches
-    return if direct_visibility?
+    return if direct_visibility? || new_record?
 
     account&.decrement_count!(:statuses_count)
     reblog&.decrement_count!(:reblogs_count) if reblog?
diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb
new file mode 100644
index 000000000..a89df86c5
--- /dev/null
+++ b/app/models/status_edit.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_edits
+#
+#  id                        :bigint(8)        not null, primary key
+#  status_id                 :bigint(8)        not null
+#  account_id                :bigint(8)
+#  text                      :text             default(""), not null
+#  spoiler_text              :text             default(""), not null
+#  media_attachments_changed :boolean          default(FALSE), not null
+#  created_at                :datetime         not null
+#  updated_at                :datetime         not null
+#
+
+class StatusEdit < ApplicationRecord
+  belongs_to :status
+  belongs_to :account, optional: true
+
+  default_scope { order(id: :asc) }
+
+  delegate :local?, to: :status
+end
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
index afc76bded..93a0ea1c0 100644
--- a/app/models/status_pin.rb
+++ b/app/models/status_pin.rb
@@ -15,4 +15,12 @@ class StatusPin < ApplicationRecord
   belongs_to :status
 
   validates_with StatusPinValidator
+
+  after_destroy :invalidate_cleanup_info
+
+  def invalidate_cleanup_info
+    return unless status&.account_id == account_id && account.local?
+
+    account.statuses_cleanup_policy&.invalidate_last_inspected(status, :unpin)
+  end
 end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 735c30608..a64042614 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -36,10 +36,11 @@ class Tag < ApplicationRecord
   scope :usable, -> { where(usable: [true, nil]) }
   scope :listable, -> { where(listable: [true, nil]) }
   scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) }
+  scope :not_trendable, -> { where(trendable: false) }
   scope :recently_used, ->(account) { joins(:statuses).where(statuses: { id: account.statuses.select(:id).limit(1000) }).group(:id).order(Arel.sql('count(*) desc')) }
   scope :matches_name, ->(term) { where(arel_table[:name].lower.matches(arel_table.lower("#{sanitize_sql_like(Tag.normalize(term))}%"), nil, true)) } # Search with case-sensitive to use B-tree index
 
-  update_index('tags#tag', :self)
+  update_index('tags', :self)
 
   def to_param
     name
@@ -75,28 +76,16 @@ class Tag < ApplicationRecord
     requested_review_at.present?
   end
 
-  def use!(account, status: nil, at_time: Time.now.utc)
-    TrendingTags.record_use!(self, account, status: status, at_time: at_time)
+  def requires_review_notification?
+    requires_review? && !requested_review?
   end
 
-  def trending?
-    TrendingTags.trending?(self)
+  def decaying?
+    max_score_at && max_score_at >= Trends.tags.options[:max_score_cooldown].ago && max_score_at < 1.day.ago
   end
 
   def history
-    days = []
-
-    7.times do |i|
-      day = i.days.ago.beginning_of_day.to_i
-
-      days << {
-        day: day.to_s,
-        uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0',
-        accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s,
-      }
-    end
-
-    days
+    @history ||= Trends::History.new('tags', id)
   end
 
   class << self
diff --git a/app/models/tag_filter.rb b/app/models/tag_filter.rb
index 85bfcbea5..ecdb52503 100644
--- a/app/models/tag_filter.rb
+++ b/app/models/tag_filter.rb
@@ -2,13 +2,8 @@
 
 class TagFilter
   KEYS = %i(
-    directory
-    reviewed
-    unreviewed
-    pending_review
-    popular
-    active
-    name
+    trending
+    status
   ).freeze
 
   attr_reader :params
@@ -18,7 +13,13 @@ class TagFilter
   end
 
   def results
-    scope = Tag.unscoped
+    scope = begin
+      if params[:status] == 'pending_review'
+        Tag.unscoped
+      else
+        trending_scope
+      end
+    end
 
     params.each do |key, value|
       next if key.to_s == 'page'
@@ -26,27 +27,40 @@ class TagFilter
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
 
-    scope.order(id: :desc)
+    scope
   end
 
   private
 
   def scope_for(key, value)
     case key.to_s
-    when 'reviewed'
-      Tag.reviewed.order(reviewed_at: :desc)
-    when 'unreviewed'
-      Tag.unreviewed
-    when 'pending_review'
-      Tag.pending_review.order(requested_review_at: :desc)
-    when 'popular'
-      Tag.order('max_score DESC NULLS LAST')
-    when 'active'
-      Tag.order('last_status_at DESC NULLS LAST')
-    when 'name'
-      Tag.matches_name(value)
+    when 'status'
+      status_scope(value)
     else
       raise "Unknown filter: #{key}"
     end
   end
+
+  def trending_scope
+    ids = Trends.tags.currently_trending_ids(false, -1)
+
+    if ids.empty?
+      Tag.none
+    else
+      Tag.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on tags.id = x.id").order('x.ordering')
+    end
+  end
+
+  def status_scope(value)
+    case value.to_s
+    when 'approved'
+      Tag.trendable
+    when 'rejected'
+      Tag.not_trendable
+    when 'pending_review'
+      Tag.pending_review
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
 end
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
deleted file mode 100644
index 31890b082..000000000
--- a/app/models/trending_tags.rb
+++ /dev/null
@@ -1,128 +0,0 @@
-# frozen_string_literal: true
-
-class TrendingTags
-  KEY                  = 'trending_tags'
-  EXPIRE_HISTORY_AFTER = 7.days.seconds
-  EXPIRE_TRENDS_AFTER  = 1.day.seconds
-  THRESHOLD            = 5
-  LIMIT                = 10
-  REVIEW_THRESHOLD     = 3
-  MAX_SCORE_COOLDOWN   = 2.days.freeze
-  MAX_SCORE_HALFLIFE   = 2.hours.freeze
-
-  class << self
-    include Redisable
-
-    def record_use!(tag, account, status: nil, at_time: Time.now.utc)
-      return unless tag.usable? && !account.silenced?
-
-      # Even if a tag is not allowed to trend, we still need to
-      # record the stats since they can be displayed in other places
-      increment_historical_use!(tag.id, at_time)
-      increment_unique_use!(tag.id, account.id, at_time)
-      increment_use!(tag.id, at_time)
-
-      # Only update when the tag was last used once every 12 hours
-      # and only if a status is given (lets use ignore reblogs)
-      tag.update(last_status_at: at_time) if status.present? && (tag.last_status_at.nil? || (tag.last_status_at < at_time && tag.last_status_at < 12.hours.ago))
-    end
-
-    def update!(at_time = Time.now.utc)
-      tag_ids = redis.smembers("#{KEY}:used:#{at_time.beginning_of_day.to_i}") + redis.zrange(KEY, 0, -1)
-      tags    = Tag.trendable.where(id: tag_ids.uniq)
-
-      # First pass to calculate scores and update the set
-
-      tags.each do |tag|
-        expected  = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
-        expected  = 1.0 if expected.zero?
-        observed  = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
-        max_time  = tag.max_score_at
-        max_score = tag.max_score
-        max_score = 0 if max_time.nil? || max_time < (at_time - MAX_SCORE_COOLDOWN)
-
-        score = begin
-          if expected > observed || observed < THRESHOLD
-            0
-          else
-            ((observed - expected)**2) / expected
-          end
-        end
-
-        if score > max_score
-          max_score = score
-          max_time  = at_time
-
-          # Not interested in triggering any callbacks for this
-          tag.update_columns(max_score: max_score, max_score_at: max_time)
-        end
-
-        decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / MAX_SCORE_HALFLIFE.to_f))
-
-        if decaying_score.zero?
-          redis.zrem(KEY, tag.id)
-        else
-          redis.zadd(KEY, decaying_score, tag.id)
-        end
-      end
-
-      users_for_review = User.staff.includes(:account).to_a.select(&:allows_trending_tag_emails?)
-
-      # Second pass to notify about previously unreviewed trends
-
-      tags.each do |tag|
-        current_rank              = redis.zrevrank(KEY, tag.id)
-        needs_review_notification = tag.requires_review? && !tag.requested_review?
-        rank_passes_threshold     = current_rank.present? && current_rank <= REVIEW_THRESHOLD
-
-        next unless !tag.trendable? && rank_passes_threshold && needs_review_notification
-
-        tag.touch(:requested_review_at)
-
-        users_for_review.each do |user|
-          AdminMailer.new_trending_tag(user.account, tag).deliver_later!
-        end
-      end
-
-      # Trim older items
-
-      redis.zremrangebyrank(KEY, 0, -(LIMIT + 1))
-      redis.zremrangebyscore(KEY, '(0.3', '-inf')
-    end
-
-    def get(limit, filtered: true)
-      tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i)
-
-      tags = Tag.where(id: tag_ids)
-      tags = tags.trendable if filtered
-      tags = tags.index_by(&:id)
-
-      tag_ids.map { |tag_id| tags[tag_id] }.compact.take(limit)
-    end
-
-    def trending?(tag)
-      rank = redis.zrevrank(KEY, tag.id)
-      rank.present? && rank < LIMIT
-    end
-
-    private
-
-    def increment_historical_use!(tag_id, at_time)
-      key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
-      redis.incrby(key, 1)
-      redis.expire(key, EXPIRE_HISTORY_AFTER)
-    end
-
-    def increment_unique_use!(tag_id, account_id, at_time)
-      key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts"
-      redis.pfadd(key, account_id)
-      redis.expire(key, EXPIRE_HISTORY_AFTER)
-    end
-
-    def increment_use!(tag_id, at_time)
-      key = "#{KEY}:used:#{at_time.beginning_of_day.to_i}"
-      redis.sadd(key, tag_id)
-      redis.expire(key, EXPIRE_HISTORY_AFTER)
-    end
-  end
-end
diff --git a/app/models/trends.rb b/app/models/trends.rb
new file mode 100644
index 000000000..8f8cb0261
--- /dev/null
+++ b/app/models/trends.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Trends
+  def self.table_name_prefix
+    'trends_'
+  end
+
+  def self.links
+    @links ||= Trends::Links.new
+  end
+
+  def self.tags
+    @tags ||= Trends::Tags.new
+  end
+
+  def self.refresh!
+    [links, tags].each(&:refresh)
+  end
+
+  def self.request_review!
+    [tags].each(&:request_review) if enabled?
+  end
+
+  def self.enabled?
+    Setting.trends
+  end
+end
diff --git a/app/models/trends/base.rb b/app/models/trends/base.rb
new file mode 100644
index 000000000..b767dcb1a
--- /dev/null
+++ b/app/models/trends/base.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class Trends::Base
+  include Redisable
+
+  class_attribute :default_options
+
+  attr_reader :options
+
+  # @param [Hash] options
+  # @option options [Integer] :threshold Minimum amount of uses by unique accounts to begin calculating the score
+  # @option options [Integer] :review_threshold Minimum rank (lower = better) before requesting a review
+  # @option options [ActiveSupport::Duration] :max_score_cooldown For this amount of time, the peak score (if bigger than current score) is decayed-from
+  # @option options [ActiveSupport::Duration] :max_score_halflife How quickly a peak score decays
+  def initialize(options = {})
+    @options = self.class.default_options.merge(options)
+  end
+
+  def register(_status)
+    raise NotImplementedError
+  end
+
+  def add(*)
+    raise NotImplementedError
+  end
+
+  def refresh(*)
+    raise NotImplementedError
+  end
+
+  def request_review
+    raise NotImplementedError
+  end
+
+  def get(*)
+    raise NotImplementedError
+  end
+
+  def score(id)
+    redis.zscore("#{key_prefix}:all", id) || 0
+  end
+
+  def rank(id)
+    redis.zrevrank("#{key_prefix}:allowed", id)
+  end
+
+  def currently_trending_ids(allowed, limit)
+    redis.zrevrange(allowed ? "#{key_prefix}:allowed" : "#{key_prefix}:all", 0, limit.positive? ? limit - 1 : limit).map(&:to_i)
+  end
+
+  protected
+
+  def key_prefix
+    raise NotImplementedError
+  end
+
+  def recently_used_ids(at_time = Time.now.utc)
+    redis.smembers(used_key(at_time)).map(&:to_i)
+  end
+
+  def record_used_id(id, at_time = Time.now.utc)
+    redis.sadd(used_key(at_time), id)
+    redis.expire(used_key(at_time), 1.day.seconds)
+  end
+
+  def trim_older_items
+    redis.zremrangebyscore("#{key_prefix}:all", '-inf', '(1')
+    redis.zremrangebyscore("#{key_prefix}:allowed", '-inf', '(1')
+  end
+
+  def score_at_rank(rank)
+    redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
+  end
+
+  private
+
+  def used_key(at_time)
+    "#{key_prefix}:used:#{at_time.beginning_of_day.to_i}"
+  end
+end
diff --git a/app/models/trends/history.rb b/app/models/trends/history.rb
new file mode 100644
index 000000000..608e33792
--- /dev/null
+++ b/app/models/trends/history.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class Trends::History
+  include Enumerable
+
+  class Aggregate
+    include Redisable
+
+    def initialize(prefix, id, date_range)
+      @days = date_range.map { |date| Day.new(prefix, id, date.to_time(:utc)) }
+    end
+
+    def uses
+      redis.mget(*@days.map { |day| day.key_for(:uses) }).map(&:to_i).sum
+    end
+
+    def accounts
+      redis.pfcount(*@days.map { |day| day.key_for(:accounts) })
+    end
+  end
+
+  class Day
+    include Redisable
+
+    EXPIRE_AFTER = 14.days.seconds
+
+    def initialize(prefix, id, day)
+      @prefix = prefix
+      @id     = id
+      @day    = day.beginning_of_day
+    end
+
+    attr_reader :day
+
+    def accounts
+      redis.pfcount(key_for(:accounts))
+    end
+
+    def uses
+      redis.get(key_for(:uses))&.to_i || 0
+    end
+
+    def add(account_id)
+      redis.pipelined do
+        redis.incrby(key_for(:uses), 1)
+        redis.pfadd(key_for(:accounts), account_id)
+        redis.expire(key_for(:uses), EXPIRE_AFTER)
+        redis.expire(key_for(:accounts), EXPIRE_AFTER)
+      end
+    end
+
+    def as_json
+      { day: day.to_i.to_s, accounts: accounts.to_s, uses: uses.to_s }
+    end
+
+    def key_for(suffix)
+      case suffix
+      when :accounts
+        "#{key_prefix}:#{suffix}"
+      when :uses
+        key_prefix
+      end
+    end
+
+    def key_prefix
+      "activity:#{@prefix}:#{@id}:#{day.to_i}"
+    end
+  end
+
+  def initialize(prefix, id)
+    @prefix = prefix
+    @id     = id
+  end
+
+  def get(date)
+    Day.new(@prefix, @id, date)
+  end
+
+  def add(account_id, at_time = Time.now.utc)
+    Day.new(@prefix, @id, at_time).add(account_id)
+  end
+
+  def aggregate(date_range)
+    Aggregate.new(@prefix, @id, date_range)
+  end
+
+  def each(&block)
+    if block_given?
+      (0...7).map { |i| block.call(get(i.days.ago)) }
+    else
+      to_enum(:each)
+    end
+  end
+
+  def as_json(*)
+    map(&:as_json)
+  end
+end
diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb
new file mode 100644
index 000000000..a0d65138b
--- /dev/null
+++ b/app/models/trends/links.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+class Trends::Links < Trends::Base
+  PREFIX = 'trending_links'
+
+  self.default_options = {
+    threshold: 15,
+    review_threshold: 10,
+    max_score_cooldown: 2.days.freeze,
+    max_score_halflife: 8.hours.freeze,
+  }
+
+  def register(status, at_time = Time.now.utc)
+    original_status = status.reblog? ? status.reblog : status
+
+    return unless original_status.public_visibility? && status.public_visibility? &&
+                  !original_status.account.silenced? && !status.account.silenced? &&
+                  !original_status.spoiler_text?
+
+    original_status.preview_cards.each do |preview_card|
+      add(preview_card, status.account_id, at_time) if preview_card.appropriate_for_trends?
+    end
+  end
+
+  def add(preview_card, account_id, at_time = Time.now.utc)
+    preview_card.history.add(account_id, at_time)
+    record_used_id(preview_card.id, at_time)
+  end
+
+  def get(allowed, limit)
+    preview_card_ids = currently_trending_ids(allowed, limit)
+    preview_cards = PreviewCard.where(id: preview_card_ids).index_by(&:id)
+    preview_card_ids.map { |id| preview_cards[id] }.compact
+  end
+
+  def refresh(at_time = Time.now.utc)
+    preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
+    calculate_scores(preview_cards, at_time)
+    trim_older_items
+  end
+
+  def request_review
+    preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
+
+    preview_cards_requiring_review = preview_cards.filter_map do |preview_card|
+      next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
+
+      if preview_card.provider.nil?
+        preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc)
+      else
+        preview_card.provider.touch(:requested_review_at)
+      end
+
+      preview_card
+    end
+
+    return if preview_cards_requiring_review.empty?
+
+    User.staff.includes(:account).find_each do |user|
+      AdminMailer.new_trending_links(user.account, preview_cards_requiring_review).deliver_later! if user.allows_trending_tag_emails?
+    end
+  end
+
+  protected
+
+  def key_prefix
+    PREFIX
+  end
+
+  private
+
+  def calculate_scores(preview_cards, at_time)
+    preview_cards.each do |preview_card|
+      expected  = preview_card.history.get(at_time - 1.day).accounts.to_f
+      expected  = 1.0 if expected.zero?
+      observed  = preview_card.history.get(at_time).accounts.to_f
+      max_time  = preview_card.max_score_at
+      max_score = preview_card.max_score
+      max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown])
+
+      score = begin
+        if expected > observed || observed < options[:threshold]
+          0
+        else
+          ((observed - expected)**2) / expected
+        end
+      end
+
+      if score > max_score
+        max_score = score
+        max_time  = at_time
+
+        # Not interested in triggering any callbacks for this
+        preview_card.update_columns(max_score: max_score, max_score_at: max_time)
+      end
+
+      decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
+
+      if decaying_score.zero?
+        redis.zrem("#{PREFIX}:all", preview_card.id)
+        redis.zrem("#{PREFIX}:allowed", preview_card.id)
+      else
+        redis.zadd("#{PREFIX}:all", decaying_score, preview_card.id)
+
+        if preview_card.trendable?
+          redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id)
+        else
+          redis.zrem("#{PREFIX}:allowed", preview_card.id)
+        end
+      end
+    end
+  end
+
+  def would_be_trending?(id)
+    score(id) > score_at_rank(options[:review_threshold] - 1)
+  end
+end
diff --git a/app/models/trends/tags.rb b/app/models/trends/tags.rb
new file mode 100644
index 000000000..a425fd207
--- /dev/null
+++ b/app/models/trends/tags.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+class Trends::Tags < Trends::Base
+  PREFIX = 'trending_tags'
+
+  self.default_options = {
+    threshold: 5,
+    review_threshold: 10,
+    max_score_cooldown: 2.days.freeze,
+    max_score_halflife: 4.hours.freeze,
+  }
+
+  def register(status, at_time = Time.now.utc)
+    original_status = status.reblog? ? status.reblog : status
+
+    return unless original_status.public_visibility? && status.public_visibility? &&
+                  !original_status.account.silenced? && !status.account.silenced?
+
+    original_status.tags.each do |tag|
+      add(tag, status.account_id, at_time) if tag.usable?
+    end
+  end
+
+  def add(tag, account_id, at_time = Time.now.utc)
+    tag.history.add(account_id, at_time)
+    record_used_id(tag.id, at_time)
+  end
+
+  def refresh(at_time = Time.now.utc)
+    tags = Tag.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
+    calculate_scores(tags, at_time)
+    trim_older_items
+  end
+
+  def get(allowed, limit)
+    tag_ids = currently_trending_ids(allowed, limit)
+    tags = Tag.where(id: tag_ids).index_by(&:id)
+    tag_ids.map { |id| tags[id] }.compact
+  end
+
+  def request_review
+    tags = Tag.where(id: currently_trending_ids(false, -1))
+
+    tags_requiring_review = tags.filter_map do |tag|
+      next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification?
+
+      tag.touch(:requested_review_at)
+      tag
+    end
+
+    return if tags_requiring_review.empty?
+
+    User.staff.includes(:account).find_each do |user|
+      AdminMailer.new_trending_tags(user.account, tags_requiring_review).deliver_later! if user.allows_trending_tag_emails?
+    end
+  end
+
+  protected
+
+  def key_prefix
+    PREFIX
+  end
+
+  private
+
+  def calculate_scores(tags, at_time)
+    tags.each do |tag|
+      expected  = tag.history.get(at_time - 1.day).accounts.to_f
+      expected  = 1.0 if expected.zero?
+      observed  = tag.history.get(at_time).accounts.to_f
+      max_time  = tag.max_score_at
+      max_score = tag.max_score
+      max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown])
+
+      score = begin
+        if expected > observed || observed < options[:threshold]
+          0
+        else
+          ((observed - expected)**2) / expected
+        end
+      end
+
+      if score > max_score
+        max_score = score
+        max_time  = at_time
+
+        # Not interested in triggering any callbacks for this
+        tag.update_columns(max_score: max_score, max_score_at: max_time)
+      end
+
+      decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
+
+      if decaying_score.zero?
+        redis.zrem("#{PREFIX}:all", tag.id)
+        redis.zrem("#{PREFIX}:allowed", tag.id)
+      else
+        redis.zadd("#{PREFIX}:all", decaying_score, tag.id)
+
+        if tag.trendable?
+          redis.zadd("#{PREFIX}:allowed", decaying_score, tag.id)
+        else
+          redis.zrem("#{PREFIX}:allowed", tag.id)
+        end
+      end
+    end
+  end
+
+  def would_be_trending?(id)
+    score(id) > score_at_rank(options[:review_threshold] - 1)
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 5c5e926e6..e47b5f135 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -10,12 +10,9 @@
 #  encrypted_password        :string           default(""), not null
 #  reset_password_token      :string
 #  reset_password_sent_at    :datetime
-#  remember_created_at       :datetime
 #  sign_in_count             :integer          default(0), not null
 #  current_sign_in_at        :datetime
 #  last_sign_in_at           :datetime
-#  current_sign_in_ip        :inet
-#  last_sign_in_ip           :inet
 #  admin                     :boolean          default(FALSE), not null
 #  confirmation_token        :string
 #  confirmed_at              :datetime
@@ -34,7 +31,6 @@
 #  disabled                  :boolean          default(FALSE), not null
 #  moderator                 :boolean          default(FALSE), not null
 #  invite_id                 :bigint(8)
-#  remember_token            :string
 #  chosen_languages          :string           is an Array
 #  created_by_application_id :bigint(8)
 #  approved                  :boolean          default(TRUE), not null
@@ -42,9 +38,15 @@
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
 #  sign_up_ip                :inet
+#  skip_sign_in_token        :boolean
 #
 
 class User < ApplicationRecord
+  self.ignored_columns = %w(
+    remember_created_at
+    remember_token
+  )
+
   include Settings::Extend
   include UserRoles
 
@@ -63,7 +65,7 @@ class User < ApplicationRecord
   devise :two_factor_backupable,
          otp_number_of_backup_codes: 10
 
-  devise :registerable, :recoverable, :rememberable, :validatable,
+  devise :registerable, :recoverable, :validatable,
          :confirmable
 
   include Omniauthable
@@ -80,6 +82,7 @@ class User < ApplicationRecord
   has_many :invites, inverse_of: :user
   has_many :markers, inverse_of: :user, dependent: :destroy
   has_many :webauthn_credentials, dependent: :destroy
+  has_many :ips, class_name: 'UserIp', inverse_of: :user
 
   has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
@@ -106,7 +109,7 @@ class User < ApplicationRecord
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) }
   scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
-  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
+  scope :matches_ip, ->(value) { left_joins(:ips).where('user_ips.ip <<= ?', value) }
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
@@ -173,15 +176,11 @@ class User < ApplicationRecord
     prepare_new_user! if new_user && approved?
   end
 
-  def update_sign_in!(request, new_sign_in: false)
+  def update_sign_in!(new_sign_in: false)
     old_current, new_current = current_sign_in_at, Time.now.utc
     self.last_sign_in_at     = old_current || new_current
     self.current_sign_in_at  = new_current
 
-    old_current, new_current = current_sign_in_ip, request.remote_ip
-    self.last_sign_in_ip     = old_current || new_current
-    self.current_sign_in_ip  = new_current
-
     if new_sign_in
       self.sign_in_count ||= 0
       self.sign_in_count  += 1
@@ -200,7 +199,7 @@ class User < ApplicationRecord
   end
 
   def suspicious_sign_in?(ip)
-    !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip)
+    !otp_required_for_login? && !skip_sign_in_token? && current_sign_in_at.present? && !ips.where(ip: ip).exists?
   end
 
   def functional?
@@ -276,31 +275,28 @@ class User < ApplicationRecord
     @shows_application ||= settings.show_application
   end
 
-  # rubocop:disable Naming/MethodParameterName
-  def token_for_app(a)
-    return nil if a.nil? || a.owner != self
-    Doorkeeper::AccessToken.find_or_create_by(application_id: a.id, resource_owner_id: id) do |t|
-      t.scopes = a.scopes
-      t.expires_in = Doorkeeper.configuration.access_token_expires_in
+  def token_for_app(app)
+    return nil if app.nil? || app.owner != self
+
+    Doorkeeper::AccessToken.find_or_create_by(application_id: app.id, resource_owner_id: id) do |t|
+      t.scopes            = app.scopes
+      t.expires_in        = Doorkeeper.configuration.access_token_expires_in
       t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled?
     end
   end
-  # rubocop:enable Naming/MethodParameterName
 
   def activate_session(request)
-    session_activations.activate(session_id: SecureRandom.hex,
-                                 user_agent: request.user_agent,
-                                 ip: request.remote_ip).session_id
+    session_activations.activate(
+      session_id: SecureRandom.hex,
+      user_agent: request.user_agent,
+      ip: request.remote_ip
+    ).session_id
   end
 
   def clear_other_sessions(id)
     session_activations.exclusive(id)
   end
 
-  def session_active?(id)
-    session_activations.active? id
-  end
-
   def web_push_subscription(session)
     session.web_push_subscription.nil? ? nil : session.web_push_subscription
   end
@@ -329,12 +325,31 @@ class User < ApplicationRecord
     super
   end
 
-  def reset_password!(new_password, new_password_confirmation)
+  def reset_password(new_password, new_password_confirmation)
     return false if encrypted_password.blank?
 
     super
   end
 
+  def reset_password!
+    # First, change password to something random and deactivate all sessions
+    transaction do
+      update(password: SecureRandom.hex)
+      session_activations.destroy_all
+    end
+
+    # Then, remove all authorized applications and connected push subscriptions
+    Doorkeeper::AccessGrant.by_resource_owner(self).in_batches.update_all(revoked_at: Time.now.utc)
+
+    Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
+      batch.update_all(revoked_at: Time.now.utc)
+      Web::PushSubscription.where(access_token_id: batch).delete_all
+    end
+
+    # Finally, send a reset password prompt to the user
+    send_reset_password_instructions
+  end
+
   def show_all_media?
     setting_display_media == 'show_all'
   end
@@ -343,22 +358,6 @@ class User < ApplicationRecord
     setting_display_media == 'hide_all'
   end
 
-  def recent_ips
-    @recent_ips ||= begin
-      arr = []
-
-      session_activations.each do |session_activation|
-        arr << [session_activation.updated_at, session_activation.ip]
-      end
-
-      arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present?
-      arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present?
-      arr << [created_at, sign_up_ip] if sign_up_ip.present?
-
-      arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
-    end
-  end
-
   def sign_in_token_expired?
     sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago
   end
@@ -389,10 +388,6 @@ class User < ApplicationRecord
 
   private
 
-  def recent_ip?(ip)
-    recent_ips.any? { |(_, recent_ip)| recent_ip == ip }
-  end
-
   def send_pending_devise_notifications
     pending_devise_notifications.each do |notification, args, kwargs|
       render_and_send_devise_message(notification, *args, **kwargs)
diff --git a/app/models/user_ip.rb b/app/models/user_ip.rb
new file mode 100644
index 000000000..a8e802e13
--- /dev/null
+++ b/app/models/user_ip.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: user_ips
+#
+#  user_id :bigint(8)        primary key
+#  ip      :inet
+#  used_at :datetime
+#
+
+class UserIp < ApplicationRecord
+  self.primary_key = :user_id
+
+  belongs_to :user, foreign_key: :user_id
+
+  def readonly?
+    true
+  end
+end
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 672e1786b..46237e45c 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -64,4 +64,8 @@ class AccountPolicy < ApplicationPolicy
   def memorialize?
     admin? && !record.user&.admin? && !record.instance_actor?
   end
+
+  def unblock_email?
+    staff?
+  end
 end
diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb
index a73823556..801ca162e 100644
--- a/app/policies/instance_policy.rb
+++ b/app/policies/instance_policy.rb
@@ -8,4 +8,8 @@ class InstancePolicy < ApplicationPolicy
   def show?
     admin?
   end
+
+  def destroy?
+    admin?
+  end
 end
diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb
new file mode 100644
index 000000000..4f485d7fc
--- /dev/null
+++ b/app/policies/preview_card_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class PreviewCardPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def update?
+    staff?
+  end
+end
diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb
new file mode 100644
index 000000000..598d54a5e
--- /dev/null
+++ b/app/policies/preview_card_provider_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class PreviewCardProviderPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def update?
+    staff?
+  end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index d832bff75..6695a0ddf 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -13,6 +13,14 @@ class UserPolicy < ApplicationPolicy
     admin? && !record.staff?
   end
 
+  def disable_sign_in_token_auth?
+    staff?
+  end
+
+  def enable_sign_in_token_auth?
+    staff?
+  end
+
   def confirm?
     staff? && !record.confirmed?
   end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 345a5e5e9..a0f1ebd0a 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -24,8 +24,8 @@ class InstancePresenter
     Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
   end
 
-  def active_user_count(weeks = 4)
-    Rails.cache.fetch("active_user_count/#{weeks}") { Redis.current.pfcount(*(0...weeks).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) }
+  def active_user_count(num_weeks = 4)
+    Rails.cache.fetch("active_user_count/#{num_weeks}") { ActivityTracker.new('activity:logins', :unique).sum(num_weeks.weeks.ago) }
   end
 
   def status_count
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index a7d948976..48707aa16 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -6,8 +6,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   context :security
 
   context_extensions :manually_approves_followers, :featured, :also_known_as,
-                     :moved_to, :property_value, :identity_proof,
-                     :discoverable, :olm, :suspended
+                     :moved_to, :property_value, :discoverable, :olm, :suspended
 
   attributes :id, :type, :following, :followers,
              :inbox, :outbox, :featured, :featured_tags,
@@ -143,7 +142,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   end
 
   def virtual_attachments
-    object.suspended? ? [] : (object.fields + object.identity_proofs.active)
+    object.suspended? ? [] : object.fields
   end
 
   def moved_to
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index e08c537b0..aa552a724 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -11,6 +11,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 
   attribute :content
   attribute :content_map, if: :language?
+  attribute :updated, if: :edited?
 
   attribute :direct_message, if: :non_public?
 
@@ -76,6 +77,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     object.language.present?
   end
 
+  delegate :edited?, to: :object
+
   def in_reply_to
     return unless object.reply? && !object.thread.nil?
 
@@ -90,6 +93,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     object.created_at.iso8601
   end
 
+  def updated
+    object.edited_at.iso8601
+  end
+
   def url
     ActivityPub::TagManager.instance.url_for(object)
   end
diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb
index dafe8f55b..4786aa760 100644
--- a/app/serializers/manifest_serializer.rb
+++ b/app/serializers/manifest_serializer.rb
@@ -48,7 +48,7 @@ class ManifestSerializer < ActiveModel::Serializer
   end
 
   def scope
-    root_url
+    '/'
   end
 
   def share_target
diff --git a/app/serializers/rest/admin/account_serializer.rb b/app/serializers/rest/admin/account_serializer.rb
index f579d3302..3480e8c5a 100644
--- a/app/serializers/rest/admin/account_serializer.rb
+++ b/app/serializers/rest/admin/account_serializer.rb
@@ -9,6 +9,7 @@ class REST::Admin::AccountSerializer < ActiveModel::Serializer
   attribute :created_by_application_id, if: :created_by_application?
   attribute :invited_by_account_id, if: :invited?
 
+  has_many :ips, serializer: REST::Admin::IpSerializer
   has_one :account, serializer: REST::AccountSerializer
 
   def id
@@ -19,10 +20,6 @@ class REST::Admin::AccountSerializer < ActiveModel::Serializer
     object.user_email
   end
 
-  def ip
-    object.user_current_sign_in_ip.to_s.presence
-  end
-
   def role
     object.user_role
   end
@@ -74,4 +71,12 @@ class REST::Admin::AccountSerializer < ActiveModel::Serializer
   def created_by_application?
     object.user&.created_by_application_id&.present?
   end
+
+  def ips
+    object.user&.ips
+  end
+
+  def ip
+    ips&.first
+  end
 end
diff --git a/app/serializers/rest/admin/cohort_serializer.rb b/app/serializers/rest/admin/cohort_serializer.rb
new file mode 100644
index 000000000..f68173616
--- /dev/null
+++ b/app/serializers/rest/admin/cohort_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class REST::Admin::CohortSerializer < ActiveModel::Serializer
+  attributes :period, :frequency
+
+  class CohortDataSerializer < ActiveModel::Serializer
+    attributes :date, :rate, :value
+
+    def date
+      object.date.iso8601
+    end
+  end
+
+  has_many :data, serializer: CohortDataSerializer
+
+  def period
+    object.period.iso8601
+  end
+end
diff --git a/app/serializers/rest/admin/dimension_serializer.rb b/app/serializers/rest/admin/dimension_serializer.rb
new file mode 100644
index 000000000..a00b6ecd7
--- /dev/null
+++ b/app/serializers/rest/admin/dimension_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::Admin::DimensionSerializer < ActiveModel::Serializer
+  attributes :key, :data
+end
diff --git a/app/serializers/rest/admin/ip_serializer.rb b/app/serializers/rest/admin/ip_serializer.rb
new file mode 100644
index 000000000..d11699dc4
--- /dev/null
+++ b/app/serializers/rest/admin/ip_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::Admin::IpSerializer < ActiveModel::Serializer
+  attributes :ip, :used_at
+end
diff --git a/app/serializers/rest/admin/measure_serializer.rb b/app/serializers/rest/admin/measure_serializer.rb
new file mode 100644
index 000000000..81d655c1a
--- /dev/null
+++ b/app/serializers/rest/admin/measure_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class REST::Admin::MeasureSerializer < ActiveModel::Serializer
+  attributes :key, :total, :previous_total, :data
+
+  def total
+    object.total.to_s
+  end
+
+  def previous_total
+    object.previous_total.to_s
+  end
+end
diff --git a/app/serializers/rest/admin/report_serializer.rb b/app/serializers/rest/admin/report_serializer.rb
index 7a77132c0..74bc0c520 100644
--- a/app/serializers/rest/admin/report_serializer.rb
+++ b/app/serializers/rest/admin/report_serializer.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class REST::Admin::ReportSerializer < ActiveModel::Serializer
-  attributes :id, :action_taken, :comment, :created_at, :updated_at
+  attributes :id, :action_taken, :category, :comment, :created_at, :updated_at
 
   has_one :account, serializer: REST::Admin::AccountSerializer
   has_one :target_account, serializer: REST::Admin::AccountSerializer
@@ -9,8 +9,13 @@ class REST::Admin::ReportSerializer < ActiveModel::Serializer
   has_one :action_taken_by_account, serializer: REST::Admin::AccountSerializer
 
   has_many :statuses, serializer: REST::StatusSerializer
+  has_many :rules, serializer: REST::RuleSerializer
 
   def id
     object.id.to_s
   end
+
+  def statuses
+    object.statuses.with_includes
+  end
 end
diff --git a/app/serializers/rest/admin/tag_serializer.rb b/app/serializers/rest/admin/tag_serializer.rb
new file mode 100644
index 000000000..425ba4ba3
--- /dev/null
+++ b/app/serializers/rest/admin/tag_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class REST::Admin::TagSerializer < REST::TagSerializer
+  attributes :id, :trendable, :usable, :requires_review
+
+  def id
+    object.id.to_s
+  end
+
+  def requires_review
+    object.requires_review?
+  end
+end
diff --git a/app/serializers/rest/identity_proof_serializer.rb b/app/serializers/rest/identity_proof_serializer.rb
deleted file mode 100644
index 0e7415935..000000000
--- a/app/serializers/rest/identity_proof_serializer.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-class REST::IdentityProofSerializer < ActiveModel::Serializer
-  attributes :provider, :provider_username, :updated_at, :proof_url, :profile_url
-
-  def proof_url
-    object.badge.proof_url
-  end
-
-  def profile_url
-    object.badge.profile_url
-  end
-
-  def provider
-    object.provider.capitalize
-  end
-end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index ae8b80fb7..48bbb55c8 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,
+             :configuration
 
   has_one :contact_account, serializer: REST::AccountSerializer
 
@@ -66,6 +67,32 @@ class REST::InstanceSerializer < ActiveModel::Serializer
     { streaming_api: Rails.configuration.x.streaming_api_base_url }
   end
 
+  def configuration
+    {
+      statuses: {
+        max_characters: StatusLengthValidator::MAX_CHARS,
+        max_media_attachments: 4,
+        characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
+      },
+
+      media_attachments: {
+        supported_mime_types: MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES,
+        image_size_limit: MediaAttachment::IMAGE_LIMIT,
+        image_matrix_limit: Attachmentable::MAX_MATRIX_LIMIT,
+        video_size_limit: MediaAttachment::VIDEO_LIMIT,
+        video_frame_rate_limit: MediaAttachment::MAX_VIDEO_FRAME_RATE,
+        video_matrix_limit: MediaAttachment::MAX_VIDEO_MATRIX_LIMIT,
+      },
+
+      polls: {
+        max_options: PollValidator::MAX_OPTIONS,
+        max_characters_per_option: PollValidator::MAX_OPTION_CHARS,
+        min_expiration: PollValidator::MIN_EXPIRATION,
+        max_expiration: PollValidator::MAX_EXPIRATION,
+      },
+    }
+  end
+
   def languages
     [I18n.default_locale]
   end
diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb
index a24f95315..f27dda832 100644
--- a/app/serializers/rest/media_attachment_serializer.rb
+++ b/app/serializers/rest/media_attachment_serializer.rb
@@ -40,7 +40,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
   end
 
   def text_url
-    object.local? ? medium_url(object) : nil
+    object.local? && object.shortcode.present? ? medium_url(object) : nil
   end
 
   def meta
diff --git a/app/serializers/rest/status_edit_serializer.rb b/app/serializers/rest/status_edit_serializer.rb
new file mode 100644
index 000000000..b123b4e09
--- /dev/null
+++ b/app/serializers/rest/status_edit_serializer.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class REST::StatusEditSerializer < ActiveModel::Serializer
+  attributes :text, :spoiler_text, :media_attachments_changed,
+             :created_at
+end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index b5dcf6208..7b5263eee 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
              :sensitive, :spoiler_text, :visibility, :language,
              :uri, :url, :replies_count, :reblogs_count,
-             :favourites_count
+             :favourites_count, :edited_at
 
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
@@ -124,7 +124,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
     current_user? &&
       current_user.account_id == object.account_id &&
       !object.reblog? &&
-      %w(public unlisted).include?(object.visibility)
+      %w(public unlisted private).include?(object.visibility)
   end
 
   def source_requested?
diff --git a/app/serializers/rest/status_source_serializer.rb b/app/serializers/rest/status_source_serializer.rb
new file mode 100644
index 000000000..c03cbd20d
--- /dev/null
+++ b/app/serializers/rest/status_source_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class REST::StatusSourceSerializer < ActiveModel::Serializer
+  attributes :id, :text, :spoiler_text, :content_type
+
+  def id
+    object.id.to_s
+  end
+end
diff --git a/app/serializers/rest/trends/link_serializer.rb b/app/serializers/rest/trends/link_serializer.rb
new file mode 100644
index 000000000..232483490
--- /dev/null
+++ b/app/serializers/rest/trends/link_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::Trends::LinkSerializer < REST::PreviewCardSerializer
+  attributes :history
+end
diff --git a/app/services/account_statuses_cleanup_service.rb b/app/services/account_statuses_cleanup_service.rb
new file mode 100644
index 000000000..3918b5ba4
--- /dev/null
+++ b/app/services/account_statuses_cleanup_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class AccountStatusesCleanupService < BaseService
+  # @param [AccountStatusesCleanupPolicy] account_policy
+  # @param [Integer] budget
+  # @return [Integer]
+  def call(account_policy, budget = 50)
+    return 0 unless account_policy.enabled?
+
+    cutoff_id = account_policy.compute_cutoff_id
+    return 0 if cutoff_id.blank?
+
+    num_deleted = 0
+    last_deleted = nil
+
+    account_policy.statuses_to_delete(budget, cutoff_id, account_policy.last_inspected).reorder(nil).find_each(order: :asc) do |status|
+      status.discard
+      RemovalWorker.perform_async(status.id, { 'redraft' => false })
+      num_deleted += 1
+      last_deleted = status.id
+    end
+
+    account_policy.record_last_inspected(last_deleted.presence || cutoff_id)
+
+    num_deleted
+  end
+end
diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb
index 72352aca6..780741feb 100644
--- a/app/services/activitypub/fetch_featured_collection_service.rb
+++ b/app/services/activitypub/fetch_featured_collection_service.rb
@@ -23,7 +23,7 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
 
   def process_items(items)
     status_ids = items.map { |item| value_or_id(item) }
-                      .filter_map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri) unless ActivityPub::TagManager.instance.local_uri?(uri) }
+                      .filter_map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: local_follower) unless ActivityPub::TagManager.instance.local_uri?(uri) }
                       .filter_map { |status| status.id if status.account_id == @account.id }
     to_remove = []
     to_add    = status_ids
@@ -46,4 +46,8 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
   def supported_context?
     super(@json)
   end
+
+  def local_follower
+    @local_follower ||= @account.followers.local.without_suspended.first
+  end
 end
diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb
index 1c79ecf11..1829e791c 100644
--- a/app/services/activitypub/fetch_remote_poll_service.rb
+++ b/app/services/activitypub/fetch_remote_poll_service.rb
@@ -8,6 +8,6 @@ class ActivityPub::FetchRemotePollService < BaseService
 
     return unless supported_context?(json)
 
-    ActivityPub::ProcessPollService.new.call(poll, json)
+    ActivityPub::ProcessStatusUpdateService.new.call(poll.status, json)
   end
 end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index cf4f62899..4f789d50b 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -13,7 +13,20 @@ class ActivityPub::FetchRemoteStatusService < BaseService
       end
     end
 
-    return if !(supported_context? && expected_type?) || actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id)
+    return unless supported_context?
+
+    actor_id = nil
+    activity_json = nil
+
+    if expected_object_type?
+      actor_id = value_or_id(first_of_value(@json['attributedTo']))
+      activity_json = { 'type' => 'Create', 'actor' => actor_id, 'object' => @json }
+    elsif expected_activity_type?
+      actor_id = value_or_id(first_of_value(@json['actor']))
+      activity_json = @json
+    end
+
+    return if activity_json.nil? || !trustworthy_attribution?(@json['id'], actor_id)
 
     actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
     actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? || needs_update?(actor)
@@ -25,14 +38,6 @@ class ActivityPub::FetchRemoteStatusService < BaseService
 
   private
 
-  def activity_json
-    { 'type' => 'Create', 'actor' => actor_id, 'object' => @json }
-  end
-
-  def actor_id
-    value_or_id(first_of_value(@json['attributedTo']))
-  end
-
   def trustworthy_attribution?(uri, attributed_to)
     return false if uri.nil? || attributed_to.nil?
     Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
@@ -42,7 +47,11 @@ class ActivityPub::FetchRemoteStatusService < BaseService
     super(@json)
   end
 
-  def expected_type?
+  def expected_activity_type?
+    equals_or_includes_any?(@json['type'], %w(Create Announce))
+  end
+
+  def expected_object_type?
     equals_or_includes_any?(@json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
   end
 
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 4ab6912e5..ec5140720 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -27,7 +27,6 @@ class ActivityPub::ProcessAccountService < BaseService
         create_account if @account.nil?
         update_account
         process_tags
-        process_attachments
 
         process_duplicate_accounts! if @options[:verified_webfinger]
       else
@@ -301,23 +300,6 @@ class ActivityPub::ProcessAccountService < BaseService
     end
   end
 
-  def process_attachments
-    return if @json['attachment'].blank?
-
-    previous_proofs = @account.identity_proofs.to_a
-    current_proofs  = []
-
-    as_array(@json['attachment']).each do |attachment|
-      next unless equals_or_includes?(attachment['type'], 'IdentityProof')
-      current_proofs << process_identity_proof(attachment)
-    end
-
-    previous_proofs.each do |previous_proof|
-      next if current_proofs.any? { |current_proof| current_proof.id == previous_proof.id }
-      previous_proof.delete
-    end
-  end
-
   def process_emoji(tag)
     return if skip_download?
     return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
@@ -334,12 +316,4 @@ class ActivityPub::ProcessAccountService < BaseService
     emoji.image_remote_url = image_url
     emoji.save
   end
-
-  def process_identity_proof(attachment)
-    provider          = attachment['signatureAlgorithm']
-    provider_username = attachment['name']
-    token             = attachment['signatureValue']
-
-    @account.identity_proofs.where(provider: provider, provider_username: provider_username).find_or_create_by(provider: provider, provider_username: provider_username, token: token)
-  end
 end
diff --git a/app/services/activitypub/process_poll_service.rb b/app/services/activitypub/process_poll_service.rb
deleted file mode 100644
index d83e614d8..000000000
--- a/app/services/activitypub/process_poll_service.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-# frozen_string_literal: true
-
-class ActivityPub::ProcessPollService < BaseService
-  include JsonLdHelper
-
-  def call(poll, json)
-    @json = json
-
-    return unless expected_type?
-
-    previous_expires_at = poll.expires_at
-
-    expires_at = begin
-      if @json['closed'].is_a?(String)
-        @json['closed']
-      elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
-        Time.now.utc
-      else
-        @json['endTime']
-      end
-    end
-
-    items = begin
-      if @json['anyOf'].is_a?(Array)
-        @json['anyOf']
-      else
-        @json['oneOf']
-      end
-    end
-
-    voters_count = @json['votersCount']
-
-    latest_options = items.filter_map { |item| item['name'].presence || item['content'] }
-
-    # If for some reasons the options were changed, it invalidates all previous
-    # votes, so we need to remove them
-    poll.votes.delete_all if latest_options != poll.options
-
-    begin
-      poll.update!(
-        last_fetched_at: Time.now.utc,
-        expires_at: expires_at,
-        options: latest_options,
-        cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
-        voters_count: voters_count
-      )
-    rescue ActiveRecord::StaleObjectError
-      poll.reload
-      retry
-    end
-
-    # If the poll had no expiration date set but now has, and people have voted,
-    # schedule a notification.
-    if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists?
-      PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
-    end
-  end
-
-  private
-
-  def expected_type?
-    equals_or_includes_any?(@json['type'], %w(Question))
-  end
-end
diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb
new file mode 100644
index 000000000..977928127
--- /dev/null
+++ b/app/services/activitypub/process_status_update_service.rb
@@ -0,0 +1,283 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessStatusUpdateService < BaseService
+  include JsonLdHelper
+
+  def call(status, json)
+    @json                      = json
+    @status_parser             = ActivityPub::Parser::StatusParser.new(@json)
+    @uri                       = @status_parser.uri
+    @status                    = status
+    @account                   = status.account
+    @media_attachments_changed = false
+    @poll_changed              = false
+
+    # Only native types can be updated at the moment
+    return if !expected_type? || already_updated_more_recently?
+
+    # Only allow processing one create/update per status at a time
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        Status.transaction do
+          create_previous_edit!
+          update_media_attachments!
+          update_poll!
+          update_immediate_attributes!
+          update_metadata!
+          create_edit!
+        end
+
+        queue_poll_notifications!
+
+        next unless significant_changes?
+
+        reset_preview_card!
+        broadcast_updates!
+      else
+        raise Mastodon::RaceConditionError
+      end
+    end
+  end
+
+  private
+
+  def update_media_attachments!
+    previous_media_attachments = @status.media_attachments.to_a
+    next_media_attachments     = []
+
+    as_array(@json['attachment']).each do |attachment|
+      media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
+
+      next if media_attachment_parser.remote_url.blank? || next_media_attachments.size > 4
+
+      begin
+        media_attachment   = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url }
+        media_attachment ||= MediaAttachment.new(account: @account, remote_url: media_attachment_parser.remote_url)
+
+        # If a previously existing media attachment was significantly updated, mark
+        # media attachments as changed even if none were added or removed
+        if media_attachment_parser.significantly_changes?(media_attachment)
+          @media_attachments_changed = true
+        end
+
+        media_attachment.description          = media_attachment_parser.description
+        media_attachment.focus                = media_attachment_parser.focus
+        media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url
+        media_attachment.blurhash             = media_attachment_parser.blurhash
+        media_attachment.save!
+
+        next_media_attachments << media_attachment
+
+        next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
+
+        RedownloadMediaWorker.perform_async(media_attachment.id) if media_attachment.remote_url_previously_changed? || media_attachment.thumbnail_remote_url_previously_changed?
+      rescue Addressable::URI::InvalidURIError => e
+        Rails.logger.debug "Invalid URL in attachment: #{e}"
+      end
+    end
+
+    removed_media_attachments = previous_media_attachments - next_media_attachments
+    added_media_attachments   = next_media_attachments - previous_media_attachments
+
+    MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
+    MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
+
+    @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any?
+  end
+
+  def update_poll!
+    previous_poll        = @status.preloadable_poll
+    @previous_expires_at = previous_poll&.expires_at
+    poll_parser          = ActivityPub::Parser::PollParser.new(@json)
+
+    if poll_parser.valid?
+      poll = previous_poll || @account.polls.new(status: @status)
+
+      # If for some reasons the options were changed, it invalidates all previous
+      # votes, so we need to remove them
+      if poll_parser.significantly_changes?(poll)
+        @poll_changed = true
+        poll.votes.delete_all unless poll.new_record?
+      end
+
+      poll.last_fetched_at = Time.now.utc
+      poll.options         = poll_parser.options
+      poll.multiple        = poll_parser.multiple
+      poll.expires_at      = poll_parser.expires_at
+      poll.voters_count    = poll_parser.voters_count
+      poll.cached_tallies  = poll_parser.cached_tallies
+      poll.save!
+
+      @status.poll_id = poll.id
+    elsif previous_poll.present?
+      previous_poll.destroy!
+      @poll_changed = true
+      @status.poll_id = nil
+    end
+  end
+
+  def update_immediate_attributes!
+    @status.text         = @status_parser.text || ''
+    @status.spoiler_text = @status_parser.spoiler_text || ''
+    @status.sensitive    = @account.sensitized? || @status_parser.sensitive || false
+    @status.language     = @status_parser.language || detected_language
+    @status.edited_at    = @status_parser.edited_at || Time.now.utc if significant_changes?
+
+    @status.save!
+  end
+
+  def update_metadata!
+    @raw_tags     = []
+    @raw_mentions = []
+    @raw_emojis   = []
+
+    as_array(@json['tag']).each do |tag|
+      if equals_or_includes?(tag['type'], 'Hashtag')
+        @raw_tags << tag['name']
+      elsif equals_or_includes?(tag['type'], 'Mention')
+        @raw_mentions << tag['href']
+      elsif equals_or_includes?(tag['type'], 'Emoji')
+        @raw_emojis << tag
+      end
+    end
+
+    update_tags!
+    update_mentions!
+    update_emojis!
+  end
+
+  def update_tags!
+    @status.tags = Tag.find_or_create_by_names(@raw_tags)
+  end
+
+  def update_mentions!
+    previous_mentions = @status.active_mentions.includes(:account).to_a
+    current_mentions  = []
+
+    @raw_mentions.each do |href|
+      next if href.blank?
+
+      account   = ActivityPub::TagManager.instance.uri_to_resource(href, Account)
+      account ||= ActivityPub::FetchRemoteAccountService.new.call(href)
+
+      next if account.nil?
+
+      mention   = previous_mentions.find { |x| x.account_id == account.id }
+      mention ||= account.mentions.new(status: @status)
+
+      current_mentions << mention
+    end
+
+    current_mentions.each do |mention|
+      mention.save if mention.new_record?
+    end
+
+    # If previous mentions are no longer contained in the text, convert them
+    # to silent mentions, since withdrawing access from someone who already
+    # received a notification might be more confusing
+    removed_mentions = previous_mentions - current_mentions
+
+    Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty?
+  end
+
+  def update_emojis!
+    return if skip_download?
+
+    @raw_emojis.each do |raw_emoji|
+      custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(raw_emoji)
+
+      next if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
+
+      emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
+
+      next unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
+
+      begin
+        emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri)
+        emoji.image_remote_url = custom_emoji_parser.image_remote_url
+        emoji.save
+      rescue Seahorse::Client::NetworkingError => e
+        Rails.logger.warn "Error storing emoji: #{e}"
+      end
+    end
+  end
+
+  def expected_type?
+    equals_or_includes_any?(@json['type'], %w(Note Question))
+  end
+
+  def lock_options
+    { redis: Redis.current, key: "create:#{@uri}", autorelease: 15.minutes.seconds }
+  end
+
+  def detected_language
+    LanguageDetector.instance.detect(@status_parser.text, @account)
+  end
+
+  def create_previous_edit!
+    # We only need to create a previous edit when no previous edits exist, e.g.
+    # when the status has never been edited. For other cases, we always create
+    # an edit, so the step can be skipped
+
+    return if @status.edits.any?
+
+    @status.edits.create(
+      text: @status.text,
+      spoiler_text: @status.spoiler_text,
+      media_attachments_changed: false,
+      account_id: @account.id,
+      created_at: @status.created_at
+    )
+  end
+
+  def create_edit!
+    return unless significant_changes?
+
+    @status_edit = @status.edits.create(
+      text: @status.text,
+      spoiler_text: @status.spoiler_text,
+      media_attachments_changed: @media_attachments_changed || @poll_changed,
+      account_id: @account.id,
+      created_at: @status.edited_at
+    )
+  end
+
+  def skip_download?
+    return @skip_download if defined?(@skip_download)
+
+    @skip_download ||= DomainBlock.reject_media?(@account.domain)
+  end
+
+  def unsupported_media_type?(mime_type)
+    mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
+  end
+
+  def significant_changes?
+    @status.text_changed? || @status.text_previously_changed? || @status.spoiler_text_changed? || @status.spoiler_text_previously_changed? || @media_attachments_changed || @poll_changed
+  end
+
+  def already_updated_more_recently?
+    @status.edited_at.present? && @status_parser.edited_at.present? && @status.edited_at > @status_parser.edited_at
+  end
+
+  def reset_preview_card!
+    @status.preview_cards.clear
+    LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id)
+  end
+
+  def broadcast_updates!
+    ::DistributionWorker.perform_async(@status.id, { 'update' => true })
+  end
+
+  def queue_poll_notifications!
+    poll = @status.preloadable_poll
+
+    # If the poll had no expiration date set but now has, or now has a sooner
+    # expiration date, and people have voted, schedule a notification
+
+    return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
+
+    PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
+    PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
+  end
+end
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
index 749c84736..f07f407d8 100644
--- a/app/services/backup_service.rb
+++ b/app/services/backup_service.rb
@@ -168,7 +168,7 @@ class BackupService < BaseService
         io.write(buffer)
       end
     end
-  rescue Errno::ENOENT, Seahorse::Client::NetworkingError
-    Rails.logger.warn "Could not backup file #{filename}: file not found"
+  rescue Errno::ENOENT, Seahorse::Client::NetworkingError => e
+    Rails.logger.warn "Could not backup file #{filename}: #{e}"
   end
 end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 363aa5ccf..2b649ee22 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -32,7 +32,7 @@ class BatchedRemoveStatusService < BaseService
 
     # Since we skipped all callbacks, we also need to manually
     # deindex the statuses
-    Chewy.strategy.current.update(StatusesIndex::Status, statuses_and_reblogs) if Chewy.enabled?
+    Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs) if Chewy.enabled?
 
     return if options[:skip_side_effects]
 
diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb
index 182f0e127..0e3fedfe7 100644
--- a/app/services/delete_account_service.rb
+++ b/app/services/delete_account_service.rb
@@ -4,6 +4,7 @@ class DeleteAccountService < BaseService
   include Payloadable
 
   ASSOCIATIONS_ON_SUSPEND = %w(
+    account_notes
     account_pins
     active_relationships
     aliases
@@ -16,7 +17,6 @@ class DeleteAccountService < BaseService
     domain_blocks
     featured_tags
     follow_requests
-    identity_proofs
     list_accounts
     migrations
     mute_relationships
@@ -34,6 +34,7 @@ class DeleteAccountService < BaseService
   # by foreign keys, making them safe to delete without loading
   # into memory
   ASSOCIATIONS_WITHOUT_SIDE_EFFECTS = %w(
+    account_notes
     account_pins
     aliases
     conversation_mutes
@@ -43,7 +44,6 @@ class DeleteAccountService < BaseService
     domain_blocks
     featured_tags
     follow_requests
-    identity_proofs
     list_accounts
     migrations
     mute_relationships
@@ -187,7 +187,7 @@ class DeleteAccountService < BaseService
     @account.favourites.in_batches do |favourites|
       ids = favourites.pluck(:status_id)
       StatusStat.where(status_id: ids).update_all('favourites_count = GREATEST(0, favourites_count - 1)')
-      Chewy.strategy.current.update(StatusesIndex::Status, ids) if Chewy.enabled?
+      Chewy.strategy.current.update(StatusesIndex, ids) if Chewy.enabled?
       Rails.cache.delete_multi(ids.map { |id| "statuses/#{id}" })
       favourites.delete_all
     end
@@ -195,7 +195,7 @@ class DeleteAccountService < BaseService
 
   def purge_bookmarks!
     @account.bookmarks.in_batches do |bookmarks|
-      Chewy.strategy.current.update(StatusesIndex::Status, bookmarks.pluck(:status_id)) if Chewy.enabled?
+      Chewy.strategy.current.update(StatusesIndex, bookmarks.pluck(:status_id)) if Chewy.enabled?
       bookmarks.delete_all
     end
   end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 6fa98ce12..46feec5aa 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -3,118 +3,134 @@
 class FanOutOnWriteService < BaseService
   # Push a status into home and mentions feeds
   # @param [Status] status
-  def call(status)
-    raise Mastodon::RaceConditionError if status.visibility.nil?
-
-    deliver_to_self(status) if status.account.local?
-
-    if status.direct_visibility?
-      deliver_to_mentioned_followers(status)
-      deliver_to_direct_timelines(status)
-      deliver_to_own_conversation(status)
-    elsif status.limited_visibility?
-      deliver_to_mentioned_followers(status)
-    else
-      deliver_to_followers(status)
-      deliver_to_lists(status)
-    end
+  # @param [Hash] options
+  # @option options [Boolean] update
+  # @option options [Array<Integer>] silenced_account_ids
+  def call(status, options = {})
+    @status    = status
+    @account   = status.account
+    @options   = options
+
+    check_race_condition!
+
+    fan_out_to_local_recipients!
+    fan_out_to_public_streams! if broadcastable?
+  end
 
-    return if status.account.silenced? || !status.public_visibility?
-    return if status.reblog? && !Setting.show_reblogs_in_public_timelines
+  private
 
-    render_anonymous_payload(status)
+  def check_race_condition!
+    # I don't know why but at some point we had an issue where
+    # this service was being executed with status objects
+    # that had a null visibility - which should not be possible
+    # since the column in the database is not nullable.
+    #
+    # This check re-queues the service to be run at a later time
+    # with the full object, if something like it occurs
 
-    deliver_to_hashtags(status)
+    raise Mastodon::RaceConditionError if @status.visibility.nil?
+  end
 
-    return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines
+  def fan_out_to_local_recipients!
+    deliver_to_self!
+    notify_mentioned_accounts!
 
-    deliver_to_public(status)
-    deliver_to_media(status) if status.media_attachments.any?
+    case @status.visibility.to_sym
+    when :public, :unlisted, :private
+      deliver_to_all_followers!
+      deliver_to_lists!
+    when :limited
+      deliver_to_mentioned_followers!
+    else
+      deliver_to_mentioned_followers!
+      deliver_to_conversation!
+      deliver_to_direct_timelines!
+    end
   end
 
-  private
+  def fan_out_to_public_streams!
+    broadcast_to_hashtag_streams!
+    broadcast_to_public_streams!
+  end
 
-  def deliver_to_self(status)
-    Rails.logger.debug "Delivering status #{status.id} to author"
-    FeedManager.instance.push_to_home(status.account, status)
-    FeedManager.instance.push_to_direct(status.account, status) if status.direct_visibility?
+  def deliver_to_self!
+    FeedManager.instance.push_to_home(@account, @status, update: update?) if @account.local?
+    FeedManager.instance.push_to_direct(@account, @status, update: update?) if @account.local? && @status.direct_visibility?
   end
 
-  def deliver_to_followers(status)
-    Rails.logger.debug "Delivering status #{status.id} to followers"
+  def notify_mentioned_accounts!
+    @status.active_mentions.where.not(id: @options[:silenced_account_ids] || []).joins(:account).merge(Account.local).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
+      LocalNotificationWorker.push_bulk(mentions) do |mention|
+        [mention.account_id, mention.id, 'Mention', 'mention']
+      end
+    end
+  end
 
-    status.account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers|
+  def deliver_to_all_followers!
+    @account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers|
       FeedInsertWorker.push_bulk(followers) do |follower|
-        [status.id, follower.id, :home]
+        [@status.id, follower.id, 'home', { 'update' => update? }]
       end
     end
   end
 
-  def deliver_to_lists(status)
-    Rails.logger.debug "Delivering status #{status.id} to lists"
-
-    status.account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists|
+  def deliver_to_lists!
+    @account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists|
       FeedInsertWorker.push_bulk(lists) do |list|
-        [status.id, list.id, :list]
+        [@status.id, list.id, 'list', { 'update' => update? }]
       end
     end
   end
 
-  def deliver_to_mentioned_followers(status)
-    Rails.logger.debug "Delivering status #{status.id} to limited followers"
-
-    status.mentions.joins(:account).merge(status.account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
+  def deliver_to_mentioned_followers!
+    @status.mentions.joins(:account).merge(@account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
       FeedInsertWorker.push_bulk(mentions) do |mention|
-        [status.id, mention.account_id, :home]
+        [@status.id, mention.account_id, 'home', { 'update' => update? }]
       end
     end
   end
 
-  def render_anonymous_payload(status)
-    @payload = InlineRenderer.render(status, nil, :status)
-    @payload = Oj.dump(event: :update, payload: @payload)
+  def deliver_to_direct_timelines!
+    FeedInsertWorker.push_bulk(@status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? }) do |account|
+      [@status.id, account.id, 'direct', { 'update' => update? }]
+    end
   end
 
-  def deliver_to_hashtags(status)
-    Rails.logger.debug "Delivering status #{status.id} to hashtags"
-
-    status.tags.pluck(:name).each do |hashtag|
-      Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
-      Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if status.local?
+  def broadcast_to_hashtag_streams!
+    @status.tags.pluck(:name).each do |hashtag|
+      Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload)
+      Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", anonymous_payload) if @status.local?
     end
   end
 
-  def deliver_to_public(status)
-    Rails.logger.debug "Delivering status #{status.id} to public timeline"
+  def broadcast_to_public_streams!
+    return if @status.reply? && @status.in_reply_to_account_id != @account.id && !Setting.show_replies_in_public_timelines
 
-    Redis.current.publish('timeline:public', @payload)
-    if status.local?
-      Redis.current.publish('timeline:public:local', @payload)
-    else
-      Redis.current.publish('timeline:public:remote', @payload)
+    Redis.current.publish('timeline:public', anonymous_payload)
+    Redis.current.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', anonymous_payload)
+
+    if @status.media_attachments.any?
+      Redis.current.publish('timeline:public:media', anonymous_payload)
+      Redis.current.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', anonymous_payload)
     end
   end
 
-  def deliver_to_media(status)
-    Rails.logger.debug "Delivering status #{status.id} to media timeline"
-
-    Redis.current.publish('timeline:public:media', @payload)
-    if status.local?
-      Redis.current.publish('timeline:public:local:media', @payload)
-    else
-      Redis.current.publish('timeline:public:remote:media', @payload)
-    end
+  def deliver_to_conversation!
+    AccountConversation.add_status(@account, @status) unless update?
   end
 
-  def deliver_to_direct_timelines(status)
-    Rails.logger.debug "Delivering status #{status.id} to direct timelines"
+  def anonymous_payload
+    @anonymous_payload ||= Oj.dump(
+      event: update? ? :'status.update' : :update,
+      payload: InlineRenderer.render(@status, nil, :status)
+    )
+  end
 
-    FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? }) do |account|
-      [status.id, account.id, :direct]
-    end
+  def update?
+    @options[:update]
   end
 
-  def deliver_to_own_conversation(status)
-    AccountConversation.add_status(status.account, status)
+  def broadcastable?
+    @status.public_visibility? && !@account.silenced? && (!@status.reblog? || Setting.show_reblogs_in_public_timelines)
   end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 5732ce8ac..94dc6389f 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -13,12 +13,12 @@ class FetchLinkCardService < BaseService
   }iox
 
   def call(status)
-    @status = status
-    @url    = parse_urls
+    @status       = status
+    @original_url = parse_urls
 
-    return if @url.nil? || @status.preview_cards.any?
+    return if @original_url.nil? || @status.preview_cards.any?
 
-    @url = @url.to_s
+    @url = @original_url.to_s
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
@@ -31,7 +31,7 @@ class FetchLinkCardService < BaseService
 
     attach_card if @card&.persisted?
   rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
-    Rails.logger.debug "Error fetching link #{@url}: #{e}"
+    Rails.logger.debug "Error fetching link #{@original_url}: #{e}"
     nil
   end
 
@@ -47,6 +47,12 @@ class FetchLinkCardService < BaseService
     return @html if defined?(@html)
 
     Request.new(:get, @url).add_headers('Accept' => 'text/html', 'User-Agent' => Mastodon::Version.user_agent + ' Bot').perform do |res|
+      # We follow redirects, and ideally we want to save the preview card for
+      # the destination URL and not any link shortener in-between, so here
+      # we set the URL to the one of the last response in the redirect chain
+      @url  = res.request.uri.to_s
+      @card = PreviewCard.find_or_initialize_by(url: @url) if @card.url != @url
+
       if res.code == 200 && res.mime_type == 'text/html'
         @html_charset = res.charset
         @html = res.body_with_limit
@@ -60,15 +66,19 @@ class FetchLinkCardService < BaseService
   def attach_card
     @status.preview_cards << @card
     Rails.cache.delete(@status)
+    Trends.links.register(@status)
   end
 
   def parse_urls
-    if @status.local?
-      urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
-    else
-      html  = Nokogiri::HTML(@status.text)
-      links = html.css('a')
-      urls  = links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
+    urls = begin
+      if @status.local?
+        @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
+      else
+        document = Nokogiri::HTML(@status.text)
+        links    = document.css('a')
+
+        links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
+      end
     end
 
     urls.reject { |uri| bad_url?(uri) }.first
@@ -79,18 +89,16 @@ class FetchLinkCardService < BaseService
     uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
   end
 
-  # rubocop:disable Naming/MethodParameterName
-  def mention_link?(a)
+  def mention_link?(anchor)
     @status.mentions.any? do |mention|
-      a['href'] == ActivityPub::TagManager.instance.url_for(mention.account)
+      anchor['href'] == ActivityPub::TagManager.instance.url_for(mention.account)
     end
   end
 
-  def skip_link?(a)
+  def skip_link?(anchor)
     # Avoid links for hashtags and mentions (microformats)
-    a['rel']&.include?('tag') || a['class']&.match?(/u-url|h-card/) || mention_link?(a)
+    anchor['rel']&.include?('tag') || anchor['class']&.match?(/u-url|h-card/) || mention_link?(anchor)
   end
-  # rubocop:enable Naming/MethodParameterName
 
   def attempt_oembed
     service         = FetchOEmbedService.new
@@ -139,42 +147,14 @@ class FetchLinkCardService < BaseService
   def attempt_opengraph
     return if html.nil?
 
-    detector = CharlockHolmes::EncodingDetector.new
-    detector.strip_tags = true
-
-    guess      = detector.detect(@html, @html_charset)
-    encoding   = guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil
-    page       = Nokogiri::HTML(@html, nil, encoding)
-    player_url = meta_property(page, 'twitter:player')
-
-    if player_url && !bad_url?(Addressable::URI.parse(player_url))
-      @card.type   = :video
-      @card.width  = meta_property(page, 'twitter:player:width') || 0
-      @card.height = meta_property(page, 'twitter:player:height') || 0
-      @card.html   = content_tag(:iframe, nil, src: player_url,
-                                               width: @card.width,
-                                               height: @card.height,
-                                               allowtransparency: 'true',
-                                               scrolling: 'no',
-                                               frameborder: '0')
-    else
-      @card.type = :link
-    end
-
-    @card.title            = meta_property(page, 'og:title').presence || page.at_xpath('//title')&.content || ''
-    @card.description      = meta_property(page, 'og:description').presence || meta_property(page, 'description') || ''
-    @card.image_remote_url = (Addressable::URI.parse(@url) + meta_property(page, 'og:image')).to_s if meta_property(page, 'og:image')
-
-    return if @card.title.blank? && @card.html.blank?
-
-    @card.save_with_optional_image!
-  end
+    link_details_extractor = LinkDetailsExtractor.new(@url, @html, @html_charset)
 
-  def meta_property(page, property)
-    page.at_xpath("//meta[contains(concat(' ', normalize-space(@property), ' '), ' #{property} ')]")&.attribute('content')&.value || page.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
+    @card = PreviewCard.find_or_initialize_by(url: link_details_extractor.canonical_url) if link_details_extractor.canonical_url != @card.url
+    @card.assign_attributes(link_details_extractor.to_preview_card_attributes)
+    @card.save_with_optional_image! unless @card.title.blank? && @card.html.blank?
   end
 
   def lock_options
-    { redis: Redis.current, key: "fetch:#{@url}", autorelease: 15.minutes.seconds }
+    { redis: Redis.current, key: "fetch:#{@original_url}", autorelease: 15.minutes.seconds }
   end
 end
diff --git a/app/services/fetch_oembed_service.rb b/app/services/fetch_oembed_service.rb
index 60be9b9dc..4cbaa04c6 100644
--- a/app/services/fetch_oembed_service.rb
+++ b/app/services/fetch_oembed_service.rb
@@ -2,6 +2,7 @@
 
 class FetchOEmbedService
   ENDPOINT_CACHE_EXPIRES_IN = 24.hours.freeze
+  URL_REGEX                 = /(=(http[s]?(%3A|:)(\/\/|%2F%2F)))([^&]*)/i.freeze
 
   attr_reader :url, :options, :format, :endpoint_url
 
@@ -65,10 +66,12 @@ class FetchOEmbedService
   end
 
   def cache_endpoint!
+    return unless URL_REGEX.match?(@endpoint_url)
+
     url_domain = Addressable::URI.parse(@url).normalized_host
 
     endpoint_hash = {
-      endpoint: @endpoint_url.gsub(/(=(http[s]?(%3A|:)(\/\/|%2F%2F)))([^&]*)/i, '={url}'),
+      endpoint: @endpoint_url.gsub(URL_REGEX, '={url}'),
       format: @format,
     }
 
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 329262cca..ed28e1371 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -68,7 +68,7 @@ class FollowService < BaseService
     follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
 
     if @target_account.local?
-      LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, :follow_request)
+      LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
     elsif @target_account.activitypub?
       ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
     end
@@ -79,7 +79,7 @@ class FollowService < BaseService
   def direct_follow!
     follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
 
-    LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, :follow)
+    LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, 'follow')
     MergeWorker.perform_async(@target_account.id, @source_account.id)
 
     follow
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index 74ad5b79f..8e6640b9d 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -76,7 +76,7 @@ class ImportService < BaseService
         if presence_hash[target_account.acct]
           items.delete(target_account.acct)
           extra = presence_hash[target_account.acct][1]
-          Import::RelationshipWorker.perform_async(@account.id, target_account.acct, action, extra)
+          Import::RelationshipWorker.perform_async(@account.id, target_account.acct, action, extra.stringify_keys)
         else
           Import::RelationshipWorker.perform_async(@account.id, target_account.acct, undo_action)
         end
@@ -87,7 +87,7 @@ class ImportService < BaseService
     tail_items = items - head_items
 
     Import::RelationshipWorker.push_bulk(head_items + tail_items) do |acct, extra|
-      [@account.id, acct, action, extra]
+      [@account.id, acct, action, extra.stringify_keys]
     end
   end
 
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index fc187db40..09e28b76b 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -67,8 +67,49 @@ class NotifyService < BaseService
     message? && @notification.target_status.direct_visibility?
   end
 
+  # Returns true if the sender has been mentionned by the recipient up the thread
   def response_to_recipient?
-    @notification.target_status.in_reply_to_account_id == @recipient.id && @notification.target_status.thread&.direct_visibility?
+    return false if @notification.target_status.in_reply_to_id.nil?
+
+    # Using an SQL CTE to avoid unneeded back-and-forth with SQL server in case of long threads
+    !Status.count_by_sql([<<-SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @notification.from_account.id]).zero?
+      WITH RECURSIVE ancestors(id, in_reply_to_id, replying_to_sender) AS (
+          SELECT
+            s.id, s.in_reply_to_id, (CASE
+              WHEN s.account_id = :recipient_id THEN
+                EXISTS (
+                  SELECT *
+                  FROM mentions m
+                  WHERE m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
+                )
+              ELSE
+                FALSE
+             END)
+          FROM statuses s
+          WHERE s.id = :id
+        UNION ALL
+          SELECT
+            s.id,
+            s.in_reply_to_id,
+            (CASE
+              WHEN s.account_id = :recipient_id THEN
+                EXISTS (
+                  SELECT *
+                  FROM mentions m
+                  WHERE m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
+                )
+              ELSE
+                FALSE
+             END)
+          FROM ancestors st
+          JOIN statuses s ON s.id = st.in_reply_to_id
+          WHERE st.replying_to_sender IS FALSE
+      )
+      SELECT COUNT(*)
+      FROM ancestors st
+      JOIN statuses s ON s.id = st.id
+      WHERE st.replying_to_sender IS TRUE AND s.visibility = 3
+    SQL
   end
 
   def from_staff?
@@ -127,7 +168,7 @@ class NotifyService < BaseService
   def push_notification!
     return if @notification.activity.nil?
 
-    Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
+    Redis.current.publish("timeline:#{@recipient.id}:notifications", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
     send_push_notifications!
   end
 
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 250d0e8ed..9d26e0f5b 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -83,6 +83,9 @@ class PostStatusService < BaseService
     status_for_validation = @account.statuses.build(status_attributes)
 
     if status_for_validation.valid?
+      # Marking the status as destroyed is necessary to prevent the status from being
+      # persisted when the associated media attachments get updated when creating the
+      # scheduled status.
       status_for_validation.destroy
 
       # The following transaction block is needed to wrap the UPDATEs to
@@ -97,7 +100,8 @@ class PostStatusService < BaseService
   end
 
   def postprocess_status!
-    LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
+    Trends.tags.register(@status)
+    LinkCrawlWorker.perform_async(@status.id)
     DistributionWorker.perform_async(@status.id)
     ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only?
     PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index c42b79db8..47277c56c 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -8,7 +8,7 @@ class ProcessHashtagsService < BaseService
     Tag.find_or_create_by_names(tags) do |tag|
       status.tags << tag
       records << tag
-      tag.use!(status.account, status: status, at_time: status.created_at) if status.public_visibility?
+      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
     end
 
     return unless status.distributable?
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index ec4cb11f9..9d239fc65 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -8,12 +8,23 @@ class ProcessMentionsService < BaseService
   # remote users
   # @param [Status] status
   def call(status)
-    return unless status.local?
+    @status = status
 
-    @status  = status
-    mentions = []
+    return unless @status.local?
 
-    status.text = status.text.gsub(Account::MENTION_RE) do |match|
+    @previous_mentions = @status.active_mentions.includes(:account).to_a
+    @current_mentions  = []
+
+    Status.transaction do
+      scan_text!
+      assign_mentions!
+    end
+  end
+
+  private
+
+  def scan_text!
+    @status.text = @status.text.gsub(Account::MENTION_RE) do |match|
       username, domain = Regexp.last_match(1).split('@')
 
       domain = begin
@@ -26,49 +37,45 @@ class ProcessMentionsService < BaseService
 
       mentioned_account = Account.find_remote(username, domain)
 
+      # If the account cannot be found or isn't the right protocol,
+      # first try to resolve it
       if mention_undeliverable?(mentioned_account)
         begin
-          mentioned_account = resolve_account_service.call(Regexp.last_match(1))
+          mentioned_account = ResolveAccountService.new.call(Regexp.last_match(1))
         rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
           mentioned_account = nil
         end
       end
 
+      # If after resolving it still isn't found or isn't the right
+      # protocol, then give up
       next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
 
-      mention = mentioned_account.mentions.new(status: status)
-      mentions << mention if mention.save
+      mention   = @previous_mentions.find { |x| x.account_id == mentioned_account.id }
+      mention ||= mentioned_account.mentions.new(status: @status)
+
+      @current_mentions << mention
 
       "@#{mentioned_account.acct}"
     end
 
-    status.save!
-
-    mentions.each { |mention| create_notification(mention) }
+    @status.save!
   end
 
-  private
-
-  def mention_undeliverable?(mentioned_account)
-    mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?)
-  end
-
-  def create_notification(mention)
-    mentioned_account = mention.account
-
-    if mentioned_account.local?
-      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention)
-    elsif mentioned_account.activitypub? && !@status.local_only?
-      ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? })
+  def assign_mentions!
+    @current_mentions.each do |mention|
+      mention.save if mention.new_record?
     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))
+    # If previous mentions are no longer contained in the text, convert them
+    # to silent mentions, since withdrawing access from someone who already
+    # received a notification might be more confusing
+    removed_mentions = @previous_mentions - @current_mentions
+
+    Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty?
   end
 
-  def resolve_account_service
-    ResolveAccountService.new
+  def mention_undeliverable?(mentioned_account)
+    mentioned_account.nil? || (!mentioned_account.local? && !mentioned_account.activitypub?)
   end
 end
diff --git a/app/services/purge_domain_service.rb b/app/services/purge_domain_service.rb
new file mode 100644
index 000000000..9df81f13e
--- /dev/null
+++ b/app/services/purge_domain_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class PurgeDomainService < BaseService
+  def call(domain)
+    Account.remote.where(domain: domain).reorder(nil).find_each do |account|
+      DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true)
+    end
+    CustomEmoji.remote.where(domain: domain).reorder(nil).find_each(&:destroy)
+    Instance.refresh
+  end
+end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index f41276de0..6556fbff7 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -30,12 +30,13 @@ class ReblogService < BaseService
 
     reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
 
+    Trends.tags.register(reblog)
+    Trends.links.register(reblog)
     DistributionWorker.perform_async(reblog.id)
     ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
 
     create_notification(reblog)
     bump_potential_friendship(account, reblog)
-    record_use(account, reblog)
 
     reblog
   end
@@ -46,7 +47,7 @@ class ReblogService < BaseService
     reblogged_status = reblog.reblog
 
     if reblogged_status.account.local?
-      LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, :reblog)
+      LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog')
     elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
       ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
     end
@@ -60,16 +61,6 @@ class ReblogService < BaseService
     PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog)
   end
 
-  def record_use(account, reblog)
-    return unless reblog.public_visibility?
-
-    original_status = reblog.reblog
-
-    original_status.tags.each do |tag|
-      tag.use!(account)
-    end
-  end
-
   def build_json(reblog)
     Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account))
   end
diff --git a/app/services/remove_from_followers_service.rb b/app/services/remove_from_followers_service.rb
new file mode 100644
index 000000000..3dac5467f
--- /dev/null
+++ b/app/services/remove_from_followers_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class RemoveFromFollowersService < BaseService
+  include Payloadable
+
+  def call(source_account, target_accounts)
+    source_account.passive_relationships.where(account_id: target_accounts).find_each do |follow|
+      follow.destroy
+
+      if source_account.local? && !follow.account.local? && follow.account.activitypub?
+        create_notification(follow)
+      end
+    end
+  end
+
+  private
+
+  def create_notification(follow)
+    ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.target_account_id, follow.account.inbox_url)
+  end
+
+  def build_json(follow)
+    Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
+  end
+end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 20c17e6df..e41ad2b0a 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -9,6 +9,7 @@ class RemoveStatusService < BaseService
   # @param   [Hash] options
   # @option  [Boolean] :redraft
   # @option  [Boolean] :immediate
+  # @option  [Boolean] :preserve
   # @option  [Boolean] :original_removed
   def call(status, **options)
     @payload  = Oj.dump(event: :delete, payload: status.id.to_s)
@@ -44,7 +45,7 @@ class RemoveStatusService < BaseService
           remove_media
         end
 
-        @status.destroy! if @options[:immediate] || !@status.reported?
+        @status.destroy! if permanently?
       else
         raise Mastodon::RaceConditionError
       end
@@ -88,7 +89,7 @@ class RemoveStatusService < BaseService
     # the author and wouldn't normally receive the delete
     # notification - so here, we explicitly send it to them
 
-    status_reach_finder = StatusReachFinder.new(@status)
+    status_reach_finder = StatusReachFinder.new(@status, unsafe: true)
 
     ActivityPub::DeliveryWorker.push_bulk(status_reach_finder.inboxes) do |inbox_url|
       [signed_activity_json, @account.id, inbox_url]
@@ -104,7 +105,7 @@ class RemoveStatusService < BaseService
     # because once original status is gone, reblogs will disappear
     # without us being able to do all the fancy stuff
 
-    @status.reblogs.includes(:account).find_each do |reblog|
+    @status.reblogs.includes(:account).reorder(nil).find_each do |reblog|
       RemoveStatusService.new.call(reblog, original_removed: true)
     end
   end
@@ -143,11 +144,15 @@ class RemoveStatusService < BaseService
   end
 
   def remove_media
-    return if @options[:redraft] || (!@options[:immediate] && @status.reported?)
+    return if @options[:redraft] || !permanently?
 
     @status.media_attachments.destroy_all
   end
 
+  def permanently?
+    @options[:immediate] || !(@options[:preserve] || @status.reported?)
+  end
+
   def lock_options
     { redis: Redis.current, key: "distribute:#{@status.id}", autorelease: 5.minutes.seconds }
   end
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index 5400612bf..3a372ef2a 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -142,7 +142,8 @@ class ResolveAccountService < BaseService
   end
 
   def queue_deletion!
-    AccountDeletionWorker.perform_async(@account.id, reserve_username: false, skip_activitypub: true)
+    @account.suspend!(origin: :remote)
+    AccountDeletionWorker.perform_async(@account.id, { 'reserve_username' => false, 'skip_activitypub' => true })
   end
 
   def lock_options
diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb
index 949c670aa..39d8a6ba7 100644
--- a/app/services/unsuspend_account_service.rb
+++ b/app/services/unsuspend_account_service.rb
@@ -1,13 +1,14 @@
 # frozen_string_literal: true
 
 class UnsuspendAccountService < BaseService
+  include Payloadable
   def call(account)
     @account = account
 
     unsuspend!
     refresh_remote_account!
 
-    return if @account.nil?
+    return if @account.nil? || @account.suspended?
 
     merge_into_home_timelines!
     merge_into_list_timelines!
diff --git a/app/validators/reaction_validator.rb b/app/validators/reaction_validator.rb
index 494b6041b..4ed3376e8 100644
--- a/app/validators/reaction_validator.rb
+++ b/app/validators/reaction_validator.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class ReactionValidator < ActiveModel::Validator
-  SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze
+  SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze
 
   LIMIT = 8
 
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
index 11997024f..2a3ac8862 100644
--- a/app/validators/status_length_validator.rb
+++ b/app/validators/status_length_validator.rb
@@ -2,7 +2,8 @@
 
 class StatusLengthValidator < ActiveModel::Validator
   MAX_CHARS = (ENV['MAX_TOOT_CHARS'] || 500).to_i
-  URL_PLACEHOLDER = "\1#{'x' * 23}"
+  URL_PLACEHOLDER_CHARS = 23
+  URL_PLACEHOLDER = "\1#{'x' * URL_PLACEHOLDER_CHARS}"
 
   def validate(status)
     return unless status.local? && !status.reblog?
diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb
index 16353066c..35a101f1d 100644
--- a/app/validators/status_pin_validator.rb
+++ b/app/validators/status_pin_validator.rb
@@ -6,7 +6,7 @@ class StatusPinValidator < ActiveModel::Validator
   def validate(pin)
     pin.errors.add(:base, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog?
     pin.errors.add(:base, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id
-    pin.errors.add(:base, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility)
+    pin.errors.add(:base, I18n.t('statuses.pin_errors.direct')) if pin.status.direct_visibility?
     pin.errors.add(:base, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count >= MAX_PINNED  && pin.account.local?
   end
 end
diff --git a/app/views/about/_login.html.haml b/app/views/about/_login.html.haml
index fa58f04d7..0f19e8164 100644
--- a/app/views/about/_login.html.haml
+++ b/app/views/about/_login.html.haml
@@ -1,13 +1,22 @@
-= simple_form_for(new_user, url: user_session_path, namespace: 'login') do |f|
-  .fields-group
-    - if use_seamless_external_login?
-      = f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false
-    - else
-      = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
+- unless omniauth_only?
+  = simple_form_for(new_user, url: user_session_path, namespace: 'login') do |f|
+    .fields-group
+      - if use_seamless_external_login?
+        = f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false
+      - else
+        = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
 
-    = f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false
+      = f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false
 
-  .actions
-    = f.button :button, t('auth.login'), type: :submit, class: 'button button-primary'
+    .actions
+      = f.button :button, t('auth.login'), type: :submit, class: 'button button-primary'
 
-  %p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path
+    %p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path
+
+- if Devise.mappings[:user].omniauthable? and User.omniauth_providers.any?
+  .simple_form.alternative-login
+    %h4= omniauth_only? ? t('auth.log_in_with') : t('auth.or_log_in_with')
+
+    .actions
+      - User.omniauth_providers.each do |provider|
+        = provider_sign_in_link(provider)
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 1cf194522..a4a79c4e7 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -16,11 +16,11 @@
         .row__information-board
           .information-board__section
             %span= t 'about.user_count_before'
-            %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true
+            %strong= friendly_number_to_human @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_to_human @instance_presenter.status_count, strip_insignificant_zeros: true
+            %strong= friendly_number_to_human @instance_presenter.status_count
             %span= t 'about.status_count_after', count: @instance_presenter.status_count
         .row__mascot
           .landing-page__mascot
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 565c4ed59..6ae9e6ae0 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -70,10 +70,10 @@
 
             .hero-widget__counters__wrapper
               .hero-widget__counter
-                %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true
+                %strong= friendly_number_to_human @instance_presenter.user_count
                 %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
+                %strong= friendly_number_to_human @instance_presenter.active_user_count
                 %span
                   = t 'about.active_count_after'
                   %abbr{ title: t('about.active_footnote') } *
diff --git a/app/views/accounts/_bio.html.haml b/app/views/accounts/_bio.html.haml
index efc26d136..e8a49a1aa 100644
--- a/app/views/accounts/_bio.html.haml
+++ b/app/views/accounts/_bio.html.haml
@@ -1,16 +1,8 @@
-- proofs = account.identity_proofs.active
 - fields = account.fields
 
 .public-account-bio
-  - unless fields.empty? && proofs.empty?
+  - unless fields.empty?
     .account__header__fields
-      - proofs.each do |proof|
-        %dl
-          %dt= proof.provider.capitalize
-          %dd.verified
-            = link_to fa_icon('check'), proof.badge.proof_url, class: 'verified__mark', title: t('accounts.link_verified_on', date: l(proof.updated_at))
-            = link_to proof.provider_username, proof.badge.profile_url
-
       - fields.each do |field|
         %dl
           %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true)
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index 76dec18b1..d583edbd2 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -15,17 +15,17 @@
         .details-counters
           .counter{ class: active_nav_class(short_account_url(account), short_account_with_replies_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-number= friendly_number_to_human account.statuses_count
               %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-number= friendly_number_to_human account.following_count
               %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-number= hide_followers_count?(account) ? '-' : (friendly_number_to_human account.followers_count)
               %span.counter-label= t('accounts.followers', count: account.followers_count)
         .spacer
         .public-account-header__tabs__tabs__buttons
@@ -36,8 +36,8 @@
 
       .public-account-header__extra__links
         = link_to account_following_index_url(account) do
-          %strong= number_to_human account.following_count, strip_insignificant_zeros: true
+          %strong= friendly_number_to_human account.following_count
           = t('accounts.following', count: account.following_count)
         = link_to account_followers_url(account) do
-          %strong= hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
+          %strong= hide_followers_count?(account) ? '-' : (friendly_number_to_human account.followers_count)
           = t('accounts.followers', count: account.followers_count)
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 1a81b96f6..72e9c6611 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -81,6 +81,6 @@
                   = t('accounts.nothing_here')
                 - else
                   %time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
-            .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
+            .trends__item__current= friendly_number_to_human featured_tag.statuses_count
 
     = render 'application/sidebar'
diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml
index c9bd8c686..2df91301e 100644
--- a/app/views/admin/accounts/_account.html.haml
+++ b/app/views/admin/accounts/_account.html.haml
@@ -1,24 +1,35 @@
-%tr
-  %td
-    = admin_account_link_to(account)
-  %td
-    %div.account-badges= account_badge(account, all: true)
-  %td
-    - if account.user_current_sign_in_ip
-      %samp.ellipsized-ip{ title: account.user_current_sign_in_ip }= account.user_current_sign_in_ip
-    - else
-      \-
-  %td
-    - if account.user_current_sign_in_at
-      %time.time-ago{ datetime: account.user_current_sign_in_at.iso8601, title: l(account.user_current_sign_in_at) }= l account.user_current_sign_in_at
-    - elsif account.last_status_at.present?
-      %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
-    - else
-      \-
-  %td
-    - if account.local? && account.user_pending?
-      = table_link_to 'check', t('admin.accounts.approve'), approve_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:approve, account.user)
-      = table_link_to 'times', t('admin.accounts.reject'), reject_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:reject, account.user)
-    - else
-      = table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}")
-      = table_link_to 'globe', t('admin.accounts.public'), ActivityPub::TagManager.instance.url_for(account)
+.batch-table__row{ class: [!account.suspended? && account.user_pending? && 'batch-table__row--attention', account.suspended? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
+  .batch-table__row__content.batch-table__row__content--unpadded
+    %table.accounts-table
+      %tbody
+        %tr
+          %td
+            = account_link_to account, path: admin_account_path(account.id)
+          %td.accounts-table__count.optional
+            - if account.suspended? || account.user_pending?
+              \-
+            - else
+              = friendly_number_to_human account.statuses_count
+            %small= t('accounts.posts', count: account.statuses_count).downcase
+          %td.accounts-table__count.optional
+            - if account.suspended? || account.user_pending?
+              \-
+            - else
+              = friendly_number_to_human account.followers_count
+            %small= t('accounts.followers', count: account.followers_count).downcase
+          %td.accounts-table__count
+            = relevant_account_timestamp(account)
+            %small= t('accounts.last_active')
+          %td.accounts-table__extra
+            - if account.local?
+              - if account.user_email
+                = link_to account.user_email.split('@').last, admin_accounts_path(email: "%@#{account.user_email.split('@').last}"), title: account.user_email
+              - else
+                \-
+              %br/
+              %samp.ellipsized-ip= relevant_account_ip(account, params[:ip])
+    - if !account.suspended? && account.user_pending? && account.user&.invite_request&.text&.present?
+      .batch-table__row__content__quote
+        %p= account.user&.invite_request&.text
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 398ab4bb4..fc667b376 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -5,30 +5,30 @@
   .filter-subset
     %strong= t('admin.accounts.location.title')
     %ul
-      %li= filter_link_to t('admin.accounts.location.local'), remote: nil
-      %li= filter_link_to t('admin.accounts.location.remote'), remote: '1'
+      %li= filter_link_to t('generic.all'), origin: nil
+      %li= filter_link_to t('admin.accounts.location.local'), origin: 'local'
+      %li= filter_link_to t('admin.accounts.location.remote'), origin: 'remote'
   .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= 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
+      %li= filter_link_to t('generic.all'), status: nil
+      %li= filter_link_to t('admin.accounts.moderation.active'), status: 'active'
+      %li= filter_link_to t('admin.accounts.moderation.suspended'), status: 'suspended'
+      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), status: 'pending'
   .filter-subset
     %strong= t('admin.accounts.role')
     %ul
-      %li= filter_link_to t('admin.accounts.moderation.all'), staff: nil
-      %li= filter_link_to t('admin.accounts.roles.staff'), staff: '1'
+      %li= filter_link_to t('admin.accounts.moderation.all'), permissions: nil
+      %li= filter_link_to t('admin.accounts.roles.staff'), permissions: 'staff'
   .filter-subset
     %strong= t 'generic.order_by'
     %ul
       %li= filter_link_to t('relationships.most_recent'), order: nil
-      %li= filter_link_to t('admin.accounts.username'), order: 'alphabetic'
       %li= filter_link_to t('relationships.last_active'), order: 'active'
 
 = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
   .fields-group
-    - AccountFilter::KEYS.each do |key|
+    - (AccountFilter::KEYS - %i(origin status permissions)).each do |key|
       - if params[key].present?
         = hidden_field_tag key, params[key]
 
@@ -41,16 +41,27 @@
       %button.button= t('admin.accounts.search')
       = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
 
-.table-wrapper
-  %table.table
-    %thead
-      %tr
-        %th= t('admin.accounts.username')
-        %th= t('admin.accounts.role')
-        %th= t('admin.accounts.most_recent_ip')
-        %th= t('admin.accounts.most_recent_activity')
-        %th
-    %tbody
-      = render partial: 'account', collection: @accounts
+= form_for(@form, url: batch_admin_accounts_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - AccountFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        - if @accounts.any? { |account| account.user_pending? }
+          = f.button safe_join([fa_icon('check'), t('admin.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+          = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+        = f.button safe_join([fa_icon('lock'), t('admin.accounts.perform_full_suspension')]), name: :suspend, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      - if @accounts.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'account', collection: @accounts, locals: { f: f }
 
 = paginate @accounts
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 27e1f80a7..3867d1b19 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -8,20 +8,12 @@
 = render 'application/card', account: @account
 
 - account = @account
-- proofs = account.identity_proofs.active
 - fields = account.fields
-- unless fields.empty? && proofs.empty? && account.note.blank?
+- unless fields.empty? && account.note.blank?
   .admin-account-bio
-    - unless fields.empty? && proofs.empty?
+    - unless fields.empty?
       %div
         .account__header__fields
-          - proofs.each do |proof|
-            %dl
-              %dt= proof.provider.capitalize
-              %dd.verified
-                = link_to fa_icon('check'), proof.badge.proof_url, class: 'verified__mark', title: t('accounts.link_verified_on', date: l(proof.updated_at))
-                = link_to proof.provider_username, proof.badge.profile_url
-
           - fields.each do |field|
             %dl
               %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true)
@@ -79,7 +71,9 @@
           = t('admin.accounts.no_limits_imposed')
       .dashboard__counters__label= t 'admin.accounts.login_status'
 
-- unless @account.local? && @account.user.nil?
+- if @account.local? && @account.user.nil?
+  = link_to t('admin.accounts.unblock_email'), unblock_email_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unblock_email, @account) && CanonicalEmailBlock.where(reference_account_id: @account.id).exists?
+- else
   .table-wrapper
     %table.table.inline-table
       %tbody
@@ -129,6 +123,27 @@
               - else
                 = t('admin.accounts.confirming')
             %td= table_link_to 'refresh', t('admin.accounts.resend_confirmation.send'), resend_admin_account_confirmation_path(@account.id), method: :post if can?(:confirm, @account.user)
+          %tr
+            %th{ rowspan: can?(:reset_password, @account.user) ? 2 : 1 }= t('admin.accounts.security')
+            %td{ rowspan: can?(:reset_password, @account.user) ? 2 : 1 }
+              - if @account.user&.two_factor_enabled?
+                = t 'admin.accounts.security_measures.password_and_2fa'
+              - elsif @account.user&.skip_sign_in_token?
+                = t 'admin.accounts.security_measures.only_password'
+              - else
+                = t 'admin.accounts.security_measures.password_and_sign_in_token'
+            %td
+              - if @account.user&.two_factor_enabled?
+                = table_link_to 'unlock', t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete if can?(:disable_2fa, @account.user)
+              - elsif @account.user&.skip_sign_in_token?
+                = table_link_to 'lock', t('admin.accounts.enable_sign_in_token_auth'), admin_user_sign_in_token_authentication_path(@account.user.id), method: :post if can?(:enable_sign_in_token_auth, @account.user)
+              - else
+                = table_link_to 'unlock', t('admin.accounts.disable_sign_in_token_auth'), admin_user_sign_in_token_authentication_path(@account.user.id), method: :delete if can?(:disable_sign_in_token_auth, @account.user)
+
+          - if can?(:reset_password, @account.user)
+            %tr
+              %td
+                = table_link_to 'key', t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, data: { confirm: t('admin.accounts.are_you_sure') }
 
           %tr
             %th= t('simple_form.labels.defaults.locale')
@@ -141,12 +156,14 @@
               %time.formatted{ datetime: @account.created_at.iso8601, title: l(@account.created_at) }= l @account.created_at
             %td
 
-          - @account.user.recent_ips.each_with_index do |(_, ip), i|
+          - recent_ips = @account.user.ips.order(used_at: :desc).to_a
+
+          - recent_ips.each_with_index do |recent_ip, i|
             %tr
               - if i.zero?
-                %th{ rowspan: @account.user.recent_ips.size }= t('admin.accounts.most_recent_ip')
-              %td= ip
-              %td= table_link_to 'search', t('admin.accounts.search_same_ip'), admin_accounts_path(ip: ip)
+                %th{ rowspan: recent_ips.size }= t('admin.accounts.most_recent_ip')
+              %td= recent_ip.ip
+              %td= table_link_to 'search', t('admin.accounts.search_same_ip'), admin_accounts_path(ip: recent_ip.ip)
 
           %tr
             %th= t('admin.accounts.most_recent_activity')
@@ -221,9 +238,6 @@
 
       %div
         - if @account.local?
-          = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
-          - if @account.user&.otp_required_for_login?
-            = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
           - if !@account.memorial? && @account.user_approved?
             = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account)
         - else
diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml
index 347eca166..03d5bffb9 100644
--- a/app/views/admin/action_logs/index.html.haml
+++ b/app/views/admin/action_logs/index.html.haml
@@ -19,7 +19,7 @@
   %div.muted-hint.center-text
     = t 'admin.action_logs.empty'
 - else
-  .announcements-list
+  .report-notes
     = render partial: 'action_log', collection: @action_logs
 
 = paginate @action_logs
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index e25b80846..2ee13b9e2 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,6 +1,11 @@
 - content_for :page_title do
   = t('admin.dashboard.title')
 
+- content_for :heading_actions do
+  = l(@time_period.first)
+  = ' - '
+  = l(@time_period.last)
+
 - unless @system_checks.empty?
   .flash-message-stack
     - @system_checks.each do |message|
@@ -9,133 +14,52 @@
         - if message.action
           = link_to t("admin.system_checks.#{message.key}.action"), message.action
 
-.dashboard__counters
-  %div
-    = link_to admin_accounts_url(local: 1, recent: 1) do
-      .dashboard__counters__num{ title: number_with_delimiter(@users_count, strip_insignificant_zeros: true) }
-        = number_to_human @users_count, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.total_users'
-  %div
-    %div
-      .dashboard__counters__num{ title: number_with_delimiter(@registrations_week, strip_insignificant_zeros: true) }
-        = number_to_human @registrations_week, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.week_users_new'
-  %div
-    %div
-      .dashboard__counters__num{ title: number_with_delimiter(@logins_week, strip_insignificant_zeros: true) }
-        = number_to_human @logins_week, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.week_users_active'
-  %div
-    = link_to admin_pending_accounts_path do
-      .dashboard__counters__num{ title: number_with_delimiter(@pending_users_count, strip_insignificant_zeros: true) }
-        = number_to_human @pending_users_count, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.pending_users'
-  %div
-    = link_to admin_reports_url do
-      .dashboard__counters__num{ title: number_with_delimiter(@reports_count, strip_insignificant_zeros: true) }
-        = number_to_human @reports_count, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.open_reports'
-  %div
-    = link_to admin_tags_path(pending_review: '1') do
-      .dashboard__counters__num{ title: number_with_delimiter(@pending_tags_count, strip_insignificant_zeros: true) }
-        = number_to_human @pending_tags_count, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.pending_tags'
-  %div
-    %div
-      .dashboard__counters__num{ title: number_with_delimiter(@interactions_week, strip_insignificant_zeros: true) }
-        = number_to_human @interactions_week, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.week_interactions'
-  %div
-    = link_to sidekiq_url do
-      .dashboard__counters__num{ title: number_with_delimiter(@queue_backlog, strip_insignificant_zeros: true) }
-        = number_to_human @queue_backlog, strip_insignificant_zeros: true
-      .dashboard__counters__label= t 'admin.dashboard.backlog'
-
-.dashboard__widgets
-  .dashboard__widgets__users
-    %div
-      %h4= t 'admin.dashboard.recent_users'
-      %ul
-        - @recent_users.each do |user|
-          %li= admin_account_link_to(user.account)
-
-  .dashboard__widgets__features
-    %div
-      %h4= t 'admin.dashboard.features'
-      %ul
-        %li
-          = feature_hint(link_to(t('admin.dashboard.feature_registrations'), edit_admin_settings_path), @registrations_enabled)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.feature_invites'), edit_admin_settings_path), @invites_enabled)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.feature_deletions'), edit_admin_settings_path), @deletions_enabled)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.feature_profile_directory'), edit_admin_settings_path), @profile_directory)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.keybase'), edit_admin_settings_path), @keybase_integration)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled)
-
-  .dashboard__widgets__versions
-    %div
-      %h4= t 'admin.dashboard.software'
-      %ul
-        %li
-          Mastodon
-          %span.pull-right= @version
-        %li
-          Ruby
-          %span.pull-right= "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
-        %li
-          PostgreSQL
-          %span.pull-right= @database_version
-        %li
-          Redis
-          %span.pull-right= @redis_version
-
-  .dashboard__widgets__space
-    %div
-      %h4= t 'admin.dashboard.space'
-      %ul
-        %li
-          PostgreSQL
-          %span.pull-right= number_to_human_size @database_size
-        %li
-          Redis
-          %span.pull-right= number_to_human_size @redis_size
-
-  .dashboard__widgets__config
-    %div
-      %h4= t 'admin.dashboard.config'
-      %ul
-        %li
-          = feature_hint(t('admin.dashboard.search'), @search_enabled)
-        %li
-          = feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode)
-        %li
-          = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
-        %li
-          = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
-        %li
-          = feature_hint('LDAP', @ldap_enabled)
-        %li
-          = feature_hint('CAS', @cas_enabled)
-        %li
-          = feature_hint('SAML', @saml_enabled)
-        %li
-          = feature_hint('PAM', @pam_enabled)
-        %li
-          = feature_hint(t('admin.dashboard.hidden_service'), @hidden_service)
-
-  .dashboard__widgets__trends
-    %div
-      %h4= t 'admin.dashboard.trends'
-      %ul
-        - @trending_hashtags.each do |tag|
-          %li
-            = link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id)
-            %span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)
+.dashboard
+  .dashboard__item
+    = react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path
+
+  .dashboard__item
+    = react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path
+
+  .dashboard__item
+    = react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions')
+
+  .dashboard__item
+    = react_admin_component :counter, measure: 'opened_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.opened_reports'), href: admin_reports_path
+
+  .dashboard__item
+    = react_admin_component :counter, measure: 'resolved_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.resolved_reports'), href: admin_reports_path(resolved: '1')
+
+  .dashboard__item
+    = link_to admin_reports_path, class: 'dashboard__quick-access' do
+      %span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
+      = fa_icon 'chevron-right fw'
+
+    = link_to admin_accounts_path(status: 'pending'), class: 'dashboard__quick-access' do
+      %span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
+      = fa_icon 'chevron-right fw'
+
+    = link_to admin_trends_tags_path(status: 'pending_review'), class: 'dashboard__quick-access' do
+      %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
+      = fa_icon 'chevron-right fw'
+
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
+
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'languages', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_languages')
+
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'servers', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_servers')
+
+  .dashboard__item.dashboard__item--span-double-column
+    = react_admin_component :retention, start_at: @time_period.last - 6.months,   end_at: @time_period.last, frequency: 'month'
+
+  .dashboard__item.dashboard__item--span-double-row
+    = react_admin_component :trends, limit: 7
+
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'software_versions', start_at: @time_period.first, end_at: @time_period.last, limit: 4, label: t('admin.dashboard.software')
+
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'space_usage', start_at: @time_period.first, end_at: @time_period.last, limit: 3, label: t('admin.dashboard.space')
diff --git a/app/views/admin/follow_recommendations/_account.html.haml b/app/views/admin/follow_recommendations/_account.html.haml
index af5a4aaf7..00196dd01 100644
--- a/app/views/admin/follow_recommendations/_account.html.haml
+++ b/app/views/admin/follow_recommendations/_account.html.haml
@@ -7,10 +7,10 @@
         %tr
           %td= account_link_to account
           %td.accounts-table__count.optional
-            = number_to_human account.statuses_count, strip_insignificant_zeros: true
+            = friendly_number_to_human account.statuses_count
             %small= t('accounts.posts', count: account.statuses_count).downcase
           %td.accounts-table__count.optional
-            = number_to_human account.followers_count, strip_insignificant_zeros: true
+            = friendly_number_to_human account.followers_count
             %small= t('accounts.followers', count: account.followers_count).downcase
           %td.accounts-table__count
             - if account.last_status_at.present?
diff --git a/app/views/admin/instances/_instance.html.haml b/app/views/admin/instances/_instance.html.haml
index 990cf9ec8..dc81007ac 100644
--- a/app/views/admin/instances/_instance.html.haml
+++ b/app/views/admin/instances/_instance.html.haml
@@ -30,4 +30,4 @@
           = ' / '
           %span.negative-hint
             = t('admin.instances.delivery.unavailable_message')
-    .trends__item__current{ title: t('admin.instances.known_accounts', count: instance.accounts_count) }= number_to_human instance.accounts_count, strip_insignificant_zeros: true
+    .trends__item__current{ title: t('admin.instances.known_accounts', count: instance.accounts_count) }= friendly_number_to_human instance.accounts_count
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index 462529338..e520bca0c 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -15,7 +15,7 @@
 
 .dashboard__counters
   %div
-    = link_to admin_accounts_path(remote: '1', by_domain: @instance.domain) do
+    = link_to admin_accounts_path(origin: 'remote', by_domain: @instance.domain) do
       .dashboard__counters__num= number_with_delimiter @instance.accounts_count
       .dashboard__counters__label= t 'admin.accounts.title'
   %div
@@ -84,3 +84,5 @@
       = link_to t('admin.instances.delivery.stop'), stop_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }, class: 'button'
     - else
       = link_to t('admin.instances.delivery.restart'), restart_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }, class: 'button'
+    - unless @instance.delivery_failure_tracker.available? && @instance.accounts_count > 0
+      = link_to t('admin.instances.purge'), admin_instance_path(@instance), data: { confirm: t('admin.instances.confirm_purge'), method: :delete }, class: 'button'
diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml
index e07e2b444..b8d3ac0e8 100644
--- a/app/views/admin/ip_blocks/_ip_block.html.haml
+++ b/app/views/admin/ip_blocks/_ip_block.html.haml
@@ -1,9 +1,9 @@
 .batch-table__row
   %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
     = f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id
-  .batch-table__row__content
-    .batch-table__row__content__text
-      %samp= "#{ip_block.ip}/#{ip_block.ip.prefix}"
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      %samp= link_to "#{ip_block.ip}/#{ip_block.ip.prefix}", admin_accounts_path(ip: "#{ip_block.ip}/#{ip_block.ip.prefix}")
       - if ip_block.comment.present?

         = ip_block.comment
diff --git a/app/views/admin/pending_accounts/_account.html.haml b/app/views/admin/pending_accounts/_account.html.haml
deleted file mode 100644
index 5b475b59a..000000000
--- a/app/views/admin/pending_accounts/_account.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-.batch-table__row
-  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
-    = f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
-  .batch-table__row__content.pending-account
-    .pending-account__header
-      = link_to admin_account_path(account.id) do
-        %strong= account.user_email
-        = "(@#{account.username})"
-      %br/
-      %samp= account.user_current_sign_in_ip
-      •
-      = t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)
-
-    - if account.user&.invite_request&.text&.present?
-      .pending-account__body
-        %p= account.user&.invite_request&.text
diff --git a/app/views/admin/pending_accounts/index.html.haml b/app/views/admin/pending_accounts/index.html.haml
deleted file mode 100644
index 8101d7f99..000000000
--- a/app/views/admin/pending_accounts/index.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-- content_for :page_title do
-  = t('admin.pending_accounts.title', count: User.pending.count)
-
-= form_for(@form, url: batch_admin_pending_accounts_path) do |f|
-  = hidden_field_tag :page, params[:page] || 1
-
-  .batch-table
-    .batch-table__toolbar
-      %label.batch-table__toolbar__select.batch-checkbox-all
-        = check_box_tag :batch_checkbox_all, nil, false
-      .batch-table__toolbar__actions
-        = f.button safe_join([fa_icon('check'), t('admin.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-
-        = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-    .batch-table__body
-      - if @accounts.empty?
-        = nothing_here 'nothing-here--under-tabs'
-      - else
-        = render partial: 'account', collection: @accounts, locals: { f: f }
-
-= paginate @accounts
-
-%hr.spacer/
-
-%div.action-buttons
-  %div
-    = link_to t('admin.accounts.approve_all'), approve_all_admin_pending_accounts_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'
-
-  %div
-    = link_to t('admin.accounts.reject_all'), reject_all_admin_pending_accounts_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive'
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index d34dc3d15..428b6cf59 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -1,7 +1,18 @@
-.speech-bubble
-  .speech-bubble__bubble
+.report-notes__item
+  = image_tag report_note.account.avatar.url, class: 'report-notes__item__avatar'
+
+  .report-notes__item__header
+    %span.username
+      = link_to display_name(report_note.account), admin_account_path(report_note.account_id)
+    %time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
+      - if report_note.created_at.today?
+        = t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time))
+      - else
+        = l report_note.created_at.to_date
+
+  .report-notes__item__content
     = simple_format(h(report_note.content))
-  .speech-bubble__owner
-    = admin_account_link_to report_note.account
-    %time.formatted{ datetime: report_note.created_at.iso8601 }= l report_note.created_at
-    = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note)
+
+  - if can?(:destroy, report_note)
+    .report-notes__item__actions
+      = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete
diff --git a/app/views/admin/reports/_action_log.html.haml b/app/views/admin/reports/_action_log.html.haml
deleted file mode 100644
index 0f7d05867..000000000
--- a/app/views/admin/reports/_action_log.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-.speech-bubble.positive
-  .speech-bubble__bubble
-    = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}_html", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target'))
-  .speech-bubble__owner
-    = admin_account_link_to(action_log.account)
-    %time.formatted{ datetime: action_log.created_at.iso8601 }= l action_log.created_at
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
index ada6dd2bc..4e06d4bbf 100644
--- a/app/views/admin/reports/_status.html.haml
+++ b/app/views/admin/reports/_status.html.haml
@@ -22,8 +22,14 @@
         = react_component :media_gallery, height: 343, sensitive: status.proper.sensitive?, visible: false, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
 
     .detailed-status__meta
+      - if status.application
+        = status.application.name
+        ·
       = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+      - if status.edited?
+        ·
+        = t('statuses.edited_at', date: l(status.edited_at))
       - if status.discarded?
         ·
         %span.negative-hint= t('admin.statuses.deleted')
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
index 721c55f71..619173373 100644
--- a/app/views/admin/reports/index.html.haml
+++ b/app/views/admin/reports/index.html.haml
@@ -7,6 +7,12 @@
     %ul
       %li= filter_link_to t('admin.reports.unresolved'), resolved: nil
       %li= filter_link_to t('admin.reports.resolved'), resolved: '1'
+  .filter-subset
+    %strong= t('admin.reports.target_origin')
+    %ul
+      %li= filter_link_to t('admin.accounts.location.all'), target_origin: nil
+      %li= filter_link_to t('admin.accounts.location.local'), target_origin: 'local'
+      %li= filter_link_to t('admin.accounts.location.remote'), target_origin: 'remote'
 
 = form_tag admin_reports_url, method: 'GET', class: 'simple_form' do
   .fields-group
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 167e96c03..e03c1220c 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -7,122 +7,199 @@
   - else
     = link_to t('admin.reports.mark_as_unresolved'), reopen_admin_report_path(@report), method: :post, class: 'button'
 
-.table-wrapper
-  %table.table.inline-table
-    %tbody
-      %tr
-        %th= t('admin.reports.reported_account')
-        %td= admin_account_link_to @report.target_account
-        %td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.target_account.targeted_reports.count), admin_reports_path(target_account_id: @report.target_account.id)
-        %td= table_link_to 'file', t('admin.reports.account.notes', count: @report.target_account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.target_account.id)
-      %tr
-        %th= t('admin.reports.reported_by')
+.report-header
+  .report-header__card
+    .account-card
+      .account-card__header
+        = image_tag @report.target_account.header.url, alt: ''
+      .account-card__title
+        .account-card__title__avatar
+          = image_tag @report.target_account.avatar.url, alt: ''
+        .display-name
+          %bdi
+            %strong.emojify.p-name= display_name(@report.target_account, custom_emojify: true)
+          %span
+            = acct(@report.target_account)
+            = fa_icon('lock') if @report.target_account.locked?
+      - if @report.target_account.note.present?
+        .account-card__bio.emojify
+          = Formatter.instance.simplified_format(@report.target_account, custom_emojify: true)
+      .account-card__actions
+        .account-card__counters
+          .account-card__counters__item
+            = friendly_number_to_human @report.target_account.statuses_count
+            %small= t('accounts.posts', count: @report.target_account.statuses_count).downcase
+          .account-card__counters__item
+            = friendly_number_to_human @report.target_account.followers_count
+            %small= t('accounts.followers', count: @report.target_account.followers_count).downcase
+          .account-card__counters__item
+            = friendly_number_to_human @report.target_account.following_count
+            %small= t('accounts.following', count: @report.target_account.following_count).downcase
+        .account-card__actions__button
+          = link_to t('admin.reports.view_profile'), admin_account_path(@report.target_account_id), class: 'button'
+    .report-header__details.report-header__details--horizontal
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.accounts.joined')
+        .report-header__details__item__content
+          %time.time-ago{ datetime: @report.target_account.created_at.iso8601, title: l(@report.target_account.created_at) }= l @report.target_account.created_at
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('accounts.last_active')
+        .report-header__details__item__content
+          - if @report.target_account.last_status_at.present?
+            %time.time-ago{ datetime: @report.target_account.last_status_at.to_date.iso8601, title: l(@report.target_account.last_status_at.to_date) }= l @report.target_account.last_status_at
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.accounts.strikes')
+        .report-header__details__item__content
+          = @report.target_account.strikes.count
+
+  .report-header__details
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('admin.reports.created_at')
+      .report-header__details__item__content
+        %time.formatted{ datetime: @report.created_at.iso8601 }
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('admin.reports.reported_by')
+      .report-header__details__item__content
         - if @report.account.instance_actor?
-          %td{ colspan: 3 }= site_hostname
+          = site_hostname
         - elsif @report.account.local?
-          %td= admin_account_link_to @report.account
-          %td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.account.targeted_reports.count), admin_reports_path(target_account_id: @report.account.id)
-          %td= table_link_to 'file', t('admin.reports.account.notes', count: @report.account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.account.id)
+          = admin_account_link_to @report.account
+        - else
+          = @report.account.domain
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('admin.reports.status')
+      .report-header__details__item__content
+        - if @report.action_taken?
+          = t('admin.reports.resolved')
         - else
-          %td{ colspan: 3 }= @report.account.domain
-      %tr
-        %th= t('admin.reports.created_at')
-        %td{ colspan: 3 }
-          %time.formatted{ datetime: @report.created_at.iso8601 }
-      %tr
-        %th= t('admin.reports.updated_at')
-        %td{ colspan: 3 }
-          %time.formatted{ datetime: @report.updated_at.iso8601 }
-      %tr
-        %th= t('admin.reports.status')
-        %td
-          - if @report.action_taken?
-            = t('admin.reports.resolved')
+          = t('admin.reports.unresolved')
+    - unless @report.target_account.local?
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.reports.forwarded')
+        .report-header__details__item__content
+          - if @report.forwarded?
+            = t('simple_form.yes')
           - else
-            = t('admin.reports.unresolved')
-        %td{ colspan: 2 }
-          - if @report.action_taken?
-            = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
-      - unless @report.target_account.local?
-        %tr
-          %th= t('admin.reports.forwarded')
-          %td{ colspan: 3 }
-            - if @report.forwarded.nil?
-              \-
-            - elsif @report.forwarded?
-              = t('simple_form.yes')
-            - else
-              = t('simple_form.no')
-      - if !@report.action_taken_by_account.nil?
-        %tr
-          %th= t('admin.reports.action_taken_by')
-          %td{ colspan: 3 }
-            = admin_account_link_to @report.action_taken_by_account
-      - else
-        %tr
-          %th= t('admin.reports.assigned')
-          %td
-            - if @report.assigned_account.nil?
-              \-
-            - else
-              = admin_account_link_to @report.assigned_account
-          %td
-            - if @report.assigned_account != current_user.account
-              = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
-          %td
-            - if !@report.assigned_account.nil?
-              = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
+            = t('simple_form.no')
+    - if !@report.action_taken_by_account.nil?
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.reports.action_taken_by')
+        .report-header__details__item__content
+          = admin_account_link_to @report.action_taken_by_account
+    - else
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.reports.assigned')
+        .report-header__details__item__content
+          - if @report.assigned_account.nil?
+            = t 'admin.reports.no_one_assigned'
+          - else
+            = admin_account_link_to @report.assigned_account
+          —
+          - if @report.assigned_account != current_user.account
+            = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
+          - elsif !@report.assigned_account.nil?
+            = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
 
 %hr.spacer
 
-%div.action-buttons
-  %div
+%h3= t 'admin.reports.category'
 
-  - if @report.unresolved?
-    %div
-      - if @report.target_account.local?
-        = link_to t('admin.accounts.warn'), new_admin_account_action_path(@report.target_account_id, type: 'none', report_id: @report.id), class: 'button'
-        = link_to t('admin.accounts.disable'), new_admin_account_action_path(@report.target_account_id, type: 'disable', report_id: @report.id), class: 'button button--destructive'
-      = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
-      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, type: 'suspend', report_id: @report.id), class: 'button button--destructive'
+%p= t 'admin.reports.category_description_html'
 
-%hr.spacer
+= react_admin_component :report_reason_selector, id: @report.id, category: @report.category, rule_ids: @report.rule_ids&.map(&:to_s), disabled: @report.action_taken?
 
-.speech-bubble
-  .speech-bubble__bubble= simple_format(@report.comment.presence || t('admin.reports.comment.none'))
-  .speech-bubble__owner
-    - if @report.account.local?
-      = admin_account_link_to @report.account
-    - else
-      = @report.account.domain
-      %br/
-    %time.formatted{ datetime: @report.created_at.iso8601 }
+- if @report.comment.present?
+  %p= t('admin.reports.comment_description_html', name: content_tag(:strong, @report.account.username, class: 'username'))
+
+  .report-notes__item
+    = image_tag @report.account.avatar.url, class: 'report-notes__item__avatar'
+
+    .report-notes__item__header
+      %span.username
+        = link_to display_name(@report.account), admin_account_path(@report.account_id)
+      %time{ datetime: @report.created_at.iso8601, title: l(@report.created_at) }
+        - if @report.created_at.today?
+          = t('admin.report_notes.today_at', time: l(@report.created_at, format: :time))
+        - else
+          = l @report.created_at.to_date
+
+    .report-notes__item__content
+      = simple_format(h(@report.comment))
+
+%hr.spacer/
 
-- unless @report.statuses.empty?
+%h3= t 'admin.reports.statuses'
+
+%p
+  = t 'admin.reports.statuses_description_html'
+  —
+  = link_to safe_join([fa_icon('plus'), t('admin.reports.add_to_report')]), admin_account_statuses_path(@report.target_account_id, report_id: @report.id), class: 'table-action-link'
+
+= form_for(@form, url: batch_admin_account_statuses_path(@report.target_account_id, report_id: @report.id)) do |f|
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        - if !@statuses.empty? && @report.unresolved?
+          = f.button safe_join([fa_icon('times'), t('admin.statuses.batch.remove_from_report')]), name: :remove_from_report, class: 'table-action-link', type: :submit
+          = f.button safe_join([fa_icon('trash'), t('admin.reports.delete_and_resolve')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        - else
+    .batch-table__body
+      - if @statuses.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
+
+- if @report.unresolved?
   %hr.spacer/
 
-  = form_for(@form, url: admin_report_reported_statuses_path(@report.id)) do |f|
-    .batch-table
-      .batch-table__toolbar
-        %label.batch-table__toolbar__select.batch-checkbox-all
-          = check_box_tag :batch_checkbox_all, nil, false
-        .batch-table__toolbar__actions
-          = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-          = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-          = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-      .batch-table__body
-        = render partial: 'admin/reports/status', collection: @report.statuses, locals: { f: f }
+  %p= t 'admin.reports.actions_description_html'
+
+  .report-actions
+    .report-actions__item
+      .report-actions__item__button
+        = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
+      .report-actions__item__description
+        = t('admin.reports.actions.silence_description_html')
+    .report-actions__item
+      .report-actions__item__button
+        = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id, type: 'suspend'), class: 'button button--destructive'
+      .report-actions__item__description
+        = t('admin.reports.actions.suspend_description_html')
+    .report-actions__item
+      .report-actions__item__button
+        = link_to t('admin.accounts.custom'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id), class: 'button'
+      .report-actions__item__description
+        = t('admin.reports.actions.other_description_html')
+
+- unless @action_logs.empty?
+  %hr.spacer/
+
+  %h3= t 'admin.reports.action_log'
+
+  .report-notes
+    = render @action_logs
 
 %hr.spacer/
 
-- @report_notes.each do |item|
-  - if item.is_a?(Admin::ActionLog)
-    = render partial: 'action_log', locals: { action_log: item }
-  - else
-    = render item
+%h3= t 'admin.reports.notes.title'
+
+%p= t 'admin.reports.notes_description_html'
+
+.report-notes
+  = render @report_notes
 
 = simple_form_for @report_note, url: admin_report_notes_path do |f|
-  = render 'shared/error_messages', object: @report_note
   = f.input :report_id, as: :hidden
 
   .field-group
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 373811ea3..49b03a9e3 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -42,7 +42,10 @@
 
   .fields-group
     = f.input :require_invite_text, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.require_invite_text.title'), hint: t('admin.settings.registrations.require_invite_text.desc_html'), disabled: !approved_registrations?
-  .fields-group
+
+  - if captcha_available?
+    .fields-group
+      = f.input :captcha_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.captcha_enabled.title'), hint: t('admin.settings.captcha_enabled.desc_html')
 
   %hr.spacer/
 
@@ -90,9 +93,6 @@
     = 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')
 
   .fields-group
-    = f.input :enable_keybase, as: :boolean, wrapper: :with_label, label: t('admin.settings.enable_keybase.title'), hint: t('admin.settings.enable_keybase.desc_html')
-
-  .fields-group
     = f.input :show_reblogs_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_reblogs_in_public_timelines.title'), hint: t('admin.settings.show_reblogs_in_public_timelines.desc_html')
 
   .fields-group
diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml
index 5414d69d5..865464c72 100644
--- a/app/views/admin/statuses/index.html.haml
+++ b/app/views/admin/statuses/index.html.haml
@@ -7,28 +7,37 @@
   .filter-subset
     %strong= t('admin.statuses.media.title')
     %ul
-      %li= link_to t('admin.statuses.no_media'), admin_account_statuses_path(@account.id, current_params.merge(media: nil)), class: !params[:media] && 'selected'
-      %li= link_to t('admin.statuses.with_media'), admin_account_statuses_path(@account.id, current_params.merge(media: true)), class: params[:media] && 'selected'
+      %li= filter_link_to t('generic.all'), media: nil, id: nil
+      %li= filter_link_to t('admin.statuses.with_media'), media: '1'
   .back-link
-    = link_to admin_account_path(@account.id) do
-      = fa_icon 'chevron-left fw'
-      = t('admin.statuses.back_to_account')
+    - if params[:report_id]
+      = link_to admin_report_path(params[:report_id].to_i) do
+        = fa_icon 'chevron-left fw'
+        = t('admin.statuses.back_to_report')
+    - else
+      = link_to admin_account_path(@account.id) do
+        = fa_icon 'chevron-left fw'
+        = t('admin.statuses.back_to_account')
 
 %hr.spacer/
 
-= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f|
-  = hidden_field_tag :page, params[:page]
-  = hidden_field_tag :media, params[:media]
+= form_for(@status_batch_action, url: batch_admin_account_statuses_path(@account.id)) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - Admin::StatusFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
 
   .batch-table
     .batch-table__toolbar
       %label.batch-table__toolbar__select.batch-checkbox-all
         = check_box_tag :batch_checkbox_all, nil, false
       .batch-table__toolbar__actions
-        = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        - unless @statuses.empty?
+          = f.button safe_join([fa_icon('flag'), t('admin.statuses.batch.report')]), name: :report, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
     .batch-table__body
-      = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
+      - if @statuses.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
 
 = paginate @statuses
diff --git a/app/views/admin/statuses/show.html.haml b/app/views/admin/statuses/show.html.haml
deleted file mode 100644
index e2470198d..000000000
--- a/app/views/admin/statuses/show.html.haml
+++ /dev/null
@@ -1,27 +0,0 @@
-- content_for :page_title do
-  = t('admin.statuses.title')
-  \-
-  = "@#{@account.acct}"
-
-.filters
-  .back-link
-    = link_to admin_account_path(@account.id) do
-      %i.fa.fa-chevron-left.fa-fw
-      = t('admin.statuses.back_to_account')
-
-%hr.spacer/
-
-= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f|
-  = hidden_field_tag :page, params[:page]
-  = hidden_field_tag :media, params[:media]
-
-  .batch-table
-    .batch-table__toolbar
-      %label.batch-table__toolbar__select.batch-checkbox-all
-        = check_box_tag :batch_checkbox_all, nil, false
-      .batch-table__toolbar__actions
-        = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-    .batch-table__body
-      = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml
deleted file mode 100644
index adf4ca7b2..000000000
--- a/app/views/admin/tags/_tag.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-.batch-table__row
-  - if batch_available
-    %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
-      = f.check_box :tag_ids, { multiple: true, include_hidden: false }, tag.id
-
-  .directory__tag
-    = link_to admin_tag_path(tag.id) do
-      %h4
-        = fa_icon 'hashtag'
-        = tag.name
-
-        %small
-          = t('admin.tags.unique_uses_today', count: tag.history.first[:accounts])
-
-          - if tag.trending?
-            = fa_icon 'fire fw'
-            = t('admin.tags.trending_right_now')
-
-      .trends__item__current= number_to_human tag.history.first[:uses], strip_insignificant_zeros: true
diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml
deleted file mode 100644
index e25b0ae84..000000000
--- a/app/views/admin/tags/index.html.haml
+++ /dev/null
@@ -1,71 +0,0 @@
-- content_for :page_title do
-  = t('admin.tags.title')
-
-.filters
-  .filter-subset
-    %strong= t('admin.tags.review')
-    %ul
-      %li= filter_link_to t('generic.all'), reviewed: nil, unreviewed: nil, pending_review: nil
-      %li= filter_link_to t('admin.tags.unreviewed'), unreviewed: '1', reviewed: nil, pending_review: nil
-      %li= filter_link_to t('admin.tags.reviewed'), reviewed: '1', unreviewed: nil, pending_review: nil
-      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), pending_review: '1', reviewed: nil, unreviewed: nil
-
-  .filter-subset
-    %strong= t('generic.order_by')
-    %ul
-      %li= filter_link_to t('admin.tags.most_recent'), popular: nil, active: nil
-      %li= filter_link_to t('admin.tags.last_active'), active: '1', popular: nil
-      %li= filter_link_to t('admin.tags.most_popular'), popular: '1', active: nil
-
-
-= form_tag admin_tags_url, method: 'GET', class: 'simple_form' do
-  .fields-group
-    - TagFilter::KEYS.each do |key|
-      = hidden_field_tag key, params[key] if params[key].present?
-
-    - %i(name).each do |key|
-      .input.string.optional
-        = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.tags.#{key}")
-
-    .actions
-      %button.button= t('admin.accounts.search')
-      = link_to t('admin.accounts.reset'), admin_tags_path, class: 'button negative'
-
-%hr.spacer/
-
-= form_for(@form, url: batch_admin_tags_path) do |f|
-  = hidden_field_tag :page, params[:page] || 1
-
-  - TagFilter::KEYS.each do |key|
-    = hidden_field_tag key, params[key] if params[key].present?
-
-  .batch-table.optional
-    .batch-table__toolbar
-      - if params[:pending_review] == '1' || params[:unreviewed] == '1'
-        %label.batch-table__toolbar__select.batch-checkbox-all
-          = check_box_tag :batch_checkbox_all, nil, false
-        .batch-table__toolbar__actions
-          = f.button safe_join([fa_icon('check'), t('admin.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-
-          = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-      - else
-        .batch-table__toolbar__actions
-          %span.neutral-hint= t('generic.no_batch_actions_available')
-
-    .batch-table__body
-      - if @tags.empty?
-        = nothing_here 'nothing-here--under-tabs'
-      - else
-        = render partial: 'tag', collection: @tags, locals: { f: f, batch_available: params[:pending_review] == '1' || params[:unreviewed] == '1' }
-
-= paginate @tags
-
-- if params[:pending_review] == '1' || params[:unreviewed] == '1'
-  %hr.spacer/
-
-  %div.action-buttons
-    %div
-      = link_to t('admin.accounts.approve_all'), approve_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'
-
-    %div
-      = link_to t('admin.accounts.reject_all'), reject_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive'
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index c4caffda1..c41ce2fc2 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -1,15 +1,47 @@
 - content_for :page_title do
   = "##{@tag.name}"
 
-.dashboard__counters
-  %div
-    = link_to tag_url(@tag), target: '_blank', rel: 'noopener noreferrer' do
-      .dashboard__counters__num= number_with_delimiter @accounts_today
-      .dashboard__counters__label= t 'admin.tags.accounts_today'
-  %div
-    %div
-      .dashboard__counters__num= number_with_delimiter @accounts_week
-      .dashboard__counters__label= t 'admin.tags.accounts_week'
+- content_for :heading_actions do
+  = l(@time_period.first)
+  = ' - '
+  = l(@time_period.last)
+
+.dashboard
+  .dashboard__item
+    = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure')
+  .dashboard__item
+    = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure')
+  .dashboard__item
+    = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure')
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension')
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension')
+  .dashboard__item
+    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do
+      - if @tag.usable?
+        %span= t('admin.trends.tags.usable')
+        = fa_icon 'check fw'
+      - else
+        %span= t('admin.trends.tags.not_usable')
+        = fa_icon 'lock fw'
+
+    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do
+      - if @tag.trendable?
+        %span= t('admin.trends.tags.trendable')
+        = fa_icon 'check fw'
+      - else
+        %span= t('admin.trends.tags.not_trendable')
+        = fa_icon 'lock fw'
+
+
+    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do
+      - if @tag.listable?
+        %span= t('admin.trends.tags.listable')
+        = fa_icon 'check fw'
+      - else
+        %span= t('admin.trends.tags.not_listable')
+        = fa_icon 'lock fw'
 
 %hr.spacer/
 
@@ -26,18 +58,3 @@
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
-
-%hr.spacer/
-
-%h3= t 'admin.tags.breakdown'
-
-.table-wrapper
-  %table.table
-    %tbody
-      - total = @usage_by_domain.sum(&:last).to_f
-
-      - @usage_by_domain.each do |(domain, count)|
-        %tr
-          %th= domain || site_hostname
-          %td= number_to_percentage((count / total) * 100, precision: 1)
-          %td= number_with_delimiter count
diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml
new file mode 100644
index 000000000..b88c1be2f
--- /dev/null
+++ b/app/views/admin/trends/links/_preview_card.html.haml
@@ -0,0 +1,30 @@
+.batch-table__row{ class: [preview_card.provider&.requires_review? && 'batch-table__row--attention', !preview_card.provider&.requires_review? && !preview_card.trendable? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :preview_card_ids, { multiple: true, include_hidden: false }, preview_card.id
+
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      = link_to preview_card.title, preview_card.url
+
+      %br/
+
+      - if preview_card.provider_name.present?
+        = preview_card.provider_name
+        •
+
+      - if preview_card.language.present?
+        = human_locale(preview_card.language)
+        •
+
+      = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
+
+      - if preview_card.trendable? && (rank = Trends.links.rank(preview_card.id))
+        •
+        %abbr{ title: t('admin.trends.tags.current_score', score: Trends.links.score(preview_card.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
+
+        - if preview_card.decaying?
+          •
+          = t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short))
+      - elsif preview_card.provider&.requires_review?
+        •
+        = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml
new file mode 100644
index 000000000..acd2b0466
--- /dev/null
+++ b/app/views/admin/trends/links/index.html.haml
@@ -0,0 +1,38 @@
+- content_for :page_title do
+  = t('admin.trends.links.title')
+
+.filters
+  .filter-subset
+    %strong= t('admin.trends.trending')
+    %ul
+      %li= filter_link_to t('generic.all'), trending: nil
+      %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed'
+  .back-link
+    = link_to admin_trends_links_preview_card_providers_path do
+      = t('admin.trends.preview_card_providers.title')
+      = fa_icon 'chevron-right fw'
+
+%hr.spacer/
+
+= form_for(@form, url: batch_admin_trends_links_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - PreviewCardFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow_provider')]), name: :approve_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      - if @preview_cards.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'preview_card', collection: @preview_cards, locals: { f: f }
+
+= paginate @preview_cards
diff --git a/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml b/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml
new file mode 100644
index 000000000..e40e6529d
--- /dev/null
+++ b/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml
@@ -0,0 +1,16 @@
+.batch-table__row{ class: [preview_card_provider.requires_review? && 'batch-table__row--attention', !preview_card_provider.requires_review? && !preview_card_provider.trendable? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :preview_card_provider_ids, { multiple: true, include_hidden: false }, preview_card_provider.id
+
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      %strong= preview_card_provider.domain
+
+      %br/
+
+      - if preview_card_provider.requires_review?
+        = t('admin.trends.pending_review')
+      - elsif preview_card_provider.trendable?
+        = t('admin.trends.preview_card_providers.allowed')
+      - else
+        = t('admin.trends.preview_card_providers.rejected')
diff --git a/app/views/admin/trends/links/preview_card_providers/index.html.haml b/app/views/admin/trends/links/preview_card_providers/index.html.haml
new file mode 100644
index 000000000..df54f58ba
--- /dev/null
+++ b/app/views/admin/trends/links/preview_card_providers/index.html.haml
@@ -0,0 +1,40 @@
+- content_for :page_title do
+  = t('admin.trends.preview_card_providers.title')
+
+.filters
+  .filter-subset
+    %strong= t('admin.tags.review')
+    %ul
+      %li= filter_link_to t('generic.all'), status: nil
+      %li= filter_link_to t('admin.trends.approved'), status: 'approved'
+      %li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
+      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{PreviewCardProvider.pending_review.count})"], ' '), status: 'pending_review'
+  .back-link
+    = link_to admin_trends_links_path do
+      = fa_icon 'chevron-left fw'
+      = t('admin.trends.links.title')
+
+
+%hr.spacer/
+
+= form_for(@form, url: batch_admin_trends_links_preview_card_providers_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - PreviewCardProviderFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table.optional
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('check'), t('admin.trends.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+    .batch-table__body
+      - if @preview_card_providers.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'preview_card_provider', collection: @preview_card_providers, locals: { f: f }
+
+= paginate @preview_card_providers
diff --git a/app/views/admin/trends/tags/_tag.html.haml b/app/views/admin/trends/tags/_tag.html.haml
new file mode 100644
index 000000000..7bb99b158
--- /dev/null
+++ b/app/views/admin/trends/tags/_tag.html.haml
@@ -0,0 +1,24 @@
+.batch-table__row{ class: [tag.requires_review? && 'batch-table__row--attention', !tag.requires_review? && !tag.trendable? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :tag_ids, { multiple: true, include_hidden: false }, tag.id
+
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      = link_to admin_tag_path(tag.id) do
+        = fa_icon 'hashtag'
+        = tag.name
+
+      %br/
+
+      = t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts })
+
+      - if tag.trendable? && (rank = Trends.tags.rank(tag.id))
+        •
+        %abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
+
+        - if tag.decaying?
+          •
+          = t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short))
+      - elsif tag.requires_review?
+        •
+        = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml
new file mode 100644
index 000000000..99ad5490f
--- /dev/null
+++ b/app/views/admin/trends/tags/index.html.haml
@@ -0,0 +1,35 @@
+- content_for :page_title do
+  = t('admin.trends.tags.title')
+
+.filters
+  .filter-subset
+    %strong= t('admin.tags.review')
+    %ul
+      %li= filter_link_to t('generic.all'), status: nil
+      %li= filter_link_to t('admin.trends.approved'), status: 'approved'
+      %li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
+      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), status: 'pending_review'
+
+%hr.spacer/
+
+= form_for(@form, url: batch_admin_trends_tags_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - TagFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table.optional
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('check'), t('admin.trends.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+    .batch-table__body
+      - if @tags.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'tag', collection: @tags, locals: { f: f }
+
+= paginate @tags
diff --git a/app/views/admin_mailer/new_pending_account.text.erb b/app/views/admin_mailer/new_pending_account.text.erb
index a466ee2de..a8a2a35fa 100644
--- a/app/views/admin_mailer/new_pending_account.text.erb
+++ b/app/views/admin_mailer/new_pending_account.text.erb
@@ -3,10 +3,10 @@
 <%= raw t('admin_mailer.new_pending_account.body') %>
 
 <%= @account.user_email %> (@<%= @account.username %>)
-<%= @account.user_current_sign_in_ip %>
+<%= @account.user_sign_up_ip %>
 <% if @account.user&.invite_request&.text.present? %>
 
 <%= quote_wrap(@account.user&.invite_request&.text) %>
 <% end %>
 
-<%= raw t('application_mailer.view')%> <%= admin_pending_accounts_url %>
+<%= raw t('application_mailer.view')%> <%= admin_accounts_url(status: 'pending') %>
diff --git a/app/views/admin_mailer/new_trending_links.text.erb b/app/views/admin_mailer/new_trending_links.text.erb
new file mode 100644
index 000000000..51789aca5
--- /dev/null
+++ b/app/views/admin_mailer/new_trending_links.text.erb
@@ -0,0 +1,16 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_trending_links.body') %>
+
+<% @links.each do |link| %>
+- <%= link.title %> • <%= link.url %>
+  <%= t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %>
+<% end %>
+
+<% if @lowest_trending_link %>
+<%= t('admin_mailer.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2)) %>
+<% else %>
+<%= t('admin_mailer.new_trending_links.no_approved_links') %>
+<% end %>
+
+<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
diff --git a/app/views/admin_mailer/new_trending_tag.text.erb b/app/views/admin_mailer/new_trending_tag.text.erb
deleted file mode 100644
index e4bfdc591..000000000
--- a/app/views/admin_mailer/new_trending_tag.text.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
-
-<%= raw t('admin_mailer.new_trending_tag.body', name: @tag.name) %>
-
-<%= raw t('application_mailer.view')%> <%= admin_tags_url(pending_review: '1') %>
diff --git a/app/views/admin_mailer/new_trending_tags.text.erb b/app/views/admin_mailer/new_trending_tags.text.erb
new file mode 100644
index 000000000..5051e8a96
--- /dev/null
+++ b/app/views/admin_mailer/new_trending_tags.text.erb
@@ -0,0 +1,16 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_trending_tags.body') %>
+
+<% @tags.each do |tag| %>
+- #<%= tag.name %>
+  <%= t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
+<% end %>
+
+<% if @lowest_trending_tag %>
+<%= t('admin_mailer.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2)) %>
+<% else %>
+<%= t('admin_mailer.new_trending_tags.no_approved_tags') %>
+<% end %>
+
+<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(pending_review: '1') %>
diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml
index 7ec91c06a..6826c3b58 100644
--- a/app/views/application/_sidebar.html.haml
+++ b/app/views/application/_sidebar.html.haml
@@ -6,7 +6,7 @@
     %p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
 
 - if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
-  - trends = TrendingTags.get(3)
+  - trends = Trends.tags.get(true, 3)
 
   - unless trends.empty?
     .endorsements-widget.trends-widget
diff --git a/app/views/auth/confirmations/captcha.html.haml b/app/views/auth/confirmations/captcha.html.haml
new file mode 100644
index 000000000..0fae367db
--- /dev/null
+++ b/app/views/auth/confirmations/captcha.html.haml
@@ -0,0 +1,14 @@
+- content_for :page_title do
+  = t('auth.captcha_confirmation.title')
+
+= form_tag auth_captcha_confirmation_url, method: 'POST', class: 'simple_form' do
+  = hidden_field_tag :confirmation_token, params[:confirmation_token]
+
+  .field-group
+    %p.hint= t('auth.captcha_confirmation.hint_html')
+
+  .field-group
+    = render_captcha
+
+  .actions
+    %button.button= t('challenge.continue')
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
index 9713bdaeb..a4323d1d9 100644
--- a/app/views/auth/sessions/new.html.haml
+++ b/app/views/auth/sessions/new.html.haml
@@ -4,24 +4,25 @@
 - content_for :header_tags do
   = render partial: 'shared/og'
 
-= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
-  .fields-group
-    - if use_seamless_external_login?
-      = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false
-    - else
-      = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
-  .fields-group
-    = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false
+- unless omniauth_only?
+  = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
+    .fields-group
+      - if use_seamless_external_login?
+        = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false
+      - else
+        = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
+    .fields-group
+      = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false
 
-  .actions
-    = f.button :button, t('auth.login'), type: :submit
+    .actions
+      = f.button :button, t('auth.login'), type: :submit
 
 - if devise_mapping.omniauthable? and resource_class.omniauth_providers.any?
   .simple_form.alternative-login
-    %h4= t('auth.or_log_in_with')
+    %h4= omniauth_only? ? t('auth.log_in_with') : t('auth.or_log_in_with')
 
     .actions
       - resource_class.omniauth_providers.each do |provider|
-        = link_to t("auth.providers.#{provider}", default: provider.to_s.chomp("_oauth2").capitalize), omniauth_authorize_path(resource_name, provider), class: "button button-#{provider}", method: :post
+        = provider_sign_in_link(provider)
 
 .form-footer= render 'auth/shared/links'
diff --git a/app/views/auth/shared/_links.html.haml b/app/views/auth/shared/_links.html.haml
index 66ed5b93f..f078e2f7e 100644
--- a/app/views/auth/shared/_links.html.haml
+++ b/app/views/auth/shared/_links.html.haml
@@ -3,7 +3,7 @@
     %li= link_to t('settings.account_settings'), edit_user_registration_path
   - else
     - if controller_name != 'sessions'
-      %li= link_to t('auth.login'), new_user_session_path
+      %li= link_to_login t('auth.login')
 
     - if controller_name != 'registrations'
       %li= link_to t('auth.register'), available_sign_up_path
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
index febfb7d17..d5509f946 100644
--- a/app/views/directories/index.html.haml
+++ b/app/views/directories/index.html.haml
@@ -39,10 +39,10 @@
 
         .directory__card__extra
           .accounts-table__count
-            = number_to_human account.statuses_count, strip_insignificant_zeros: true
+            = friendly_number_to_human account.statuses_count
             %small= t('accounts.posts', count: account.statuses_count).downcase
           .accounts-table__count
-            = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
+            = hide_followers_count?(account) ? '-' : (friendly_number_to_human account.followers_count)
             %small= t('accounts.followers', count: account.followers_count).downcase
           .accounts-table__count
             - if account.last_status_at.present?
diff --git a/app/views/layouts/_theme.html.haml b/app/views/layouts/_theme.html.haml
index 92de64b0d..5dba77621 100644
--- a/app/views/layouts/_theme.html.haml
+++ b/app/views/layouts/_theme.html.haml
@@ -2,12 +2,13 @@
   - if theme[:pack] != 'common' && theme[:common]
     = render partial: 'layouts/theme', object: theme[:common]
   - if theme[:pack]
-    = javascript_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", crossorigin: 'anonymous'
+    - pack_path = theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}"
+    = javascript_pack_tag pack_path, crossorigin: 'anonymous'
     - if theme[:skin]
       - if !theme[:flavour] || theme[:skin] == 'default'
-        = stylesheet_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", media: 'all', crossorigin: 'anonymous'
+        = stylesheet_pack_tag pack_path, media: 'all', crossorigin: 'anonymous'
       - else
-        = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}", crossorigin: 'anonymous'
+        = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}", media: 'all', crossorigin: 'anonymous'
     - if theme[:preload]
       - theme[:preload].each do |link|
         %link{ href: asset_pack_path("#{link}.js"), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index 57ad5aaf1..61198171d 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -21,7 +21,7 @@
             - if user_signed_in?
               = link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn'
             - else
-              = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn nav-link nav-button'
+              = link_to_login t('auth.login'), class: 'webapp-btn nav-link nav-button'
               = link_to t('auth.register'), available_sign_up_path, class: 'webapp-btn nav-link nav-button'
 
     .container= yield
diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml
index 9b7e1b65c..31460a76e 100644
--- a/app/views/notification_mailer/_status.html.haml
+++ b/app/views/notification_mailer/_status.html.haml
@@ -37,7 +37,7 @@
                                   %p
                                     - status.media_attachments.each do |a|
                                       - if status.local?
-                                        = link_to medium_url(a), medium_url(a)
+                                        = link_to full_asset_url(a.file.url(:original)), full_asset_url(a.file.url(:original))
                                       - else
                                         = link_to a.remote_url, a.remote_url
 
diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb
index 8999a1f8e..c43f32d9f 100644
--- a/app/views/notification_mailer/_status.text.erb
+++ b/app/views/notification_mailer/_status.text.erb
@@ -1,8 +1,8 @@
 <% if status.spoiler_text? %>
-<%= raw status.spoiler_text %>
-----
-
+> <%= raw word_wrap(status.spoiler_text, break_sequence: "\n> ") %>
+> ----
+>
 <% end %>
-<%= raw Formatter.instance.plaintext(status) %>
+> <%= raw word_wrap(Formatter.instance.plaintext(status), break_sequence: "\n> ") %>
 
 <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %>
diff --git a/app/views/relationships/_account.html.haml b/app/views/relationships/_account.html.haml
index f521aff22..0fa3cffb5 100644
--- a/app/views/relationships/_account.html.haml
+++ b/app/views/relationships/_account.html.haml
@@ -9,10 +9,10 @@
             = interrelationships_icon(@relationships, account.id)
           %td= account_link_to account
           %td.accounts-table__count.optional
-            = number_to_human account.statuses_count, strip_insignificant_zeros: true
+            = friendly_number_to_human account.statuses_count
             %small= t('accounts.posts', count: account.statuses_count).downcase
           %td.accounts-table__count.optional
-            = number_to_human account.followers_count, strip_insignificant_zeros: true
+            = friendly_number_to_human account.followers_count
             %small= t('accounts.followers', count: account.followers_count).downcase
           %td.accounts-table__count
             - if account.last_status_at.present?
diff --git a/app/views/settings/featured_tags/index.html.haml b/app/views/settings/featured_tags/index.html.haml
index 297379893..65de7f8f3 100644
--- a/app/views/settings/featured_tags/index.html.haml
+++ b/app/views/settings/featured_tags/index.html.haml
@@ -28,4 +28,4 @@
           - else
             %time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
           = table_link_to 'trash', t('filters.index.delete'), settings_featured_tag_path(featured_tag), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
-      .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
+      .trends__item__current= friendly_number_to_human featured_tag.statuses_count
diff --git a/app/views/settings/identity_proofs/_proof.html.haml b/app/views/settings/identity_proofs/_proof.html.haml
deleted file mode 100644
index 14e8e91be..000000000
--- a/app/views/settings/identity_proofs/_proof.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-%tr
-  %td
-    = link_to proof.badge.profile_url, class: 'name-tag' do
-      = image_tag proof.badge.avatar_url, width: 15, height: 15, alt: '', class: 'avatar'
-      %span.username
-        = proof.provider_username
-        %span= "(#{proof.provider.capitalize})"
-
-  %td
-    - if proof.live?
-      %span.positive-hint
-        = fa_icon 'check-circle fw'
-        = t('identity_proofs.active')
-    - else
-      %span.negative-hint
-        = fa_icon 'times-circle fw'
-        = t('identity_proofs.inactive')
-
-  %td
-    = table_link_to 'external-link', t('identity_proofs.view_proof'), proof.badge.proof_url if proof.badge.proof_url
-    = table_link_to 'trash', t('identity_proofs.remove'), settings_identity_proof_path(proof), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/settings/identity_proofs/index.html.haml b/app/views/settings/identity_proofs/index.html.haml
deleted file mode 100644
index d0ea03ecd..000000000
--- a/app/views/settings/identity_proofs/index.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- content_for :page_title do
-  = t('settings.identity_proofs')
-
-%p= t('identity_proofs.explanation_html')
-
-- unless @proofs.empty?
-  %hr.spacer/
-
-  .table-wrapper
-    %table.table
-      %thead
-        %tr
-          %th= t('identity_proofs.identity')
-          %th= t('identity_proofs.status')
-          %th
-      %tbody
-        = render partial: 'settings/identity_proofs/proof', collection: @proofs, as: :proof
diff --git a/app/views/settings/identity_proofs/new.html.haml b/app/views/settings/identity_proofs/new.html.haml
deleted file mode 100644
index 5e4e9895d..000000000
--- a/app/views/settings/identity_proofs/new.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- content_for :page_title do
-  = t('identity_proofs.authorize_connection_prompt')
-
-.form-container
-  .oauth-prompt
-    %h2= t('identity_proofs.authorize_connection_prompt')
-
-  = simple_form_for @proof, url: settings_identity_proofs_url, html: { method: :post } do |f|
-    = f.input :provider, as: :hidden
-    = f.input :provider_username, as: :hidden
-    = f.input :token, as: :hidden
-
-    = hidden_field_tag :user_agent, params[:user_agent]
-
-    .connection-prompt
-      .connection-prompt__row.connection-prompt__connection
-        .connection-prompt__column
-          = image_tag current_account.avatar.url(:original), size: 96, class: 'account__avatar'
-
-          %p= t('identity_proofs.i_am_html', username: content_tag(:strong,current_account.username), service: site_hostname)
-
-        .connection-prompt__column.connection-prompt__column-sep
-          = fa_icon 'link'
-
-        .connection-prompt__column
-          = image_tag @proof.badge.avatar_url, size: 96, class: 'account__avatar'
-
-          %p= t('identity_proofs.i_am_html', username: content_tag(:strong, @proof.provider_username), service: @proof.provider.capitalize)
-
-    .connection-prompt__post
-      = f.input :post_status, label: t('identity_proofs.publicize_checkbox'), as: :boolean, wrapper: :with_label, :input_html => { checked: true }
-
-      = f.input :status_text, as: :text, input_html: { value: t('identity_proofs.publicize_toot', username: @proof.provider_username, service: @proof.provider.capitalize, url: @proof.badge.proof_url), rows: 4 }
-
-    = f.button :button, t('identity_proofs.authorize'), type: :submit
-    = link_to t('simple_form.no'), settings_identity_proofs_url, class: 'button negative'
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index fb7ce6780..21948b200 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -29,9 +29,8 @@
   .fields-group
     = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
 
-  - if Setting.profile_directory
-    .fields-group
-      = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable')
+  .fields-group
+    = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t(Setting.profile_directory ? 'simple_form.hints.defaults.discoverable' : 'simple_form.hints.defaults.discoverable_no_directory'), recommended: true
 
   %hr.spacer/
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index daf164949..cd5ed52af 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -37,10 +37,15 @@
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
+    - if status.edited?
+      %data.dt-updated{ value: status.edited_at.to_time.iso8601 }
 
     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
       %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
     ·
+    - if status.edited?
+      = t('statuses.edited_at', date: l(status.edited_at))
+      ·
     %span.detailed-status__visibility-icon
       = visibility_icon status
     ·
@@ -55,18 +60,18 @@
         = fa_icon('reply')
       - else
         = fa_icon('reply-all')
-      %span.detailed-status__reblogs>= number_to_human status.replies_count, strip_insignificant_zeros: true
+      %span.detailed-status__reblogs>= friendly_number_to_human status.replies_count
       = " "
     ·
     - if status.public_visibility? || status.unlisted_visibility?
       = link_to remote_interaction_path(status, type: :reblog), class: 'modal-button detailed-status__link' do
         = fa_icon('retweet')
-        %span.detailed-status__reblogs>= number_to_human status.reblogs_count, strip_insignificant_zeros: true
+        %span.detailed-status__reblogs>= friendly_number_to_human status.reblogs_count
         = " "
       ·
     = link_to remote_interaction_path(status, type: :favourite), class: 'modal-button detailed-status__link' do
       = fa_icon('star')
-      %span.detailed-status__favorites>= number_to_human status.favourites_count, strip_insignificant_zeros: true
+      %span.detailed-status__favorites>= friendly_number_to_human status.favourites_count
       = " "
 
     - if user_signed_in?
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index a7c78b997..b1e79a1cc 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -7,6 +7,9 @@
       %span.status__visibility-icon><
         = visibility_icon status
       %time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+      - if status.edited?
+        %abbr{ title: t('statuses.edited_at', date: l(status.edited_at.to_date)) }
+          *
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
 
     .p-author.h-card
diff --git a/app/views/statuses/_status.html.haml b/app/views/statuses/_status.html.haml
index 9f3197d0d..3b7152753 100644
--- a/app/views/statuses/_status.html.haml
+++ b/app/views/statuses/_status.html.haml
@@ -56,6 +56,6 @@
 
 - if include_threads && !embedded_view? && !user_signed_in?
   .entry{ class: entry_classes }
-    = link_to new_user_session_path, class: 'load-more load-gap' do
+    = link_to_login class: 'load-more load-gap' do
       = fa_icon 'comments'
       = t('statuses.sign_in_to_participate')
diff --git a/app/views/statuses_cleanup/show.html.haml b/app/views/statuses_cleanup/show.html.haml
new file mode 100644
index 000000000..59de4b5aa
--- /dev/null
+++ b/app/views/statuses_cleanup/show.html.haml
@@ -0,0 +1,45 @@
+- content_for :page_title do
+  = t('settings.statuses_cleanup')
+
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_policy'
+
+= simple_form_for @policy, url: statuses_cleanup_path, method: :put, html: { id: 'edit_policy' } do |f|
+
+  .fields-row
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :enabled, as: :boolean, wrapper: :with_label, label: t('statuses_cleanup.enabled'), hint: t('statuses_cleanup.enabled_hint')
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :min_status_age, wrapper: :with_label, label: t('statuses_cleanup.min_age_label'), collection: AccountStatusesCleanupPolicy::ALLOWED_MIN_STATUS_AGE.map(&:to_i), label_method: lambda { |i| t("statuses_cleanup.min_age.#{i}") }, include_blank: false, hint: false
+
+  .flash-message= t('statuses_cleanup.explanation')
+
+  %h4= t('statuses_cleanup.exceptions')
+
+  .fields-row
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :keep_pinned, wrapper: :with_label, label: t('statuses_cleanup.keep_pinned'), hint: t('statuses_cleanup.keep_pinned_hint')
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :keep_direct, wrapper: :with_label, label: t('statuses_cleanup.keep_direct'), hint: t('statuses_cleanup.keep_direct_hint')
+
+  .fields-row
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :keep_self_fav, wrapper: :with_label, label: t('statuses_cleanup.keep_self_fav'), hint: t('statuses_cleanup.keep_self_fav_hint')
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :keep_self_bookmark, wrapper: :with_label, label: t('statuses_cleanup.keep_self_bookmark'), hint: t('statuses_cleanup.keep_self_bookmark_hint')
+
+  .fields-row
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :keep_polls, wrapper: :with_label, label: t('statuses_cleanup.keep_polls'), hint: t('statuses_cleanup.keep_polls_hint')
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :keep_media, wrapper: :with_label, label: t('statuses_cleanup.keep_media'), hint: t('statuses_cleanup.keep_media_hint')
+
+  %h4= t('statuses_cleanup.interaction_exceptions')
+
+  .fields-row
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :min_favs, wrapper: :with_label, label: t('statuses_cleanup.min_favs'), hint: t('statuses_cleanup.min_favs_hint'), input_html: { min: 1, placeholder: t('statuses_cleanup.ignore_favs') }
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :min_reblogs, wrapper: :with_label, label: t('statuses_cleanup.min_reblogs'), hint: t('statuses_cleanup.min_reblogs_hint'), input_html: { min: 1, placeholder: t('statuses_cleanup.ignore_reblogs') }
+
+  .flash-message= t('statuses_cleanup.interaction_exceptions_explanation')
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index 5a2911ecb..bda1fef6c 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -37,16 +37,26 @@
                           %tr
                             %td.column-cell.text-center
                               - unless @warning.none_action?
-                                %p= t "user_mailer.warning.explanation.#{@warning.action}"
+                                %p= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance
 
                               - unless @warning.text.blank?
                                 = Formatter.instance.linkify(@warning.text)
 
-                              - if !@statuses.nil? && !@statuses.empty?
+                              - if @warning.report && !@warning.report.other?
+                                %p
+                                  %strong= t('user_mailer.warning.reason')
+                                  = t("user_mailer.warning.categories.#{@warning.report.category}")
+
+                                - if @warning.report.violation? && @warning.report.rule_ids.present?
+                                  %ul.rules-list
+                                    - @warning.report.rules.each do |rule|
+                                      %li= rule.text
+
+                              - unless @statuses.empty?
                                 %p
                                   %strong= t('user_mailer.warning.statuses')
 
-- if !@statuses.nil? && !@statuses.empty?
+- unless @statuses.empty?
   - @statuses.each_with_index do |status, i|
     = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true
 
diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb
index bb6610c79..31d7308ae 100644
--- a/app/views/user_mailer/warning.text.erb
+++ b/app/views/user_mailer/warning.text.erb
@@ -3,11 +3,24 @@
 ===
 
 <% unless @warning.none_action? %>
-<%= t "user_mailer.warning.explanation.#{@warning.action}" %>
+<%= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance %>
 
 <% end %>
+<% if @warning.text.present? %>
 <%= @warning.text %>
-<% if !@statuses.nil? && !@statuses.empty? %>
+
+<% end %>
+<% if @warning.report && !@warning.report.other? %>
+**<%= t('user_mailer.warning.reason') %>** <%= t("user_mailer.warning.categories.#{@warning.report.category}") %>
+
+<% if @warning.report.violation? && @warning.report.rule_ids.present? %>
+<% @warning.report.rules.each do |rule| %>
+- <%= rule.text %>
+<% end %>
+
+<% end %>
+<% end %>
+<% if !@statuses.empty? %>
 <%= t('user_mailer.warning.statuses') %>
 
 <% @statuses.each do |status| %>
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index 6c5a576a7..788f2cf80 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -44,11 +44,7 @@ class ActivityPub::DeliveryWorker
   end
 
   def synchronization_header
-    "collectionId=\"#{account_followers_url(@source_account)}\", digest=\"#{@source_account.remote_followers_hash(inbox_url_prefix)}\", url=\"#{account_followers_synchronization_url(@source_account)}\""
-  end
-
-  def inbox_url_prefix
-    @inbox_url[/http(s?):\/\/[^\/]+\//]
+    "collectionId=\"#{account_followers_url(@source_account)}\", digest=\"#{@source_account.remote_followers_hash(@inbox_url)}\", url=\"#{account_followers_synchronization_url(@source_account)}\""
   end
 
   def perform_request
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index 09898ca49..575e11025 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -1,54 +1,32 @@
 # frozen_string_literal: true
 
-class ActivityPub::DistributionWorker
-  include Sidekiq::Worker
-  include Payloadable
-
-  sidekiq_options queue: 'push'
-
+class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
+  # Distribute a new status or an edit of a status to all the places
+  # where the status is supposed to go or where it was interacted with
   def perform(status_id)
     @status  = Status.find(status_id)
     @account = @status.account
 
-    return if skip_distribution?
-
-    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }]
-    end
-
-    relay! if relayable?
+    distribute!
   rescue ActiveRecord::RecordNotFound
     true
   end
 
-  private
-
-  def skip_distribution?
-    @status.direct_visibility? || @status.limited_visibility?
-  end
-
-  def relayable?
-    @status.public_visibility?
-  end
+  protected
 
   def inboxes
-    # 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.in_reply_to_local_account? && @status.distributable?
-                   @account.followers.or(@status.thread.account.followers).inboxes
-                 else
-                   @account.followers.inboxes
-                 end
+    @inboxes ||= StatusReachFinder.new(@status).inboxes
   end
 
   def payload
-    @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account))
+    @payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account))
+  end
+
+  def activity
+    ActivityPub::ActivityPresenter.from_status(@status)
   end
 
-  def relay!
-    ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
-      [payload, @account.id, inbox_url]
-    end
+  def options
+    { 'synchronize_followers' => @status.private_visibility? }
   end
 end
diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb
index 41e61132f..8ecc17db9 100644
--- a/app/workers/activitypub/raw_distribution_worker.rb
+++ b/app/workers/activitypub/raw_distribution_worker.rb
@@ -2,22 +2,47 @@
 
 class ActivityPub::RawDistributionWorker
   include Sidekiq::Worker
+  include Payloadable
 
   sidekiq_options queue: 'push'
 
+  # Base worker for when you want to queue up a bunch of deliveries of
+  # some payload. In this case, we have already generated JSON and
+  # we are going to distribute it to the account's followers minus
+  # the explicitly provided inboxes
   def perform(json, source_account_id, exclude_inboxes = [])
-    @account = Account.find(source_account_id)
+    @account         = Account.find(source_account_id)
+    @json            = json
+    @exclude_inboxes = exclude_inboxes
 
-    ActivityPub::DeliveryWorker.push_bulk(inboxes - exclude_inboxes) do |inbox_url|
-      [json, @account.id, inbox_url]
-    end
+    distribute!
   rescue ActiveRecord::RecordNotFound
     true
   end
 
-  private
+  protected
+
+  def distribute!
+    return if inboxes.empty?
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [payload, source_account_id, inbox_url, options]
+    end
+  end
+
+  def payload
+    @json
+  end
+
+  def source_account_id
+    @account.id
+  end
 
   def inboxes
-    @inboxes ||= @account.followers.inboxes
+    @inboxes ||= @account.followers.inboxes - @exclude_inboxes
+  end
+
+  def options
+    {}
   end
 end
diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
deleted file mode 100644
index d4d0148ac..000000000
--- a/app/workers/activitypub/reply_distribution_worker.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-# Obsolete but kept around to make sure existing jobs do not fail after upgrade.
-# Should be removed in a subsequent release.
-
-class ActivityPub::ReplyDistributionWorker
-  include Sidekiq::Worker
-  include Payloadable
-
-  sidekiq_options queue: 'push'
-
-  def perform(status_id)
-    @status  = Status.find(status_id)
-    @account = @status.thread&.account
-
-    return unless @account.present? && @status.distributable?
-
-    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [payload, @status.account_id, inbox_url]
-    end
-  rescue ActiveRecord::RecordNotFound
-    true
-  end
-
-  private
-
-  def inboxes
-    @inboxes ||= @account.followers.inboxes
-  end
-
-  def payload
-    @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
-  end
-end
diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb
index 3a207f071..81fde63b8 100644
--- a/app/workers/activitypub/update_distribution_worker.rb
+++ b/app/workers/activitypub/update_distribution_worker.rb
@@ -1,33 +1,24 @@
 # frozen_string_literal: true
 
-class ActivityPub::UpdateDistributionWorker
-  include Sidekiq::Worker
-  include Payloadable
-
-  sidekiq_options queue: 'push'
-
+class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker
+  # Distribute an profile update to servers that might have a copy
+  # of the account in question
   def perform(account_id, options = {})
     @options = options.with_indifferent_access
     @account = Account.find(account_id)
 
-    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [signed_payload, @account.id, inbox_url]
-    end
-
-    ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
-      [signed_payload, @account.id, inbox_url]
-    end
+    distribute!
   rescue ActiveRecord::RecordNotFound
     true
   end
 
-  private
+  protected
 
   def inboxes
-    @inboxes ||= @account.followers.inboxes
+    @inboxes ||= AccountReachFinder.new(@account).inboxes
   end
 
-  def signed_payload
-    @signed_payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with]))
+  def payload
+    @payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with]))
   end
 end
diff --git a/app/workers/admin/domain_purge_worker.rb b/app/workers/admin/domain_purge_worker.rb
new file mode 100644
index 000000000..7cba2c89e
--- /dev/null
+++ b/app/workers/admin/domain_purge_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Admin::DomainPurgeWorker
+  include Sidekiq::Worker
+
+  def perform(domain)
+    PurgeDomainService.new.call(domain)
+  end
+end
diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb
index e85cd7e95..770325ccf 100644
--- a/app/workers/distribution_worker.rb
+++ b/app/workers/distribution_worker.rb
@@ -3,10 +3,10 @@
 class DistributionWorker
   include Sidekiq::Worker
 
-  def perform(status_id)
+  def perform(status_id, options = {})
     RedisLock.acquire(redis: Redis.current, key: "distribute:#{status_id}", autorelease: 5.minutes.seconds) do |lock|
       if lock.acquired?
-        FanOutOnWriteService.new.call(Status.find(status_id))
+        FanOutOnWriteService.new.call(Status.find(status_id), **options.symbolize_keys)
       else
         raise Mastodon::RaceConditionError
       end
diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb
index 45e6bb88d..b81b09cac 100644
--- a/app/workers/feed_insert_worker.rb
+++ b/app/workers/feed_insert_worker.rb
@@ -3,9 +3,10 @@
 class FeedInsertWorker
   include Sidekiq::Worker
 
-  def perform(status_id, id, type = :home)
-    @type     = type.to_sym
-    @status   = Status.find(status_id)
+  def perform(status_id, id, type = 'home', options = {})
+    @type      = type.to_sym
+    @status    = Status.find(status_id)
+    @options   = options.symbolize_keys
 
     case @type
     when :home
@@ -25,10 +26,12 @@ class FeedInsertWorker
   private
 
   def check_and_insert
-    return if feed_filtered?
-
-    perform_push
-    perform_notify if notify?
+    if feed_filtered?
+      perform_unpush if update?
+    else
+      perform_push
+      perform_notify if notify?
+    end
   end
 
   def feed_filtered?
@@ -51,15 +54,30 @@ class FeedInsertWorker
   def perform_push
     case @type
     when :home
-      FeedManager.instance.push_to_home(@follower, @status)
+      FeedManager.instance.push_to_home(@follower, @status, update: update?)
     when :list
-      FeedManager.instance.push_to_list(@list, @status)
+      FeedManager.instance.push_to_list(@list, @status, update: update?)
     when :direct
-      FeedManager.instance.push_to_direct(@account, @status)
+      FeedManager.instance.push_to_direct(@account, @status, update: update?)
+    end
+  end
+
+  def perform_unpush
+    case @type
+    when :home
+      FeedManager.instance.unpush_from_home(@follower, @status, update: true)
+    when :list
+      FeedManager.instance.unpush_from_list(@list, @status, update: true)
+    when :direct
+      FeedManager.instance.unpush_from_direct(@account, @status, update: true)
     end
   end
 
   def perform_notify
     NotifyService.new.call(@follower, :status, @status)
   end
+
+  def update?
+    @options[:update]
+  end
 end
diff --git a/app/workers/local_notification_worker.rb b/app/workers/local_notification_worker.rb
index 6b08ca6fc..a22e2834d 100644
--- a/app/workers/local_notification_worker.rb
+++ b/app/workers/local_notification_worker.rb
@@ -12,6 +12,8 @@ class LocalNotificationWorker
       activity = activity_class_name.constantize.find(activity_id)
     end
 
+    return if Notification.where(account: receiver, activity: activity).any?
+
     NotifyService.new.call(receiver, type || activity_class_name.underscore, activity)
   rescue ActiveRecord::RecordNotFound
     true
diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb
index 53a6b87f1..4a900e3b8 100644
--- a/app/workers/move_worker.rb
+++ b/app/workers/move_worker.rb
@@ -47,16 +47,22 @@ class MoveWorker
 
   def copy_account_notes!
     AccountNote.where(target_account: @source_account).find_each do |note|
-      text = I18n.with_locale(note.account.user.locale || I18n.default_locale) do
+      text = I18n.with_locale(note.account.user&.locale || I18n.default_locale) do
         I18n.t('move_handler.copy_account_note_text', acct: @source_account.acct)
       end
 
       new_note = AccountNote.find_by(account: note.account, target_account: @target_account)
       if new_note.nil?
-        AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join("\n"))
+        begin
+          AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join("\n"))
+        rescue ActiveRecord::RecordInvalid
+          AccountNote.create!(account: note.account, target_account: @target_account, comment: note.comment)
+        end
       else
         new_note.update!(comment: [text, note.comment, "\n", new_note.comment].join("\n"))
       end
+    rescue ActiveRecord::RecordInvalid
+      nil
     rescue => e
       @deferred_error = e
     end
@@ -84,7 +90,7 @@ class MoveWorker
 
   def add_account_note_if_needed!(account, id)
     unless AccountNote.where(account: account, target_account: @target_account).exists?
-      text = I18n.with_locale(account.user.locale || I18n.default_locale) do
+      text = I18n.with_locale(account.user&.locale || I18n.default_locale) do
         I18n.t(id, acct: @source_account.acct)
       end
       AccountNote.create!(account: account, target_account: @target_account, comment: text)
diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb
index f0191d479..7613ed5f1 100644
--- a/app/workers/poll_expiration_notify_worker.rb
+++ b/app/workers/poll_expiration_notify_worker.rb
@@ -6,19 +6,44 @@ class PollExpirationNotifyWorker
   sidekiq_options lock: :until_executed
 
   def perform(poll_id)
-    poll = Poll.find(poll_id)
+    @poll = Poll.find(poll_id)
 
-    # Notify poll owner and remote voters
-    if poll.local?
-      ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id)
-      NotifyService.new.call(poll.account, :poll, poll)
-    end
+    return if does_not_expire?
+    requeue! && return if not_due_yet?
 
-    # Notify local voters
-    poll.votes.includes(:account).group(:account_id).select(:account_id).map(&:account).select(&:local?).each do |account|
-      NotifyService.new.call(account, :poll, poll)
-    end
+    notify_remote_voters_and_owner! if @poll.local?
+    notify_local_voters!
   rescue ActiveRecord::RecordNotFound
     true
   end
+
+  def self.remove_from_scheduled(poll_id)
+    queue = Sidekiq::ScheduledSet.new
+    queue.select { |scheduled| scheduled.klass == name && scheduled.args[0] == poll_id }.map(&:delete)
+  end
+
+  private
+
+  def does_not_expire?
+    @poll.expires_at.nil?
+  end
+
+  def not_due_yet?
+    @poll.expires_at.present? && !@poll.expired?
+  end
+
+  def requeue!
+    PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id)
+  end
+
+  def notify_remote_voters_and_owner!
+    ActivityPub::DistributePollUpdateWorker.perform_async(@poll.status.id)
+    NotifyService.new.call(@poll.account, :poll, @poll)
+  end
+
+  def notify_local_voters!
+    @poll.voters.merge(Account.local).find_each do |account|
+      NotifyService.new.call(account, :poll, @poll)
+    end
+  end
 end
diff --git a/app/workers/push_update_worker.rb b/app/workers/push_update_worker.rb
index d76d73d96..ae444cfde 100644
--- a/app/workers/push_update_worker.rb
+++ b/app/workers/push_update_worker.rb
@@ -2,15 +2,38 @@
 
 class PushUpdateWorker
   include Sidekiq::Worker
+  include Redisable
 
-  def perform(account_id, status_id, timeline_id = nil)
-    account     = Account.find(account_id)
-    status      = Status.find(status_id)
-    message     = InlineRenderer.render(status, account, :status)
-    timeline_id = "timeline:#{account.id}" if timeline_id.nil?
+  def perform(account_id, status_id, timeline_id = nil, options = {})
+    @account     = Account.find(account_id)
+    @status      = Status.find(status_id)
+    @timeline_id = timeline_id || "timeline:#{account.id}"
+    @options     = options.symbolize_keys
 
-    Redis.current.publish(timeline_id, Oj.dump(event: :update, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
+    publish!
   rescue ActiveRecord::RecordNotFound
     true
   end
+
+  private
+
+  def payload
+    InlineRenderer.render(@status, @account, :status)
+  end
+
+  def message
+    Oj.dump(
+      event: update? ? :'status.update' : :update,
+      payload: payload,
+      queued_at: (Time.now.to_f * 1000.0).to_i
+    )
+  end
+
+  def publish!
+    redis.publish(@timeline_id, message)
+  end
+
+  def update?
+    @options[:update]
+  end
 end
diff --git a/app/workers/remote_account_refresh_worker.rb b/app/workers/remote_account_refresh_worker.rb
new file mode 100644
index 000000000..9632936b5
--- /dev/null
+++ b/app/workers/remote_account_refresh_worker.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class RemoteAccountRefreshWorker
+  include Sidekiq::Worker
+  include ExponentialBackoff
+  include JsonLdHelper
+
+  sidekiq_options queue: 'pull', retry: 3
+
+  def perform(id)
+    account = Account.find_by(id: id)
+    return if account.nil? || account.local?
+
+    ActivityPub::FetchRemoteAccountService.new.call(account.uri)
+  rescue Mastodon::UnexpectedResponseError => e
+    response = e.response
+
+    if response_error_unsalvageable?(response)
+      # Give up
+    else
+      raise e
+    end
+  end
+end
diff --git a/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb b/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb
new file mode 100644
index 000000000..f42d4bca6
--- /dev/null
+++ b/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+class Scheduler::AccountsStatusesCleanupScheduler
+  include Sidekiq::Worker
+
+  # This limit is mostly to be nice to the fediverse at large and not
+  # generate too much traffic.
+  # This also helps limiting the running time of the scheduler itself.
+  MAX_BUDGET         = 50
+
+  # This is an attempt to spread the load across instances, as various
+  # accounts are likely to have various followers.
+  PER_ACCOUNT_BUDGET = 5
+
+  # This is an attempt to limit the workload generated by status removal
+  # jobs to something the particular instance can handle.
+  PER_THREAD_BUDGET  = 5
+
+  # Those avoid loading an instance that is already under load
+  MAX_DEFAULT_SIZE    = 2
+  MAX_DEFAULT_LATENCY = 5
+  MAX_PUSH_SIZE       = 5
+  MAX_PUSH_LATENCY    = 10
+  # 'pull' queue has lower priority jobs, and it's unlikely that pushing
+  # deletes would cause much issues with this queue if it didn't cause issues
+  # with default and push. Yet, do not enqueue deletes if the instance is
+  # lagging behind too much.
+  MAX_PULL_SIZE       = 500
+  MAX_PULL_LATENCY    = 300
+
+  # This is less of an issue in general, but deleting old statuses is likely
+  # to cause delivery errors, and thus increase the number of jobs to be retried.
+  # This doesn't directly translate to load, but connection errors and a high
+  # number of dead instances may lead to this spiraling out of control if
+  # unchecked.
+  MAX_RETRY_SIZE = 50_000
+
+  sidekiq_options retry: 0, lock: :until_executed
+
+  def perform
+    return if under_load?
+
+    budget = compute_budget
+    first_policy_id = last_processed_id
+
+    loop do
+      num_processed_accounts = 0
+
+      scope = AccountStatusesCleanupPolicy.where(enabled: true)
+      scope.where(Account.arel_table[:id].gt(first_policy_id)) if first_policy_id.present?
+      scope.find_each(order: :asc) do |policy|
+        num_deleted = AccountStatusesCleanupService.new.call(policy, [budget, PER_ACCOUNT_BUDGET].min)
+        num_processed_accounts += 1 unless num_deleted.zero?
+        budget -= num_deleted
+        if budget.zero?
+          save_last_processed_id(policy.id)
+          break
+        end
+      end
+
+      # The idea here is to loop through all policies at least once until the budget is exhausted
+      # and start back after the last processed account otherwise
+      break if budget.zero? || (num_processed_accounts.zero? && first_policy_id.nil?)
+      first_policy_id = nil
+    end
+  end
+
+  def compute_budget
+    threads = Sidekiq::ProcessSet.new.filter { |x| x['queues'].include?('push') }.map { |x| x['concurrency'] }.sum
+    [PER_THREAD_BUDGET * threads, MAX_BUDGET].min
+  end
+
+  def under_load?
+    return true if Sidekiq::Stats.new.retry_size > MAX_RETRY_SIZE
+    queue_under_load?('default', MAX_DEFAULT_SIZE, MAX_DEFAULT_LATENCY) || queue_under_load?('push', MAX_PUSH_SIZE, MAX_PUSH_LATENCY) || queue_under_load?('pull', MAX_PULL_SIZE, MAX_PULL_LATENCY)
+  end
+
+  private
+
+  def queue_under_load?(name, max_size, max_latency)
+    queue = Sidekiq::Queue.new(name)
+    queue.size > max_size || queue.latency > max_latency
+  end
+
+  def last_processed_id
+    Redis.current.get('account_statuses_cleanup_scheduler:last_account_id')
+  end
+
+  def save_last_processed_id(id)
+    if id.nil?
+      Redis.current.del('account_statuses_cleanup_scheduler:last_account_id')
+    else
+      Redis.current.set('account_statuses_cleanup_scheduler:last_account_id', id, ex: 1.hour.seconds)
+    end
+  end
+end
diff --git a/app/workers/scheduler/follow_recommendations_scheduler.rb b/app/workers/scheduler/follow_recommendations_scheduler.rb
index cb1e15961..effc63e59 100644
--- a/app/workers/scheduler/follow_recommendations_scheduler.rb
+++ b/app/workers/scheduler/follow_recommendations_scheduler.rb
@@ -16,12 +16,12 @@ class Scheduler::FollowRecommendationsScheduler
     AccountSummary.refresh
     FollowRecommendation.refresh
 
-    fallback_recommendations = FollowRecommendation.limit(SET_SIZE).index_by(&:account_id)
+    fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE).index_by(&:account_id)
 
     I18n.available_locales.each do |locale|
       recommendations = begin
         if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
-          FollowRecommendation.localized(locale).limit(SET_SIZE).index_by(&:account_id)
+          FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).index_by(&:account_id)
         else
           {}
         end
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
index 918c10ac9..adc99c605 100644
--- a/app/workers/scheduler/ip_cleanup_scheduler.rb
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -16,7 +16,7 @@ class Scheduler::IpCleanupScheduler
 
   def clean_ip_columns!
     SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
-    User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil)
+    User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(sign_up_ip: nil)
     LoginActivity.where('created_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
   end
 
diff --git a/app/workers/scheduler/trending_tags_scheduler.rb b/app/workers/scheduler/trends/refresh_scheduler.rb
index 94d76d010..b559ba46b 100644
--- a/app/workers/scheduler/trending_tags_scheduler.rb
+++ b/app/workers/scheduler/trends/refresh_scheduler.rb
@@ -1,11 +1,11 @@
 # frozen_string_literal: true
 
-class Scheduler::TrendingTagsScheduler
+class Scheduler::Trends::RefreshScheduler
   include Sidekiq::Worker
 
   sidekiq_options retry: 0
 
   def perform
-    TrendingTags.update! if Setting.trends
+    Trends.refresh!
   end
 end
diff --git a/app/workers/scheduler/trends/review_notifications_scheduler.rb b/app/workers/scheduler/trends/review_notifications_scheduler.rb
new file mode 100644
index 000000000..f334261bd
--- /dev/null
+++ b/app/workers/scheduler/trends/review_notifications_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Scheduler::Trends::ReviewNotificationsScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options retry: 0
+
+  def perform
+    Trends.request_review!
+  end
+end
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index be0c4277d..750d2127b 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -8,6 +8,7 @@ class Scheduler::UserCleanupScheduler
   def perform
     clean_unconfirmed_accounts!
     clean_suspended_accounts!
+    clean_discarded_statuses!
   end
 
   private
@@ -24,4 +25,12 @@ class Scheduler::UserCleanupScheduler
       Admin::AccountDeletionWorker.perform_async(deletion_request.account_id)
     end
   end
+
+  def clean_discarded_statuses!
+    Status.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
+      RemovalWorker.push_bulk(statuses) do |status|
+        [status.id, { 'immediate' => true }]
+      end
+    end
+  end
 end